Design a QR code event check-in app
The door-staff app of an event platform: staff point a phone at an attendee's ticket QR code and see a big green or red answer in under a second, even when the venue Wi-Fi is dead. The hard parts are a ticket payload that can be checked without a network, fast and reliable camera scanning in the browser, an attendee list stored on the phone before doors open, and catching the same ticket scanned at two doors.
The question
Design the check-in app that door staff use at a conference or concert. Each attendee shows a QR code on their phone or on paper. A staff member scans it with their own phone, and the app says whether to let them in. A big event has 20,000 attendees, 6 doors, and 30 staff phones scanning at once.
Venue Wi-Fi is often overloaded and the basement hall has no signal at all. Walk me through what is inside the QR code, how the camera reads it fast, how the app works with no network, and how you stop one ticket from getting two people in through two different doors.
Real-life examples: Eventbrite Organizer app, Tito Check-in, Luma check-in, Ticket Tailor Box Office
Requirements: what to ask before drawing anything
A check-in app looks tiny: one camera view and one answer. It is really four problems stacked together. It is a camera app that has to read a code in a dark doorway, a verification system that decides if a ticket is real, an offline database that must already be on the phone when the doors open, and a multi-device sync problem, because 30 phones are deciding about the same 20,000 tickets at the same time.
Spend the first five minutes on questions whose answers change the design. The table lists the questions this guide asks, the answer it assumes, and what each answer changes.
| Question | Answer we assume | What it changes |
|---|---|---|
| How big are events? | From a 50-person meetup to a 50,000-person festival. We design for 20,000 tickets and check the 50,000 case. | The attendee list must fit on a phone. Size math for 50,000 rows decides what we store. |
| How many doors and devices? | Up to 10 doors and 40 staff phones per event, mixing iPhones and Android phones the staff bring themselves. | Several devices decide about the same ticket. We need a sync story and per-device identity. |
| Is the network reliable? | No. Assume Wi-Fi that drops for minutes and halls with no signal at all. | Every scan must be decided on the phone. The network is only for sync, never for the answer. |
| Native app or web? | A web app (a PWA) so staff can open a link on any phone with no app store step. | Camera access through the browser, decoding in JavaScript or WebAssembly, and iOS Safari limits. |
| What does the ticket look like? | A QR code in an email, in a wallet pass, or printed on paper. We control what goes inside it. | We can choose the payload: a random id or a signed token. |
| Can people leave and come back? | It depends on the event. The organizer turns re-entry on or off. | Check-in is not a one-way flag. We need check-out, undo, and a policy per event. |
| Are there ticket types? | Yes: General, VIP, Speaker, Day 2 only. Some doors accept only some types. | A valid ticket can still be rejected at the wrong door or on the wrong day. |
| How fast is the queue? | Doors open and 2,000 people arrive in 30 minutes. | About 1 person per second across all doors. Each scan must take a couple of seconds at most. |
Functional requirements
- Staff sign in, pick the event and their door, and download the event for offline use before doors open.
- Scan a ticket QR code with the rear camera and show a full-screen result: admitted, already checked in, wrong door or day, or not a valid ticket.
- Search attendees by name, email, or order number when a code will not scan or the attendee has no phone.
- Undo a check-in made by mistake, and check a person out when re-entry is allowed.
- Show live counts: checked in versus expected, for the whole event and per door.
- Keep working with no network, and sync check-ins with the server and other devices when it comes back.
Non-functional requirements
- Speed: from code in view to result on screen in under 500 ms on a mid-range phone, and ready for the next ticket about 1 second later.
- Offline: 100 percent of scans work with no network once the event is downloaded. The app itself opens with no network.
- Correctness: a ticket is never admitted twice at the same device. Across devices, a duplicate is caught within one sync interval when online.
- Battery: a phone scans for a 4-hour door shift on one charge, with the screen kept on.
- Security and privacy: a lost phone can be cut off, and attendee personal data is removed from the phone after the event.
- Accessibility: results do not rely on colour alone, with large text and an audio cue a staff member can hear in a loud room.
Out of scope
Selling tickets, seat holds, and payment are covered in the seat-map and checkout-flow guides. Staff sign-in screens are the auth-flow guide. The generic machinery of an offline write queue and sync protocol is the offline-sync-engine guide; this guide uses it and focuses on what is special about a door. Badge printing and NFC wristbands are good follow-ups but not part of the core design.
Check your understanding · Exercise 1
Which answers from the interviewer would change the design the most (select all that apply)? Select all that apply.
What makes a door app hard
Five traits of this problem drive every later decision. Name them early in an interview: they show you see the real problem behind the simple screen.
- The answer must be local. A staff member cannot wait 4 seconds for a request on a crowded Wi-Fi while a queue of 300 people watches. The decision "let in or not" has to come from data already on the phone. The server is a place to share results, not a place to ask.
- The input is a camera, not a keyboard. Codes arrive tilted, glossy, on cracked screens with the brightness turned down, or crumpled on paper, in a dark doorway. The app must read many frames per second without draining the battery, and it must offer a manual path when the camera fails.
- Many writers, one truth. 30 phones each record check-ins. Two phones can admit the same ticket within the same minute if they cannot talk. No design removes this window while offline; a good design makes it small, visible, and handled by a policy.
- The person in front of you is waiting. Throughput is the product. If each scan takes 5 seconds instead of 2, a 2,000-person arrival takes 2.5 times longer at the same number of doors. Every 100 ms of feedback delay, every extra tap, and every double scan of the same code costs real queue time.
- Real money and real people. A fake or shared ticket that gets in is lost revenue and a safety number that is wrong. A real ticket wrongly rejected is an angry customer at the door. Errors in both directions matter, so the app must explain why it said no.
A quick throughput estimate makes the numbers concrete. Say 2,000 people arrive in the first 30 minutes across 6 doors. That is about 333 people per door, or one every 5.4 seconds per door. With one staff phone per door and about 2.5 seconds per person (raise phone, scan, read result, wave through), a single phone keeps up with room to spare.
Push the arrival to 5,000 in 30 minutes and each door needs one person every 2.2 seconds. Now a second phone per door, or a faster scan loop, is the difference between a short line and a 20-minute line. These are estimates, but they show why the scan loop is measured in hundreds of milliseconds.
| Part of one scan | Budget (estimate) | What eats it |
|---|---|---|
| Attendee holds up the code, staff aims | 800 to 1,500 ms | Humans. Big, clear framing guides help. |
| Camera sees the code, decoder reads it | 100 to 300 ms | Frame rate, decode speed, focus, glare. |
| Verify and look up the ticket | under 20 ms | IndexedDB read or a signature check on the phone. |
| Show the result, sound, staff reads it | 300 to 600 ms | Screen size, contrast, the sound starting fast. |
| Cooldown before the next scan | about 1,000 ms | A debounce so the same code is not read twice. |
Think first
Why not just call POST /checkins for each scan and show the server's answer? The server is the only place that truly knows if a ticket was used.
It works on a good network and fails exactly when it matters. At doors-open, 20,000 phones in the crowd load the same Wi-Fi. A request that takes 150 ms in testing takes 3 to 10 seconds or times out. The queue stops.
The online call is still valuable, but as a second step: decide locally first, show the result, then send the check-in in the background. When the network is good, the server can still tell you "already used at Door 2" within a moment, and the app can flag it. That is the design the rest of this guide builds.
What goes inside the QR code
Before any camera code, decide what the QR code says. A QR code is just a container for a short string or a few hundred bytes. The scanner reads those bytes and must turn them into an answer. What you put inside decides whether the phone can answer with no network, how big and dense the code is, and what a forger can do.
The naive design puts a URL in the code: https://tix.example.com/t/48213. It scans with any phone camera, which feels friendly, but it breaks in three ways.
- The number is guessable, so someone can make code 48214 and walk in on another person's ticket.
- The scanner needs the server to look the number up, so it fails with no network.
- An attendee who scans their own ticket with the phone camera opens a web page, which might check them in by mistake if the link does anything.
So the real question is what to put in the code instead. Three designs are worth weighing.
Decision What should the ticket QR code contain?
What we pick: Pick the compact signed binary and still download the list. The signature lets the phone trust a ticket even when the list is stale, which happens at every event with door sales. The list gives the phone the one thing a signature cannot: whether this ticket was already used. If the platform is small and has no late sales, an opaque id plus the list is a fine, simpler answer. Say that out loud.
Payload size decides how easy the code is to read
A QR code's version runs from 1 to 40. Version v is a square of 17 + 4v modules (the black and white squares) per side. Its error correction level (L, M, Q, H) lets a reader recover about 7, 15, 25, or 30 percent of the code's data when part of it is damaged, at the cost of capacity.
On a phone screen 5 cm wide, a Version 2 code has modules about 1.5 mm across, and a Version 13 code about 0.65 mm (estimate, including the 4-module quiet margin on each side). Bigger modules mean the camera can read the code from further away, with more blur, glare, and cracks.
| Payload | Length | Mode | Smallest version at level M |
|---|---|---|---|
| Opaque id, Base32 with a prefix | 29 chars | Alphanumeric | Version 2 holds 38: 25 by 25 modules |
| Compact binary, Base45 | 140 chars with the prefix | Alphanumeric | Version 6 holds 154: 41 by 41 |
| Compact binary, Base64url | 125 chars with the prefix | Byte | Version 8 holds 152: 49 by 49 (Version 7 holds only 122) |
| JWT with ES256 signature | about 250 to 290 chars | Byte | Version 12 holds 287, Version 13 holds 331: 65 to 69 wide |
This is the same trick the EU Digital COVID Certificate used for proof of vaccination: a signed binary record, compressed, written in Base45 so it fits a readable QR code that any border scanner could verify offline. The verification code on the scanner is short, because the browser's Web Crypto API does the hard part.
type Claims = { ticketId: string; eventId: number; type: number; days: number; kid: number };
async function verifyTicket(raw: string, keys: Map<number, CryptoKey>): Promise<Claims | null> {
const bytes = base45Decode(raw.slice(3)); // strip the "T2:" prefix
if (bytes.length !== 91) return null;
const body = bytes.subarray(0, 27);
const sig = bytes.subarray(27); // 64 bytes: r and s, the format WebCrypto expects
const claims = parseClaims(body);
const key = keys.get(claims.kid);
if (!key) return null; // unknown key: older app or forged
const ok = await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, sig, body);
return ok ? claims : null;
}
The sketch uses ECDSA on the P-256 curve because every major browser has supported it in Web Crypto for years. Ed25519 also makes a 64-byte signature, so the payload size would not change. It only reached Web Crypto in Safari 17, Firefox 129, and Chrome 137, so pick it only if every staff phone runs those versions or newer.
Screenshots: a signature proves real, not unique
A signed ticket cannot be forged, but it can be copied. If Alice posts a screenshot of her ticket, every copy has a perfect signature. Only the first scan is honest; every later scan must be caught by the used state, which is a sync problem, not a crypto problem (see the duplicates chapter). Three extra defenses exist, each with a cost.
- Rotating codes. Ticketmaster's SafeTix shows a barcode that its help centre says refreshes every 15 seconds, so a screenshot goes stale fast. A rotating code typically works like a one-time password: the attendee app mixes a per-ticket secret with the current 15-second time window. The cost: attendees must use the app or a wallet pass, phone clocks must be close to correct, paper tickets stop working, and the scanner needs each ticket's secret, which makes the downloaded list more sensitive.
- Name on the ticket and ID checks for high-value events. Cheap in tech, slow at the door.
- Short validity windows, such as a Day 2 ticket that is only valid on day 2. This limits misuse but does not stop it.
Check your understanding · Exercise 2
Tickets use the compact signed format. Which statements are true (select all that apply)? Select all that apply.
Reading codes from the camera, fast
The naive scanner is input type=file capture=environment: the phone opens its camera app, the staff member takes a photo, and the page decodes it. That is three taps and 2 to 3 seconds per ticket, so the queue doubles.
The second naive design opens a live stream and decodes every full-resolution frame on the main thread, 30 times a second. The page stutters, the phone gets hot, and the battery is gone by the second hour. A good scanner sits between: a live stream, a small crop of each frame, decoded a few times a second off the main thread.
Opening the camera
navigator.mediaDevices.getUserMedia only exists in a secure context: the page must be served over HTTPS (or from localhost during development). Ask for the rear camera with facingMode and a moderate resolution. You do not need 4K to read a QR code; 1280 by 720 gives plenty of pixels for the centre crop and keeps each frame cheap. Use ideal, not exact, so a laptop with only a front camera still works for testing.
async function openCamera(video: HTMLVideoElement): Promise<MediaStreamTrack> {
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
facingMode: { ideal: "environment" },
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30 },
},
});
video.playsInline = true;
video.muted = true;
video.srcObject = stream;
await video.play();
return stream.getVideoTracks()[0];
}
Permission is where door apps fail in real life. Handle each error name with its own screen. NotAllowedError means the user or a policy said no: show short steps to re-enable camera access for this site, and a big "Search by name" button so the door keeps moving.
NotReadableError usually means another app holds the camera: ask the staff member to close it. OverconstrainedError only happens with exact, min, or max constraints that no camera can meet, which is one more reason to ask with ideal. Device pickers, permission states, and track lifecycle are covered in depth in the video-conferencing guide; reuse that here instead of rebuilding it.
Choosing the decoder
Decision What turns a camera frame into the QR text?
What we pick: Feature-detect. If BarcodeDetector exists and getSupportedFormats() includes qr_code, use it. Otherwise use a WebAssembly decoder in a worker, which is the path every iPhone takes. Both sit behind one decode(frame) interface, so the rest of the app does not care, and you can switch a device to the other path if field reports show it misreads. Polyfills such as the barcode-detector package already wrap ZXing-C++ WebAssembly behind the same API.
The frame pipeline
The camera gives 30 frames a second. You do not need to decode 30. Five to ten attempts a second already catch a code within 100 to 200 ms of it entering the frame, and leave the CPU mostly idle between attempts.
The loop is: take a frame, crop the centre square where the framing guide is drawn, downscale it to about 640 by 640, and send it to the worker. While the worker is busy, skip frames instead of queueing them. A queue of old frames only adds delay; the newest frame is always the most useful one.
let busy = false;
async function tick(video: HTMLVideoElement, worker: Worker) {
if (busy || video.readyState < 2) return; // backpressure: drop this frame
busy = true;
const side = Math.min(video.videoWidth, video.videoHeight); // centre square: 720 x 720 at 720p
const sx = (video.videoWidth - side) / 2;
const sy = (video.videoHeight - side) / 2;
try {
const frame = await createImageBitmap(video, sx, sy, side, side, {
resizeWidth: 640, resizeHeight: 640,
});
worker.postMessage({ frame }, [frame]); // transfer ownership, zero copy
} catch {
busy = false; // frame not ready: try on the next tick
}
}
worker.onmessage = (e) => { busy = false; if (e.data.text) onCode(e.data.text); };
setInterval(() => tick(video, worker), 125); // about 8 attempts per second
postMessage, not copied.WASM in a worker, 8 attempts per second, centre crop: about 25 ms per decode, 8 decodes per second, 22 frames skipped, one CPU core 20 percent busy, main thread 3 percent busy, about 88 ms from code in view to result. guard on: 1 admit, 12 repeat reads dropped.
Torch and the manual fallback
Dark doorways are common. Some phones let the page turn on the flash as a torch through the camera track: check track.getCapabilities().torch, and if it is there, call track.applyConstraints({ advanced: [{ torch: true }] }). Support depends on the browser and the phone, so show the torch button only when the capability exists, and do not promise it to staff in training material.
Some tickets will never scan: a phone with a dead battery, a code on a smashed screen, a guest-list name with no ticket at all. The manual path is a search box over the local attendee list by name, email, or order number. It runs on IndexedDB, so it works offline, and it shows enough to identify the right person (name, ticket type, last 4 characters of the order) without showing more personal data than needed. Results are tapped, not typed, so one search plus one tap checks a person in.
Check your understanding · Exercise 3
Field reports say iPhones at Door 3 take 2 seconds to read codes while Android phones take 0.3 seconds. Which changes are likely to help (select all that apply)? Select all that apply.
Feedback at the door: the result screen and the scan loop
The decoder returned a string. Now the app must turn it into a decision and show that decision to a person who is looking at the attendee, not the screen, in a loud room.
The naive version shows a small toast that says "Checked in" at the bottom. Staff miss it, the toast for the next person stacks on the old one, and a rejected ticket slides by unnoticed. Toasts are right for background messages (see the toast-notifications guide); a door decision is the main content and takes the whole screen.
The decision, in order
- Parse: is this our format at all? A Wi-Fi QR code or a URL gets "Not a ticket for this app" and no sound of failure, so staff do not panic.
- Verify the signature with the event key (about 1 to 5 ms on a phone, estimate). Bad signature: red, "Invalid ticket".
- Event and rules: right event, right day, right door for this ticket type. Wrong door: amber, "VIP entrance is Door 1".
- State: look the ticket up in IndexedDB. Refunded: red. Already checked in: red with when, where, and by which device. Not in list: accept if the signature is good, and tag the check-in "unknown at scan" for review.
- Record: write the check-in to IndexedDB and the outbox in one transaction, then show green. Writing first means a crash between showing and saving cannot admit a person without a record.
| Result | Screen | Sound | Next step |
|---|---|---|---|
| Admitted | Green, check icon, the word ADMIT, name and ticket type | Short high tone | Auto-returns to the camera after about 1 second |
| Already checked in | Red, cross icon, ALREADY IN, time, door, device | Two low tones | Stays until staff tap Dismiss or Override |
| Wrong door or day | Amber, arrow icon, the right door or date | Two low tones | Stays until tapped |
| Invalid or refunded | Red, cross icon, the reason | Two low tones | Stays until tapped; offers Search by name |
Debounce: the same code, read twice
The camera keeps seeing the same ticket after it has been accepted. Without a guard, the next frame reads it again and the screen flips to red, "Already checked in", for a person who just got in. Staff learn to distrust the app after one of those.
The fix is a short memory: ignore a code that equals the last code if it was seen within about the last 2 seconds. Each repeat read restarts that window, so a code held in view stays ignored. A different code is accepted as soon as the camera is back, so a fast queue is not slowed down.
const REPEAT_WINDOW_MS = 2000;
let last: { text: string; at: number } | null = null;
function onCode(text: string) {
const now = performance.now();
const repeat = last !== null && last.text === text && now - last.at < REPEAT_WINDOW_MS;
last = { text, at: now }; // a repeat read restarts the window
if (repeat) return;
scanner.send({ type: "CODE", text }); // to the state machine
}
Sound and vibration
Staff often hear the result before they look. Play short tones through the Web Audio API: decode the two sound files into AudioBuffers once at start, then play them in a few milliseconds. Browsers only allow audio after a user gesture, so the "Start scanning" tap also resumes the AudioContext.
Vibration is a nice extra on Android Chrome with navigator.vibrate(80). Safari on iOS has never supported it, and Firefox removed it in version 129. Treat it as a bonus, never as the only signal.
Put the numbers together. A green result held for 1 second, plus 200 ms to restart decoding, plus a human aiming the next ticket in about 1 second, gives one attendee every 2 to 3 seconds per phone (estimate). That is 1,200 to 1,800 people an hour per phone at full speed, which is why a 20,000-person event plans for several phones per door.
Check your understanding · Exercise 4
Which choices make the door both faster and safer (select all that apply)? Select all that apply.
Getting the event onto the phone before doors open
Every decision in the last chapter reads local data. So the phone must hold the event before the first attendee arrives: the attendee list, the public keys, and the door rules.
The naive approach fetches the list when the scanner screen opens. At 18:55, with 30 staff phones and 2,000 attendees on the same Wi-Fi, that download crawls, and the first scans happen on an empty list. Downloading must be an explicit, checked step before doors open, the way a pilot runs a checklist before take-off.
How big is the event?
| Item | Per ticket (estimate) | 20,000 tickets | 50,000 tickets |
|---|---|---|---|
| Ticket id, type, days, order ref, status | about 70 bytes | 1.4 MB | 3.5 MB |
| Display name and search keys | about 60 bytes | 1.2 MB | 3 MB |
| Hashed email for lookup (SHA-256 as 43 Base64url characters) | about 45 bytes | 0.9 MB | 2.3 MB |
| JSON overhead (field names, quotes) | about 60 bytes | 1.2 MB | 3 MB |
| Total as JSON | about 235 bytes | 4.7 MB | about 12 MB |
| Gzipped on the wire | about 3 times smaller: random ids and hashes barely compress | about 1.5 MB | about 4 MB |
| In IndexedDB with 3 indexes | about 2 times the JSON | about 10 MB | about 25 MB |
Notice the hashed email: staff search by name, or type the attendee's full email, which the app hashes with a per-event salt and compares. The phone never holds a readable email list, so a lost phone leaks less, though a hash only slows down someone who already has a list of emails to try. Names stay, because staff need to see them. This is data minimization: store only what the door needs.
Decision How does the phone get and keep the attendee list?
What we pick: Versioned snapshot plus deltas. It turns the risky part, the big download, into a planned step that happens the evening before or in the green room, and it makes every sync during doors cheap. The generic cursor-based sync protocol is the one in the offline-sync-engine guide; here the twist is the explicit readiness gate.
The ready for offline check
A phone is ready when five things are true, and the home screen shows each as a line with a tick: the app shell is cached by the service worker, the snapshot is fully written (20,000 of 20,000 rows, version 412), the event keys are present, storage is marked persistent, and the last sync is recent. If any line is missing, the scanner still works but shows a yellow banner, "Not ready for offline: attendee list incomplete", so the door lead knows before the rush, not during it.
Write the snapshot in batches, about 1,000 rows per IndexedDB transaction, from a worker. One giant transaction holds a lock for seconds and fails as a whole if the tab dies; small batches show progress and resume where they stopped.
Then call navigator.storage.persist(). Without it, the browser may evict the site's storage when the phone runs low on disk. Chrome decides silently from signals such as the app being installed, Firefox asks the user, and Safari has supported the call since 15.2. Check the result with navigator.storage.persisted() and show it in the ready list.
type Readiness = { shell: boolean; rows: number; expected: number; keys: boolean;
persisted: boolean; lastSyncAgoMs: number };
async function checkReady(eventId: string): Promise<Readiness> {
const meta = await db.get("events", eventId);
return {
shell: (await caches.has("app-shell-v19")),
rows: await db.countFromIndex("tickets", "eventId", eventId),
expected: meta?.ticketCount ?? 0,
keys: (meta?.keys?.length ?? 0) > 0,
persisted: await navigator.storage.persisted(),
lastSyncAgoMs: Date.now() - (meta?.lastSyncAt ?? 0),
};
}
The concepts behind this are taught in the offline-first, indexeddb, and client-storage lessons. What is specific to a door is the deadline: the data has a time by which it must be on the phone, and the UI has to make that deadline visible.
Check your understanding · Exercise 5
A 50,000-person festival. Which plans are sound (select all that apply)? Select all that apply.
One ticket, two doors: duplicates across devices
Phone A at Door 1 admits ticket 7QK2 at 19:02. Thirty seconds later, a friend holding a screenshot of the same ticket walks up to Door 2. Phone B only knows if Phone A's check-in reached it in those thirty seconds. Every design here is about making that window short when the network works, honest when it does not, and handled by a clear policy either way.
Record events, not a flag
The naive model stores checkedIn: true on the ticket and syncs the ticket row. Two offline phones both set it to true, and when they sync, the server sees two identical writes and keeps one. The second admission vanishes: nobody ever learns that two people got in.
Instead, each phone appends a check-in event: its own id, the ticket, the action (in, out, or undo), the device, the door, and the time. Events are never overwritten, so the server sees both admissions and can flag the second one. The ticket's state is computed by folding its events in order: replaying them one by one from the start.
Decision How do devices learn about each other's check-ins?
What we pick: Decide locally, push at once, and let the server broadcast. Peer sync goes through the server, not phone to phone. It keeps the door instant and makes the duplicate window as short as the network allows. Be honest in the interview: no design catches a duplicate between two phones that cannot reach anything. The answer is to shrink that window and to have a policy for what happens after.
First wins, and what the screen says
The server orders events for a ticket by scan time and keeps the earliest in as the official check-in. Device clocks drift, so each sync also measures the offset between the phone clock and the server clock, and every event carries the corrected time.
A later in for the same ticket, with no out in between, is stored as a duplicate admission and shows up in the door lead's alert list: "Ticket 7QK2 admitted twice: Door 1 at 19:02 (Phone A), Door 2 at 19:02:30 (Phone B, offline)". The second person is already inside; the alert lets security act, and the post-event report counts it.
At scan time, the red screen uses whatever the phone knows. If the earlier check-in came from this phone or arrived by sync, it says "Already checked in at 19:02, Door 1, by Phone A". That detail matters: staff can tell a genuine re-scan (the person walked back out a side door) from a shared ticket (two strangers, one ticket). If the phone is offline, the ready line on the scanner shows "Offline for 6 min: other doors' check-ins not visible", so staff know the app might not know.
Step 0 of 8. Phone A online, last result ready, 0 to sync. Phone B online, last result ready, 0 to sync. Server knows 0 check-ins and 0 duplicates. Both phones online. Press Next attendee.
Policies, undo, and re-entry
- Single entry (the default): a second
inwithout anoutis rejected at scan and flagged after sync. - Re-entry allowed: staff check people out at the exit, which appends an
outevent. The nextinis then valid. Without exit scanning, the organizer can allow a set number of entries instead. - Door and day rules: ticket types map to allowed doors and days. These rules live in the event download, so they work offline.
- Undo: the green screen shows an Undo button for about 10 seconds, and the attendee detail screen offers it later. It appends an
undoevent pointing at the check-in it cancels. Nothing is deleted, so the audit trail stays complete. - Override: a door lead can admit someone despite a red result, with a reason. It is just another event, with the staff id and reason attached.
Because every action is an event, the merge rules from the conflict-resolution lesson collapse into one rule here: sort a ticket's events by corrected time, skip undone events, then replay in and out. Two phones can never produce a conflict the fold cannot handle; they can only produce a duplicate admission, which is a fact to report, not a conflict to resolve.
Live counts
Door leads want "8,412 of 20,000 checked in" and a number per door. The server computes counts from the event log and pushes them over the same WebSocket every few seconds. If the socket cannot connect (some venue networks block it), the app polls GET /events/42/stats every 15 seconds.
The number on a phone is the server count plus this phone's not-yet-synced check-ins, with a small "syncing" marker while the outbox is not empty. The websockets and server-sent-events lessons cover the transport. Server-Sent Events would also work here, since counts and check-ins only flow from server to phone and phone writes go through normal requests.
Check your understanding · Exercise 6
Phone B has been offline for 10 minutes. Phone A, online, admitted ticket 7QK2 five minutes ago. Someone shows 7QK2 at Phone B. What happens in this design (select all that apply)? Select all that apply.
Architecture, data model, and API
With the hard decisions made, the architecture follows from them. The rule that shapes every boundary: the scan path never touches the network. Camera, decoder, verifier, and the local database form a closed loop on the phone. Sync and live updates sit beside that loop and can fail without stopping it.
This chapter puts the earlier decisions together in three views: the parts and how data flows between them, the records stored on the phone, and the API the phone calls. Each view is short, because the reasoning behind it is in the chapters above.
The parts and how data flows
| Part | Runs where | Responsibility | Why the boundary is here |
|---|---|---|---|
| Scanner screen | Main thread, React | Video preview, framing guide, result screen, Undo, torch, manual search entry | Only UI. It reads state and sends events to the scan machine. |
| Scan state machine | Main thread | The states from idle to cooldown; repeat guard; decides when to accept the next code | One place owns what happens next, so no race between frames and taps. |
| Frame grabber | Main thread | Crops and scales frames, sends one at a time, drops frames while busy | createImageBitmap from a video element needs the DOM. |
| Decoder worker | Web Worker | BarcodeDetector or WebAssembly decode of each frame | Decoding takes tens of ms; off the main thread it cannot stutter the UI. |
| Verifier and rules | Main thread or worker | Parse payload, check signature, apply door and day rules, fold ticket events | Pure functions over local data: easy to unit test with real ticket fixtures. |
| Local store | IndexedDB | Tickets, events (check-ins), outbox, event meta, keys | Survives reloads and crashes; queried by id and by name. |
| Sync engine | Main thread, plus service worker for background retries | Push outbox, pull deltas, clock offset, snapshot download | The generic engine from the offline-sync-engine guide, with the event log as its data. |
| Live channel | Main thread | WebSocket for check-ins and counts; polling fallback | Optional: the app is correct without it, just slower to see other doors. |
| Service worker | Service worker | Precache the app shell, decoder, and sounds; serve them offline | So the app opens in a basement with no signal. |
The verifier is deliberately separate from the UI and from the store. It is the part that decides who gets in, so it must be the easiest to test. Given a payload, a set of keys, a ticket row, and its events, it returns a decision. Hundreds of test cases (expired key, wrong day, re-entry after out, undone check-in) run in milliseconds with no browser.
Follow one scan through the data flow. The camera frame goes to the worker as a transferred ImageBitmap; the worker returns a short string. The verifier reads the ticket row and its events from IndexedDB, decides, and writes a new event and an outbox entry in one transaction. The screen re-renders from the decision.
Separately, the sync engine sees the outbox change, posts the batch, and receives the server's verdict for each event (accepted or duplicate). Other phones receive the event through the live channel and write it to their own store, which is what lets them say "Already checked in at Door 1".
Think first
Should the signature check run in the decoder worker, so the main thread only ever sees verified tickets?
It can. Web Crypto is available in workers, and moving the check there keeps all CPU work off the main thread. The cost is that the worker now needs the keys and the rules, so it grows from a pure decoder into part of the business logic.
A signature check is a few milliseconds, so most teams keep it on the main thread next to the rules and the store for simpler code. Move it only if profiling shows main-thread long tasks during scans.
Data model: what lives on the phone
Six kinds of data live on the phone, four of them in IndexedDB stores, each with a different owner and lifetime. Keeping them apart avoids the classic bug where a sync overwrites a local check-in that was never sent.
| Entity | Lives in | Owner | Lifetime |
|---|---|---|---|
| Event meta: name, doors, ticket types, rules, keys, snapshot version | IndexedDB events | Server, read-only on the phone | Until the event is wiped |
| Ticket rows | IndexedDB tickets, keyed by id, indexes on event id, name tokens, email hash | Server, replaced by snapshot and deltas | Until the event is wiped |
| Check-in events | IndexedDB checkins, index on ticket id | Append-only; local ones created here, remote ones arrive by sync | Until synced and the event is wiped |
| Outbox | IndexedDB outbox | This phone | Until the server acknowledges |
| Device session: device id, staff, door, token | IndexedDB plus memory | Auth service | One shift, hours |
| Scanner state, last code, torch, result | Memory, state machine | UI | Seconds |
type Ticket = {
id: string; // 128-bit id, Base32
eventId: string;
typeId: number; // General, VIP, Speaker...
days: number; // bitmask of valid days
orderRef: string; // last 4 shown to staff
name: string; // display only
nameTokens: string[]; // lowercased, accent-folded, for search
emailHash: string; // salted SHA-256, Base64url
status: "valid" | "refunded" | "void";
};
type CheckIn = {
id: string; // UUID made on the phone: makes retries idempotent
ticketId: string;
action: "in" | "out" | "undo";
undoes?: string; // CheckIn id, when action is "undo"
deviceId: string;
doorId: string;
staffId: string;
scannedAt: number; // phone time + measured server offset
source: "scan" | "manual" | "override";
serverVerdict?: "accepted" | "duplicate";
};
Two choices deserve a word. The check-in id is made on the phone as a UUID, so if a batch upload times out and is sent again, the server sees the same ids and stores each event once (idempotency, covered in the retries-and-backoff lesson). And serverVerdict starts empty: the phone's own decision is final for the door, while the server's verdict arrives later and only feeds alerts and reports.
Check your understanding · Exercise 7
Which data model choices protect against lost or double-counted check-ins (select all that apply)? Select all that apply.
Interface: the API between phone and server
The API mirrors the lifecycle: set up a device, download the event, push and pull changes, and listen live. Every call is safe to retry, because the network at a door fails halfway through requests all the time.
| Call | Purpose | Notes |
|---|---|---|
POST /devices | Register this phone for an event and a door; returns a device token | Staff signs in first; the token is scoped to one event, expires after the event day |
GET /events/42/snapshot | Redirect to a signed CDN URL of the gzipped snapshot | Response carries version; cached by the CDN for every phone |
GET /events/42/changes?since=412 | Ticket and check-in changes since a version | Returns nextVersion; 410 Gone if the version is too old, so the phone downloads a fresh snapshot |
POST /events/42/checkins | Push a batch of check-in events from the outbox | Idempotent by event id; returns a verdict per event and the server time |
GET /events/42/stats | Counts per door and total | Polling fallback for the live channel |
WS /events/42/live | Server pushes check-ins from other devices, count updates, and revocations | Resume with the last version on reconnect |
// POST /events/42/checkins
{ "deviceId": "dev_b31", "sentAt": 1761057900412,
"events": [
{ "id": "c-9f1", "ticketId": "7QK2MZ", "action": "in", "doorId": "d2", "scannedAt": 1761057781000 },
{ "id": "c-9f2", "ticketId": "H44PLA", "action": "in", "doorId": "d2", "scannedAt": 1761057802000 }
] }
// 200 OK
{ "serverTime": 1761057900530,
"results": [
{ "id": "c-9f1", "verdict": "duplicate",
"first": { "doorId": "d1", "deviceId": "dev_a07", "scannedAt": 1761057720000 } },
{ "id": "c-9f2", "verdict": "accepted" }
],
"nextVersion": 431 }
The live channel sends small typed messages. On reconnect, the phone sends its last version and the server replays what it missed, or tells it to fetch changes if the gap is large. A device_revoked message makes the phone wipe the event and sign out at once.
type LiveMessage =
| { type: "checkin"; version: number; event: CheckIn }
| { type: "ticket"; version: number; ticket: Ticket } // new sale or refund
| { type: "stats"; total: number; expected: number; byDoor: Record<string, number> }
| { type: "device_revoked"; deviceId: string }
| { type: "keys"; keys: { kid: number; jwk: JsonWebKey }[] }; // key rotation
Check your understanding · Exercise 8
A batch upload of 40 check-ins times out at the door. The phone retries 20 seconds later. What must the API guarantee (select all that apply)? Select all that apply.
Running all shift: PWA, battery, and security
The app has to open in a basement, stay on for four hours, and be safe to lose. Those three needs are the subject of this chapter. The general mechanics are in the pwa, service-workers, and service-worker-caching lessons; here is what changes at a door.
Opening with no network
Make the app installable with a web app manifest (display: standalone, icons, a start URL of the scanner), and precache the app shell with the service worker at install: HTML, JavaScript, CSS, the WebAssembly decoder, and the two sound files. Version the cache name (app-shell-v19) and serve those files cache-first. A staff member who opened the app once on the hotel Wi-Fi can now launch it from the home screen in a dead zone, and the ready check confirms the shell is there.
Do not let a new deploy swap the app in the middle of a shift. A new service worker that activates during doors could change the scan logic while a staff member is mid-queue, or reload the page while the outbox has unsent events. Let the new worker wait, and show "Update ready: will apply after this event" on the settings screen. Updates happen between events, never during them.
Screen on, battery alive
A phone that dims after 30 seconds is useless at a door. The Screen Wake Lock API keeps it on: await navigator.wakeLock.request("screen"). Browsers release the lock when the page is hidden, so request it again on visibilitychange.
Support is good in current Chrome, Firefox (since 126), and Safari (since 16.4). On iPhones, though, it only works in installed home screen apps from iOS 18.4, after WebKit fixed a bug there. On older iPhones, the staff briefing should include raising Auto-Lock in the phone's settings for the shift. Feature-detect "wakeLock" in navigator and show the result in the ready list.
- Pause the camera when idle. After 60 seconds with no code, stop decoding and dim the preview behind a "Tap to scan" overlay. The camera is the biggest drain after the screen.
- Decode at 8 attempts per second, not 30, and only the centre crop. The scanner lab above shows the difference in CPU work.
- Batch the network. Push the outbox at once when online, but pull deltas every 30 seconds instead of constantly, and let the WebSocket be the fast path.
- Tell the door lead about battery. The Battery Status API is not available in Safari or Firefox, so do not build on it. A line in the staff briefing ("bring a power bank") beats any code.
Staff sessions, lost phones, and keys
Staff are often temporary volunteers on their own phones. The sign-in itself is the auth-flow guide, and the one-time code box that a passwordless staff sign-in shows is the otp-input guide; what matters here is what the session can do.
After sign-in, the phone registers as a device for one event and one role. It gets a short-lived device token (hours, not weeks, ending with the event day) that only allows reading that event and posting check-ins. It cannot see payments, edit tickets, or export the attendee list. Tokens and refresh are covered in the auth-tokens lesson.
- Revoking a lost phone: the organizer taps Revoke in the dashboard. The server rejects that device's token, pushes
device_revokedon the live channel, and the phone wipes the event's IndexedDB data and caches. If the phone stays offline, the data stays until the token expires and the app's own expiry timer wipes it. Keep that window short. - Wipe after the event: when the event ends (plus a grace period for late sync), the app deletes the tickets, names, and check-ins once the outbox is empty. Personal data should not outlive the event on 40 personal phones.
- Key rotation: tickets carry a key id. The event download carries all currently valid public keys. Issue new tickets with a new key; keep the old public key until the old tickets are no longer valid. Revoked keys go out on the live channel. Only public keys ever reach phones; the private key stays on the server.
- Audit: every check-in, undo, and override carries the staff id and device id, so a disputed entry can be traced.
Check your understanding · Exercise 9
A volunteer loses their phone at 21:00 during the event. Which parts of this design limit the damage (select all that apply)? Select all that apply.
Accessibility, observability, and the report after
Door staff include people with low vision, colour blindness, and hearing loss, working in glare, rain, and noise. A result that is only a green or red tint fails about 1 in 12 men with a colour vision deficiency (a commonly cited figure for red-green types) and everyone in direct sunlight.
The result screen carries the decision in four channels at once: colour, a large icon shape (check, cross, arrow), a word in very large type (ADMIT, ALREADY IN, WRONG DOOR), and a distinct sound. Any one of them alone is enough to act on. The visual-accessibility lesson covers contrast ratios; aim well above the 4.5 to 1 minimum here, since screens are dimmed and viewed at arm's length.
- Screen reader users: the result text goes into an
aria-live="assertive"region, phrased so it makes sense alone: "Admitted. Priya Nair, VIP." or "Rejected. Already checked in at 19:02, Door 1." The camera view has a label and the torch and Search buttons are real buttons with names. - Text size: the result word is readable from 1 metre: at least about 48 CSS pixels. The UI respects the system text size for everything else.
- Reduced motion: no flashing transitions between results; a flash of full-screen colour can be uncomfortable, so with
prefers-reduced-motionthe colour switches without animation. - Audio: two clearly different tones, not just two pitches of the same beep, and a volume setting in the app since the system volume is often down.
- One-handed use: Undo, Search, and Torch sit in the bottom third, in thumb reach, with 48 by 48 pixel targets.
Seeing problems during doors
The door lead's dashboard shows, per device: last sync time, outbox size, battery if the device reports it, snapshot version, and scans per minute. A device whose outbox grows while others drain has a network problem; a device with zero scans for 10 minutes at a busy door has a person or camera problem. Each phone also logs scan timings (code seen to result) and decode failures, buffered locally and sent with the sync, so the team can find the phone model where decoding is slow.
After the event, the same event log becomes the report: arrivals per 5 minutes per door (for planning next year's staffing), no-show rate by ticket type, duplicate admissions and where they happened, manual lookups as a share of scans (a high share means codes were hard to read), and overrides by staff member. None of this needs extra tracking: it is all in the check-in events already synced.
Check your understanding · Exercise 10
Which result screen designs work for a colour-blind staff member in bright sunlight at a loud door (select all that apply)? Select all that apply.
Running it in the interview
In 45 minutes you cannot cover everything above. Lead with the one idea that makes this problem different: the decision is made on the phone, and the network only shares results. Then spend most of your time on the two places where candidates usually go shallow: the payload and the duplicate window.
| Minutes | What to do | What to draw |
|---|---|---|
| 0 to 5 | Ask about event size, doors, network, re-entry, ticket types, devices | Nothing yet; write the numbers in a corner |
| 5 to 10 | State the throughput budget and the offline-first rule | The scan loop budget: aim, decode, verify, show, cooldown |
| 10 to 18 | Payload: opaque id versus signed token, size versus QR version, screenshots | Three payloads with their QR sizes |
| 18 to 25 | Camera and decoder: getUserMedia, BarcodeDetector or WebAssembly worker, frame dropping, fallback search | The frame pipeline |
| 25 to 35 | Offline data and duplicates: snapshot plus deltas, events not flags, first-wins, the risk window | Architecture, then the two-door sequence |
| 35 to 42 | Data model, API, security, accessibility in quick passes | CheckIn type and the batch endpoint |
| 42 to 45 | Summarize trade-offs and what you would do with more time | Point back at the diagrams |
Say the trade-offs out loud as you make them. "I am choosing a signed token so late sales work offline, and I accept a denser code; I will keep it small with a binary format." "I cannot catch a duplicate between two offline phones; I will make the window visible and alert after sync." Interviewers listen for exactly these sentences: a choice, its cost, and why the cost is acceptable here.
Think first
Follow-up: "The whole venue loses internet for the first hour of doors. What happens?"
Every phone keeps scanning from its local snapshot; the result screens are the same. Each phone shows how long it has been offline. Duplicates within a single phone are still caught; duplicates across phones are not, so the door lead can switch high-value doors to name checks or assign one ticket type per door to reduce cross-door risk.
When the network returns, outboxes drain, the server marks duplicates, and the door lead gets one alert list. Counts jump to the true numbers within a sync or two.
Think first
Follow-up: "A reseller sells 50 copies of the same ticket screenshot. How would you limit it?"
First copy gets in; the next copies at the same door are caught instantly, at other doors within seconds when online. That limits the reseller to a handful of entries, not 50, as long as phones stay connected.
For more, rotate codes in an attendee app or wallet pass (as SafeTix does with a refresh every 15 seconds), bind tickets to a name for high-value events, and use the duplicate report to cancel the original buyer's other tickets. Each step adds friction for honest attendees, so it is an organizer setting, not a default.
Check your understanding · Exercise 11
Which of these would an interviewer count as strong signals in this problem (select all that apply)? Select all that apply.
Summary
- Decide on the phone, share through the server. The scan path never waits on the venue network.
- Put a signed, compact binary in the QR code and still download the attendee list: the signature proves a ticket is real, the list says whether it was used.
- Payload size sets the QR version: about 140 Base45 characters fit Version 6 at level M, while a JWT needs Version 12 or 13. Smaller codes read faster on bad screens.
- Scan 5 to 10 times a second on a cropped, downscaled frame in a worker, drop frames while busy, and use BarcodeDetector only where it exists; iPhones need a WebAssembly decoder.
- Make readiness an explicit step: snapshot downloaded and counted, keys present, storage persisted, app shell cached, all shown before doors open.
- Record check-ins as append-only events with phone-made ids. The server keeps the first admission and reports later ones as duplicates instead of hiding them.
- The offline duplicate window cannot be closed, only shrunk and shown. Policies like re-entry, door rules, and alerts handle the rest.
- Show results in colour, shape, words, and sound, keep red results until tapped, and wipe personal data from phones after the event.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- MDN: MediaDevices.getUserMedia() Constraints such as facingMode, error names, and the secure context rule.
- MDN: Barcode Detection API The built-in detector and its browser support.
- Chrome for Developers: The Shape Detection API How BarcodeDetector exposes the platform's own barcode reader.
- MDN: Screen Wake Lock API Keeping the screen on, and why the lock is released when the page is hidden.
- MDN: StorageManager.persist() Asking the browser not to evict the attendee list.
- web.dev: Persistent storage How Chrome, Firefox, and Safari decide whether to grant it.
- MDN: Navigator.vibrate() Vibration support, and its absence in Safari and current Firefox.
- Thonky: QR code character capacities Capacity by version, mode, and error correction level.
- RFC 9285: The Base45 Data Encoding Encoding binary data for QR alphanumeric mode.
- ZXing-C++ A barcode decoding library that can be built for WebAssembly.
- Ticketmaster Help: Why do my tickets have a moving barcode? SafeTix codes that refresh every 15 seconds.
Concepts behind it
- Offline-first designA way of building apps where the device's own copy of the data is what the UI reads and writes, and a sync engine reconciles it with the server in the background, so the app stays fast and usable on a bad connection or none at all.
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
- Progressive web appsA website that the browser can install as an app: a manifest gives it a name, icons, and its own window, a service worker makes it open without the network, and the result lives on the home screen without an app store.
- Service worker cachingUsing a service worker as a programmable cache between your page and the network, so you choose per request whether to answer from a stored copy, the network, or both: the basis of instant loads and offline apps.
- Conflict resolution strategiesWhat to do when two people change the same data: from last write wins per field, to version checks with
If-Match, to three-way merges and merge screens, chosen separately for each kind of data. - Authentication and token storageHow a web app proves who the user is on every request after login: sessions or tokens, where the proof lives in the browser, how it is refreshed and revoked, and how OAuth with PKCE and OIDC fit in.
- Color, contrast, and motionHow people with low vision, color blindness, motion sensitivity, or custom system themes see your UI, and the rules that keep it usable: contrast ratios, never color alone, reflow at 400% zoom, target size, and the user preference media queries.
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.