What Is the Gossip Sync Protocol? How Bitchat Synchronizes Messages in the Mesh
The gossip sync protocol is a bandwidth-efficient, probabilistic synchronization mechanism that uses Golomb-Coded Set (GCS) filters to exchange missing broadcast packets between peers, ensuring eventual consistency across the mesh without requiring centralized servers.
The gossip sync protocol powers decentralized message propagation in Bitchat, an open-source, permissionless mesh messaging application developed by permissionlesstech. By combining periodic maintenance cycles with compact probabilistic filters, the protocol enables nodes to discover and exchange missing messages efficiently while bounding bandwidth and storage usage. This article examines the protocol's architecture and implementation based on the Bitchat source code.
Core Architecture and Components
The protocol centers on the GossipSyncManager class defined in bitchat/Sync/GossipSyncManager.swift. This manager orchestrates packet storage, filter construction, and synchronization scheduling across the mesh network.
Packet Stores and LRU Caching
The manager maintains dedicated PacketStore instances for each broadcast type—messages, fragments, file transfers, group messages, pre-key bundles, announces, and board posts. These stores function as bounded LRU caches that retain only the freshest entries according to configurable capacity limits.
When a broadcast packet arrives, the manager verifies its MessageType and freshness via isPacketFresh, which compares the packet timestamp against maxMessageAgeSeconds. Valid packets are inserted using deterministic IDs generated by PacketIdUtil.computeId:
func _onPublicPacketSeen(_ packet: BitchatPacket) {
guard let messageType = MessageType(rawValue: packet.type) else { return }
switch messageType {
case .message:
guard isBroadcastRecipient && isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
archiveDirty = true
// ... similar handling for .fragment, .fileTransfer, .groupMessage, etc.
}
}
Golomb-Coded Set (GCS) Filters
The protocol uses GCS filters to create space-efficient, probabilistic representations of packet ID sets. During each sync round, the manager constructs a filter fitting within gcsMaxBytes that encodes the set of packet IDs known to the node. The filter parameters—p, m, and data—are transmitted in REQUEST_SYNC payloads to minimize bandwidth consumption compared to sending full ID lists.
The Synchronization Lifecycle
Periodic Maintenance and Scheduling
Every maintenanceIntervalSeconds, the manager executes performPeriodicMaintenance, which iterates over syncSchedules—one schedule per packet-type group. For each due schedule, the manager calls sendPeriodicSync, dispatching either a unicast to known peers or a broadcast request:
private func performPeriodicMaintenance(now: Date = Date()) {
// ... cleanup logic omitted
for index in syncSchedules.indices {
guard syncSchedules[index].interval > 0 else { continue }
if syncSchedules[index].lastSent == .distantPast ||
now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
syncSchedules[index].lastSent = now
sendPeriodicSync(for: syncSchedules[index].types)
}
}
}
Building the REQUEST_SYNC Payload
The buildGcsPayload(for:types) method gathers candidate packets, sorts them newest-first, and constructs the filter. If the store exceeds the byte budget, only the most recent takeN packets are included, and the manager records a sinceTimestamp cursor representing the oldest included packet. This cursor allows responders to skip older packets that the requester already possesses:
private func buildGcsPayload(for types: SyncTypeFlags, fragmentIdFilter: String? = nil) -> Data {
var candidates: [BitchatPacket] = []
if types.contains(.message) {
candidates.append(contentsOf: messages.allPackets(isFresh: isPacketFresh))
}
// ... add other types
candidates.sort { $0.timestamp > $1.timestamp }
let ids = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
let sinceTimestamp = // derived from params.includedCount
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data,
types: types, sinceTimestamp: sinceTimestamp,
fragmentIdFilter: fragmentIdFilter)
return req.encode()
}
Handling Inbound Sync Requests
When a peer transmits a REQUEST_SYNC, the manager processes it via _handleRequestSync. The implementation first checks SyncResponseRateLimiter to prevent DoS attacks that could force expensive full-store diffs. It then decodes the peer's GCS filter and iterates local packets, using mightContain to identify missing items. For each missing packet, the manager sends a solicited response marked with isRSR = true and ttl = 0 (local-only propagation):
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
guard responseRateLimiter.shouldRespond(to: peerID, now: Date()) else { return }
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
func mightContain(_ id: Data) -> Bool { /* ... */ }
if request.types.contains(.message) {
for pkt in messages.allPackets(isFresh: isPacketFresh) where pkt.timestamp >= request.sinceTimestamp {
if !mightContain(PacketIdUtil.computeId(pkt)) {
var toSend = pkt; toSend.ttl = 0; toSend.isRSR = true
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
}
// ... similar blocks for other types
}
Notably, announces and pre-key bundles are exempt from the sinceTimestamp cursor because they are rare and bounded, ensuring new peers can always obtain critical cryptographic material.
Persistence and Hygiene Mechanisms
GossipMessageArchive
Public messages persist to disk via GossipMessageArchive (bitchat/Sync/GossipMessageArchive.swift), enabling the app to resume gossip sync with a partially-filled store after restart. The manager calls persistNow before backgrounding and restoreArchivedMessages during initialization.
Rate Limiting and Cleanup
The SyncResponseRateLimiter (bitchat/Sync/SyncResponseRateLimiter.swift) restricts how frequently a specific peer can trigger synchronization responses. Additionally, cleanupStaleAnnouncements prunes expired peer announcements after stalePeerTimeoutSeconds, and the manager supports removing specific peers' messages (e.g., on block) or wiping the entire archive when the user clears the timeline.
Integration with the Network Stack
The BLEService (bitchat/Services/BLE/BLEService.swift) instantiates the GossipSyncManager, forwards inbound packets via onPublicPacketSeen, and invokes scheduleInitialSyncToPeer when new peers connect. The RequestSyncManager tracks outbound requests to protect against replay attacks, while NoiseEncryptionService consumes gossiped pre-key bundles for end-to-end encryption setup.
// Typical integration pattern within BLEService
let syncMgr = GossipSyncManager(
myPeerID: myPeerID,
requestSyncManager: requestSyncManager,
archive: GossipMessageArchive(path: archivePath)
)
syncMgr.delegate = self
syncMgr.start()
// Handle incoming mesh traffic
func handleIncoming(_ pkt: BitchatPacket) {
syncMgr.onPublicPacketSeen(pkt)
}
// Trigger sync with newly discovered peers
func peerDidConnect(_ peerID: PeerID) {
syncMgr.scheduleInitialSyncToPeer(peerID, delaySeconds: 3.0)
}
Summary
- GossipSyncManager orchestrates mesh-wide synchronization from
bitchat/Sync/GossipSyncManager.swift, managing per-type LRU caches and periodic maintenance cycles. - GCS filters provide compact, probabilistic membership testing via parameters
p,m, anddata, significantly reducing sync bandwidth compared to full ID enumeration. - REQUEST_SYNC packets carry filter data and
sinceTimestampcursors to enable incremental synchronization, while rare packets like pre-key bundles bypass temporal filtering. - SyncResponseRateLimiter prevents resource exhaustion by throttling expensive diff operations from aggressive peers.
- GossipMessageArchive ensures gossip state survives application restarts, restoring the packet store from disk on launch.
Frequently Asked Questions
How does the gossip sync protocol handle stale or expired messages?
The protocol validates packet freshness via isPacketFresh before insertion into the PacketStore, rejecting packets older than maxMessageAgeSeconds (with extended windows for public messages). During sync responses, the sinceTimestamp cursor ensures peers only evaluate messages newer than the filter's oldest entry, while the LRU cache automatically evicts aged entries to bound memory usage.
What is a GCS filter and why does Bitchat use it for synchronization?
A GCS (Golomb-Coded Set) filter is a space-efficient probabilistic data structure that tests set membership with a configurable false-positive rate. Bitchat encodes known packet IDs into these filters using GCSFilter.buildFilter to fit within gcsMaxBytes, allowing peers to compare state using kilobytes rather than megabytes of raw ID lists, which is critical for bandwidth-constrained mesh networks.
How does the protocol prevent malicious peers from overwhelming the network with sync requests?
The SyncResponseRateLimiter (bitchat/Sync/SyncResponseRateLimiter.swift) tracks request frequency per peer ID via shouldRespond. If a peer attempts to trigger full-store diffs too frequently, the manager drops the request, preventing computational denial-of-service attacks that could exhaust battery or bandwidth on mobile mesh nodes.
What happens to gossip state when the Bitchat app restarts?
The GossipMessageArchive persists recent public messages to disk before the application backgrounds. On launch, restoreArchivedMessages repopulates the PacketStore instances, allowing the node to resume gossip sync immediately without re-downloading messages it already possesses, ensuring continuity in the mesh's eventual consistency model.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →