When I started building an app that tracks how busy venues are in real time, I thought the crowd counter would be the easy part. The idea was simple: use geofences to passively detect when a user walks into a bar or restaurant, increment a headcount, and decrement it when they leave. No manual check-ins, everything automatic in the background.
After the first real-world test I had venues showing negative headcounts, counts stuck way too high, and numbers that never moved. Every one of those was a different bug, and fixing them taught me that a counter this simple on paper has a lot of ways to go wrong in production.
Here's every failure mode I ran into and how I fixed each one.
The first thing I noticed was that walking into a venue sometimes incremented the counter twice. iOS fires duplicate didEnterRegion and didExitRegion callbacks near boundaries. The device oscillates around the edge and the system triggers the event more than once. This is just how geofence monitoring behaves in the real world.
The fix is a debounce on the client. Before any network request goes out, I check how long it's been since the last event of the same type. If it's under 10 seconds, I drop it:
let now = Date()
if let last = lastEntryEventTime, now.timeIntervalSince(last) < 10 { return }
lastEntryEventTime = now
My first version of the debounce used one shared timer for both entry and exit events. That introduced a new bug.
Picture this: a user quickly crosses a boundary, entry fires, and then six seconds later the exit fires. The shared timer resets on entry, so when the exit comes in it's only been six seconds and gets dropped. The user left the venue and the counter stayed stuck.
Keep two independent timestamps, one for entry and one for exit, so each type only debounces itself:
private var lastEntryEventTime: Date?
private var lastExitEventTime: Date?
The debounce reduces duplicates but some still get through. An app relaunch, a crash and restart, or weird timing between events can all let a duplicate entry reach the server.
So I added a second layer of validation server-side. Instead of only storing the headcount on the venue, I keep a separate deviceCheckins document that records which venue each device is currently inside:
// deviceCheckins/{deviceId}
{
currentHotspotId: "venue_abc",
updated: "2026-06-10T21:00:00Z"
}
Before touching the counter, the server checks this record. If the device is already recorded as inside that venue, the write is rejected and the count stays where it is:
if (deviceState?.currentHotspotId === hotspotId) {
return { success: true, updated: false };
}
The client gets a success response either way. The distinction is handled entirely server-side.
This one took me a while to notice. Everything looked fine until I started seeing venues with headcounts that crept up over several days and never came back down.
What was happening: if a user's app got killed mid-visit, or their phone died, the exit event never fired. The device stayed permanently recorded as inside a venue and the count was stuck one too high with nothing to correct it.
Exit events sometimes never arrive, so I added an auto-exit on the server. Whenever a device checks into a new venue, the server checks if it's currently recorded somewhere else. If it is, it exits that venue first:
if (deviceState?.currentHotspotId && deviceState.currentHotspotId !== hotspotId) {
transaction.update(prevHotspotRef, {
headcount: Math.max(0, prevCount - 1)
});
}
A user can only be in one place at a time. Enforcing that server-side means every missed exit self-heals the next time the user goes somewhere new.
That covers one version of this problem. The other version: a user leaves a venue, goes home, and stops using the app. They make no new check-in, so auto-exit has nothing to act on. Their device stays recorded as inside a venue indefinitely.
The fix for this is a scheduled cleanup job that runs every hour. It scans all deviceCheckins documents and decrements any venue where the check-in is older than six hours:
const sixHoursAgo = new Date(now.getTime() - (6 * 60 * 60 * 1000));
for (const doc of snapshot.docs) {
if (!data.currentHotspotId) continue;
if (data.updated >= sixHoursAgoISO) continue;
await db.runTransaction(async (transaction) => {
// Re-read before writing to avoid race conditions
const freshDevice = await transaction.get(doc.ref);
const freshHotspot = await transaction.get(hotspotRef);
transaction.update(doc.ref, { currentHotspotId: null });
if (freshCount > 0) {
transaction.update(hotspotRef, {
headcount: Math.max(0, freshCount - 1)
});
}
});
}
Six hours is a judgment call for the kind of venues the app covers. For a different use case that number would need tuning. Without some upper bound, stale check-ins accumulate and the counts drift upward permanently.
Two devices check in simultaneously, both read the same count, both write count + 1, and one increment gets lost:
Device A reads count: 5
Device B reads count: 5
Device A writes: 6
Device B writes: 6 // should be 7
I wrapped every write in a Firestore transaction. The transaction reads both the device state and the hotspot count atomically and writes them together. If another write touches either document mid-transaction, Firestore retries it automatically:
await db.runTransaction(async (transaction) => {
const deviceDoc = await transaction.get(deviceStateRef);
const hotspotDoc = await transaction.get(hotspotRef);
// all reads first, then all writes
transaction.set(deviceStateRef, { currentHotspotId: hotspotId, ... });
transaction.update(hotspotRef, { headcount: currentCount + 1 });
});
With proper state tracking most bad decrements get caught upstream, but I still wanted a hard floor as a last line of defense. Data gets corrupted from a bad deploy, a manual Firestore edit, or an edge case you missed. Every decrement clamps at zero:
const newCount = Math.max(0, currentCount - 1);
A counter showing zero when people are inside is a minor UX issue. A counter showing -4 is a trust issue.
The last edge case I ran into was subtler. Sometimes a device record would show a user as checked in, but the counter for that venue was already at zero. Case 6 stops the decrement, but the stale device record causes problems on the next event from that device.
The fix was to always clear the device state, even when skipping the decrement:
if (currentCount <= 0) {
transaction.set(deviceStateRef, { currentHotspotId: null, ... });
return { success: true, updated: false };
}
The record gets corrected without touching the count, and the next event from that device is treated as a clean slate.
What I learned
- The client debounce cuts down the volume of duplicate requests reaching the server.
- Server-side state tracking rejects the duplicates that get through anyway.
- Auto-exit handles missed exits. A user can only be in one place at a time, and enforcing that server-side means every missed exit self-heals on the next check-in.
- Transactions keep simultaneous writes consistent without any extra coordination logic.
- The floor and state correction clean up whatever is left. All of these work together.
Building this out was one of the more interesting problems in the whole app. On the surface it's just a number going up and down, but keeping that number accurate under real conditions meant thinking through every way it could drift, and handling each one separately.