# How the Geohash Presence Service Tracks Nearby Users in BitChat

> Discover how the geohash presence service tracks nearby users in BitChat by broadcasting ephemeral Nostr events to geohash channels, ensuring privacy with randomized delays and derived identities.

- Repository: [permissionlesstech/bitchat](https://github.com/permissionlesstech/bitchat)
- Tags: architecture
- Published: 2026-07-31

---

**The geohash presence service tracks nearby users by broadcasting ephemeral Nostr Kind 20001 events to low-precision geohash channels every 40–80 seconds, using randomized delays and derived identities to prevent correlation attacks.**

The **geohash presence service** powers location-based discovery in the BitChat messaging app (`permissionlesstech/bitchat`) while enforcing strict privacy boundaries. As implemented in [`GeohashPresenceService.swift`](https://github.com/permissionlesstech/bitchat/blob/main/GeohashPresenceService.swift), the system uses the Nostr protocol to announce user presence in geographic zones without exposing precise coordinates or correlating activity across different precision levels.

## Three-Phase Location Tracking Architecture

The service operates through a continuous cycle of detection, scheduling, and broadcasting that maintains an up-to-date view of nearby participants.

### Detecting Location Changes

The service subscribes to `LocationStateManager.shared.$availableChannels` to monitor when the device enters or exits geohash zones. When the set of available channels changes, the service schedules an immediate heartbeat after a 5-second debounce period, ensuring the user is promptly announced in new areas.

### Randomized Heartbeat Scheduling

While the app is active and Tor is ready, the service runs a looping timer with intervals randomized between **40 seconds** and **80 seconds** (`loopMinInterval` to `loopMaxInterval`). Each iteration performs the following steps:

1. Filters current channels to include only precision levels 2 (region), 4 (province), and 5 (city)
2. Launches independent `Task` instances for each permitted channel
3. Each task sleeps for a random **2–5 seconds** (`burstMinDelay` to `burstMaxDelay`) before broadcasting

This temporal decorrelation prevents observers from linking presence broadcasts across different geohash levels to a single identity.

```swift
func scheduleNextHeartbeat() {
    guard started else { return }
    heartbeatTimer?.invalidate()
    let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
    let generation = heartbeatGeneration
    heartbeatTimer = scheduleTimer(interval) { [weak self] in
        Task { @MainActor [weak self] in
            guard let self,
                  self.started,
                  self.heartbeatGeneration == generation else { return }
            self.performHeartbeat()
        }
    }
}

```

### Broadcasting Presence Events

For each allowed geohash, the service derives a short-lived identity via `deriveIdentity(forGeohash:)` and constructs a Kind 20001 event using `NostrProtocol.createGeohashPresenceEvent`. The event is sent to the nearest relays determined by `relayLookup` and transmitted via `relaySender`.

```swift
func broadcastPresence(for geohash: String) {
    do {
        guard let identity = try? deriveIdentity(geohash) else { return }
        let event = try NostrProtocol.createGeohashPresenceEvent(
            geohash: geohash,
            senderIdentity: identity
        )
        let targetRelays = relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
        if !targetRelays.isEmpty {
            relaySender(event, targetRelays)
        }
    } catch {
        SecureLogger.error("Presence: failed to create event for \(geohash): \(error)")
    }
}

```

## Privacy Protections in Presence Tracking

The implementation enforces multiple safeguards to prevent location tracking and identity correlation.

### Geohash Precision Filtering

The service strictly filters broadcasts using an `allowedPrecisions` set that excludes high-precision geohashes. Only region (2 characters), province (4 characters), and city (5 characters) levels receive presence announcements. High-precision levels such as neighbourhood, block, and building are never broadcast, ensuring exact locations remain private.

### Temporal Decorrelation

To prevent timing analysis attacks, each heartbeat iteration introduces randomized delays. The main loop varies between 40–80 seconds, while individual channel broadcasts add randomized 2–5 second delays before transmission. This "burst" decorrelation ensures that presence events for different geohash levels occur at uncorrelated times.

```swift
for channel in channels {
    // privacy restriction – only low‑precision levels are allowed
    if !allowedPrecisions.contains(channel.geohash.count) { continue }

    let taskID = UUID()
    let delay = TimeInterval.random(in: burstMinDelay...burstMaxDelay)
    let nanoseconds = UInt64(delay * 1_000_000_000)

    let task = Task { @MainActor [weak self] in
        await sleeper(nanoseconds)            // random 2‑5 s delay
        guard let self,
              !Task.isCancelled,
              self.started,
              self.heartbeatGeneration == generation else { return }
        self.broadcastPresence(for: channel.geohash)
    }
    pendingBroadcastTasks[taskID] = task
}

```

## Counting Online Participants

Incoming events are processed by `GeohashMessageHandler`, which maintains a mapping of `pubkey → lastSeenTimestamp`. A participant is considered online if their last seen timestamp is within the last **5 minutes**. The handler processes both Kind 20000 chat messages and Kind 20001 presence heartbeats to update this state continuously.

The UI layer displays exact counts for low-precision channels where presence is actively broadcast, while showing "`[? people]`" for high-precision channels where privacy restrictions prevent presence announcements.

## Summary

- The **geohash presence service** uses ephemeral Nostr Kind 20001 events to announce user presence in low-precision geohash zones.
- Heartbeats run on randomized 40–80 second intervals with additional 2–5 second decorrelation delays to prevent identity correlation.
- Only region, province, and city level geohashes (precision 2, 4, and 5) receive broadcasts; neighbourhood, block, and building locations remain private.
- `GeohashMessageHandler` counts participants as online when seen within the last 5 minutes, updating the UI accordingly.
- All broadcast logic resides in [`GeohashPresenceService.swift`](https://github.com/permissionlesstech/bitchat/blob/main/GeohashPresenceService.swift) with specifications documented in [`GeohashPresenceSpec.md`](https://github.com/permissionlesstech/bitchat/blob/main/GeohashPresenceSpec.md).

## Frequently Asked Questions

### What prevents the geohash presence service from leaking exact locations?

The service filters broadcasts using an `allowedPrecisions` set that excludes high-precision geohashes. Only region (2 characters), province (4 characters), and city (5 characters) levels receive presence events, while neighbourhood, block, and building coordinates remain unannounced according to the privacy rules defined in [`GeohashPresenceSpec.md`](https://github.com/permissionlesstech/bitchat/blob/main/GeohashPresenceSpec.md).

### How does the service prevent timing correlation attacks?

Each heartbeat iteration introduces randomization at two levels: the main loop varies between 40–80 seconds (`loopMinInterval` to `loopMaxInterval`), and individual channel broadcasts add randomized 2–5 second delays (`burstMinDelay` to `burstMaxDelay`). This temporal decorrelation makes it difficult to link multiple presence events to a single identity.

### Which Nostr event kinds does the presence system use?

The system broadcasts Kind 20001 (`GEOHASH_PRESENCE`) events for presence announcements and listens for both Kind 20001 and Kind 20000 (`GEO_CHAT`) events to determine if a user is online within the last 5 minutes.

### Where is the presence logic implemented in the BitChat codebase?

The core implementation lives in [`GeohashPresenceService.swift`](https://github.com/permissionlesstech/bitchat/blob/main/GeohashPresenceService.swift), which handles scheduling, identity derivation via `deriveIdentity(forGeohash:)`, and broadcasting. Event processing and user counting occur in [`GeohashMessageHandler.swift`](https://github.com/permissionlesstech/bitchat/blob/main/GeohashMessageHandler.swift), with design specifications detailed in [`GeohashPresenceSpec.md`](https://github.com/permissionlesstech/bitchat/blob/main/GeohashPresenceSpec.md).