Make one-time redemptions safe to retry
I built a check-in flow where a parent prepares a short code on their phone, and a shared desk or kiosk redeems it when the family walks up. Scan or type the code, confirm the kids and rooms, print matching tags. It feels like a single button press.
Under the hood it is a one-time write that has to survive a flaky network, a double tap, a lost response, and two volunteers trying the same code at two stations. If you treat “mark used, then create attendance” as a happy-path script, you get double bookings on retry and stuck families when the write dies mid-flight.
The pattern that held up: claim the code with a short lock, key every attempt with a client submission id, and store the successful result on the code so a retry returns the original outcome instead of inventing a second one.
What goes wrong without that shape
Three failure modes showed up as soon as I exercised the real path:
- Lost response. The desk confirms, the server writes attendance, the browser never hears back. The volunteer taps again. Without care, that is a second arrival for the same kids.
- Concurrent stations. Two devices redeem the same code a second apart. Both pass a
naive
if not usedcheck, both write. - Partial failure. You mark the code used, then the attendance write throws. Now the family is locked out and not checked in.
“Just make it transactional” helps when everything lives in one database transaction. It does not help when the client needs a clear answer after a timeout, or when a second station has to be told “wait, someone else has this.”
Claim first, with a short lock
Before any attendance write, the server tries to claim the unused code with a compare-and-set update. Only one claim wins. Everyone else gets a conflict that means “another station is handling this family,” not “the code is burned forever.”
async function claimCode(codeId, token, now) {
const until = new Date(now.getTime() + 2 * 60 * 1000);
const claimed = await db.codes.findOneAndUpdate(
{
id: codeId,
used: false,
$or: [{ lockUntil: null }, { lockUntil: { $lt: now.toISOString() } }],
},
{ $set: { lock: token, lockUntil: until.toISOString() } },
);
if (!claimed) {
throw conflict('This family is being checked in elsewhere. Wait and try again.');
}
return claimed;
}
Two minutes is long enough for a slow confirm and short enough that a crashed tab does not
park the family until someone clears state by hand. The lock is a lease, not a permanent
flag. When it expires, another station can claim. When the winner finishes (or fails in
finally), it clears lockUntil so a deliberate retry does not wait out the clock.
Key the attempt from the client
The desk generates a clientSubmissionId when it starts a redemption and sends the same
id on every retry of that attempt. The server stores it on the code once the claim
succeeds.
That id is what turns “try again” into “resume,” and a different id into “someone else”:
async function redeem(code, body, perform) {
const submission = String(body.clientSubmissionId || '').trim();
if (!submission) throw unprocessable('Restart this check-in and try again.');
const pre = await loadPrepared(code);
if (pre.used) {
if (pre.arrivalSubmissionId === submission && pre.arrivalResult) {
return { ...pre.arrivalResult, duplicate: true };
}
throw conflict('This code was already used. Check the roster before starting over.');
}
// A retry from another device resumes the original selection.
if (pre.arrivalSubmissionId && pre.arrivalSubmissionId !== submission) {
body = { ...body, clientSubmissionId: pre.arrivalSubmissionId };
}
const token = newId();
await claimCode(pre.id, token, new Date());
try {
const payload = pre.arrivalPayload || body;
const result = await perform(payload);
await db.codes.updateOne(
{ id: pre.id, lock: token },
{
$set: {
used: true,
usedAt: new Date().toISOString(),
arrivalSubmissionId: submission,
arrivalResult: result,
arrivalPayload: payload,
},
},
);
return result;
} finally {
await db.codes.updateOne({ id: pre.id, lock: token }, { $set: { lockUntil: null } });
}
}
A few properties fall out of that:
- Same submission id after success returns the stored result with a
duplicateflag. The UI can show the tags again without creating a second attendance row. - Different submission id after success is a hard conflict. The code is spent; do not silently attach a new family to it.
- Same code, different station, first write still in flight loses the claim and gets the “wait” conflict instead of a second booking.
- Persisting
arrivalPayloadmeans an interrupted write can resume with the children and rooms the first volunteer already confirmed, not whatever the second screen happens to send.
Fail closed on meaning, open on recovery
I am stricter about what the code means than about whether the network cooperated.
Unknown, replaced, wrong-service, expired, and already-used codes each get their own status and a message a volunteer can act on. Prepared arrivals never fall into an offline queue: confirming without the server would invent attendance the desk cannot stand behind. Typing still works when the camera permission is denied; that is a capture fallback, not an authority fallback.
Recovery is deliberately boring. Deterministic attendance ids (derived from the prepared code and the attempt) let a resume find the half-written row instead of inserting a twin. The lock expires. The stored result is the source of truth for “did this already happen.” There is no admin “force reuse” in the hot path; that belongs in a separate recovery tool with an audit trail.
Tradeoffs I accepted
- A lease instead of a forever lock. A crashed client can block the code for up to two minutes. That is better than requiring an operator to clear a stuck flag during a rush, and better than no lock at all.
- Storing the full result on the code. It duplicates a bit of attendance data, and it makes the code document the cache for retries. The alternative is re-querying attendance by a derived key and hoping the shape still matches what the UI needs to reprint tags.
- Client-generated submission ids. A buggy client could send a new id on every tap and look like a different attempt. The UI has to treat the id as sticky for the lifetime of that confirmation screen. Server-generated ids only work if you have a reliable place to round-trip them before the first write.
- No offline redemption for this path. Families with a prepared code wait for the network. The cached family list still covers the walk-up case; prepared codes are the online fast lane.
The checklist
- Separate claim from commit. Win a short lock before you create the durable side effect.
- Give every attempt a client submission id and send the same one on retries.
- On success, store the result (and the selection) on the one-time record so a retry can return it.
- Treat a second submission id after success as a conflict, not a new booking.
- Expire the lock and clear it in
finallyso failure is recoverable without a human. - Return actionable errors for unknown, spent, wrong-context, and in-flight codes.
Gift cards, invite tokens, ticket QR codes, and seat holds all share this shape. The product difference is what you create after the claim. The reliability difference is whether a retry is a resume or a second charge.