backend
Offline sync that's actually safe to replay
Offline sync that's actually safe to replay
I built this for hardware that loses connectivity but still has to work — a kiosk, a counter terminal. The device queues actions locally and replays them once it's back online.
Queuing is trivial. Replaying safely is where the real design is, because a naive replay endpoint has two failure modes:
- Duplicate processing. The device retries after a dropped response and sends the same event twice.
- Stale conflicts. The device queued an action against state that has since changed — it wants to "call" a token another device already cancelled.
Two layers of idempotency
Layer one — raw ID dedup. Every event carries a client-generated ID. Check whether it's already been processed before doing anything else. This catches the simple "sent twice because no response came back" case.
Layer two — semantic idempotency, per event type. The ID check misses logically conflicting events that don't share an ID. This needs type-specific validation: a create event checks whether something with the same natural key already exists; a transition event checks whether the current state actually permits the move. This is the layer that stops real corruption, not just duplicate rows.
Each event's checks, its application, and its audit record go in one transaction — so I can't end up with an event applied but never recorded (reprocessed next sync), or recorded but never applied.
await prisma.$transaction(async (tx) => {
const existing = await tx.device_events.findUnique({ where: { event_id } });
if (existing) return { status: "duplicate" };
const check = await checkSemanticIdempotency(event, tx);
if (!check.canProceed) return { status: "rejected", reason: check.reason };
await applyEvent(event, tx);
await tx.device_events.create({ data: { event_id, ... } });
return { status: "accepted" };
}, { timeout: 10000 });
Don't return pass/fail for the batch
A device replaying its queue needs to know which events were accepted and which were rejected and why — so it can clear the accepted ones locally and decide what to do with the rest. A structured result (accepted, rejected: [{eventId, reason}], per-event audit) is what makes the endpoint usable instead of an all-or-nothing black box.
If the sync affects derived state — a queue ordering, a cached count — recomputing it once after the whole batch is cheaper and safer than nudging it per event. Incremental updates across a batch of potentially conflicting offline events drift; a rebuild from source truth doesn't.
The whole thing works fine in testing if you skip a layer. It breaks in exactly the scenario the feature exists for.