cf.completefrontend
My progress
SYSTEM DESIGN · #125

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.

QuestionAnswer we assumeWhat 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.

Wireframe of the door app on a phone, two screens side by side. Left, the scanner for Door 2: a count of 8,412 of 20,000 checked in, a status line saying offline for 2 minutes with 3 check-ins to sync, the camera preview with a square framing guide, buttons for Torch, Search, and Undo, and a ready line showing list version 412 and keys present. Right, the result screen: a full green panel with the word ADMIT, the name Priya Nair, VIP, Door 2 at 19:04, a note that it returns to the camera in 1 second, an Undo button with 10 seconds left, and a note that the result is announced to screen readers.Scan: Door 28,412 / 20,000 inoffline 2 min, 3 to syncaim the code hereTorchSearchUndoready: list v412, keys okResultADMITPriya NairVIP, order ending 7Q2KDoor 2, 19:04back to camera in 1 sUndo (10 s)announced to screen readers
The scanner always shows two facts staff need: how many are in, and whether this phone is in sync. The result is the whole screen, with a word and a name, not just a colour.

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

One attendee every 2 to 3 seconds per phone is a reasonable target. Only the middle rows are ours to optimize, but they are also the ones that go wrong.
Part of one scanBudget (estimate)What eats it
Attendee holds up the code, staff aims800 to 1,500 msHumans. Big, clear framing guides help.
Camera sees the code, decoder reads it100 to 300 msFrame rate, decode speed, focus, glare.
Verify and look up the ticketunder 20 msIndexedDB read or a signature check on the phone.
Show the result, sound, staff reads it300 to 600 msScreen size, contrast, the sound starting fast.
Cooldown before the next scanabout 1,000 msA 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.

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?

Opaque random id, looked up in a list

The server makes 128 random bits per ticket and encodes them as 26 characters of uppercase Base32, like T1:7QK2M....

The scanner looks the id up in the attendee list it downloaded. Found and not used means admit.

Pros

  • Tiny: 29 characters fit a Version 2 code (25 by 25 modules) at error correction level M, so it scans fast from far away.
  • Nothing to forge: guessing a valid 128-bit id is hopeless.
  • Revoking a ticket is easy: delete or flag it in the list.

Cons

  • Useless without the list. A ticket bought 5 minutes ago is unknown until the next sync.
  • The list must be on every phone, with every id in it.

Signed token (JWT style)

The code carries the ticket id, event, ticket type, and valid days, plus a digital signature made with the event's private key.

The scanner holds only the public key and checks the signature on the phone.

Pros

  • Works with no list at all: a brand new ticket is valid the moment it is issued.
  • A forger cannot change the type from General to VIP without breaking the signature.

Cons

  • Big. A JSON Web Token with an ES256 signature is about 250 to 290 characters, which needs Version 12 or 13 at level M (65 or 69 modules wide). Denser codes are slower to read on a cracked or dim screen.
  • It proves the ticket is real, not that it is unused. A screenshot is just as valid as the original.
  • Revocation needs a list anyway: refunded tickets stay validly signed.

Compact signed binary, plus the list

Pack the same fields into 27 bytes of binary, add a 64-byte ECDSA P-256 signature, and encode the 91 bytes in Base45 (RFC 9285) so the QR code can use its denser alphanumeric mode.

The scanner verifies the signature, then also checks the downloaded list for state: used, refunded, or unknown.

Pros

  • About 140 characters with a short T2: prefix: fits Version 6 at level M (41 by 41 modules), about 60 percent of the JWT's width and about a third of its modules.
  • Late tickets that are not in the list yet are still accepted as authentic, and marked for sync.
  • The list adds used and refunded state; the signature covers the gaps in the list.

Cons

  • A custom format: you own the encoder, the decoder, and their tests.
  • Two checks to explain to support staff when they disagree.

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.

Capacities from the standard QR tables. Alphanumeric mode only allows 0 to 9, A to Z, space and a few symbols, which is why Base45 and uppercase Base32 exist. It packs 2 characters into 11 bits, so 140 Base45 characters (770 bits) make a smaller code than 125 Base64url characters in byte mode (1,000 bits).
PayloadLengthModeSmallest version at level M
Opaque id, Base32 with a prefix29 charsAlphanumericVersion 2 holds 38: 25 by 25 modules
Compact binary, Base45140 chars with the prefixAlphanumericVersion 6 holds 154: 41 by 41
Compact binary, Base64url125 chars with the prefixByteVersion 8 holds 152: 49 by 49 (Version 7 holds only 122)
JWT with ES256 signatureabout 250 to 290 charsByteVersion 12 holds 287, Version 13 holds 331: 65 to 69 wide
Three ticket payloads compared, with squares drawn to the relative width of their QR codes at error correction level M. Opaque id: 29 characters, Version 2, 25 by 25 modules, about 1.5 mm per module on a 5 cm screen, but it needs the list. Signed compact binary in Base45: 140 characters with its prefix, Version 6, 41 by 41 modules, about 1.0 mm per module, verifiable offline and paired with the list. JWT with an ES256 signature: about 270 characters, Version 13, 69 by 69 modules, about 0.65 mm per module, dense and slower to read.Opaque id29 charsVersion 2: 25 x 25module about 1.5 mmneeds the listSigned, Base45140 charsVersion 6: 41 x 41module about 1.0 mmoffline + listJWT, ES256about 270 charsVersion 13: 69 x 69module about 0.65 mmdense, slower
Same screen, same phone: the more bytes, the smaller each module. The compact signed format keeps offline verification with about a third of the modules of a JWT.

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.

ts
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;
}
Verifying a ticket on the phone. Keys come with the event download, indexed by a 1-byte key id so keys can rotate.

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.
Timeline of a rotating ticket code over 60 seconds, in four 15-second windows showing the codes A31F, 77C2, E90B, and 4D18. At 5 seconds someone takes a screenshot, which saves A31F. At 40 seconds the scanner at the door sees the live code E90B and accepts it, but the screenshot's A31F is two windows old and is rejected. The scanner accepts the current window and one on either side to allow for clock drift.attendee app: code changes every 15 sA31F77C2E90B4D180 s15 s30 s45 s60 st = 5 s: screenshotsaves A31Ft = 40 s at the doorlive E90B: acceptscreenshot A31F: rejectaccept current window plus one each side
A screenshot freezes one window of a moving target. The price is that attendees need the app or a wallet pass, and every clock must be roughly right.

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.

ts
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];
}
Open the rear camera and play it inline. playsInline and muted matter on iPhones, where video otherwise wants to go full screen.

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?

Built-in BarcodeDetector

new BarcodeDetector({ formats: ["qr_code"] }), then await detector.detect(bitmap) returns the decoded values and corner points. It also works inside a Web Worker.

Pros

  • Uses the platform's native scanner: fast and no download.
  • Works in Chrome on Android since version 83.

Cons

  • Not on iOS Safari (only behind an experimental flag), not in Firefox, and on desktop Chrome only on macOS and ChromeOS. Chrome on an iPhone uses Apple's WebKit engine, so it lacks the API too.
  • Quality differs between devices, since each platform brings its own engine.

WebAssembly decoder in a Web Worker

A C++ decoder such as ZXing-C++ or ZBar compiled to WebAssembly, loaded inside a worker. The main thread sends frames; the worker returns text.

Pros

  • Same behaviour on every browser, including iOS.
  • Off the main thread, so the result screen never stutters.
  • Precached by the service worker, so it works offline.

Cons

  • An extra download of a few hundred KB (estimate), once.
  • Slower than native on most phones; still well within budget for a cropped frame.

Pure JavaScript decoder (for example jsQR)

A decoder written in JavaScript, run on the RGBA pixels of each frame.

Pros

  • Small and easy to drop in.
  • No WebAssembly build step.

Cons

  • Usually the slowest of the three, often several times slower than WebAssembly (estimate).
  • Many are QR only and maintained less actively.

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.

ts
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
Main thread side of the loop. createImageBitmap crops and scales; the bitmap is transferred to the worker, not copied.
The frame pipeline. The camera produces 30 frames per second at 1280 by 720. The frame grabber takes about 8 per second, crops the centre and scales it to 640 by 640, and transfers a bitmap to the decoder worker, which uses BarcodeDetector or a WebAssembly decoder. The decoded text goes to the repeat guard, which drops the same code seen within 2 seconds, then to the scan state machine, which decides and shows the result. Below, one second of camera frames: 30 boxes, of which 8 are decoded and 22 are dropped.Camera1280 x 720Grabbercrop to 640WorkerDetector or WASM30 fps8 fpsRepeat guardsame code, 2 sScan machinedecide, showtextnewone second of camera frames8 decoded, 22 dropped: the newest frame wins
Only one frame is in the worker at a time. If it is still busy, the grabber skips the frame instead of queueing it, so a result is never based on an old picture. The bitmap is transferred with postMessage, not copied.
Decoder
Attempts per second
Frame

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.

Scanner lab. 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.one second of camera frames: 30 captured8 decoded, 22 skipped while the decoder was busy or idleone decode25 msCPU core busy20%main thread busy3%code to result88 msguard on: 1 admit, 12 repeat reads dropped
Rough estimates for a mid-range phone, to show the shape of the trade-off, not exact timings. Green is comfortable, hatched is tight, red hurts battery or responsiveness.

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

  1. 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.
  2. Verify the signature with the event key (about 1 to 5 ms on a phone, estimate). Bad signature: red, "Invalid ticket".
  3. Event and rules: right event, right day, right door for this ticket type. Wrong door: amber, "VIP entrance is Door 1".
  4. 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.
  5. 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.
Green auto-clears to keep the line moving. Every non-green result needs a tap, because a missed rejection is worse than a slow one.
ResultScreenSoundNext step
AdmittedGreen, check icon, the word ADMIT, name and ticket typeShort high toneAuto-returns to the camera after about 1 second
Already checked inRed, cross icon, ALREADY IN, time, door, deviceTwo low tonesStays until staff tap Dismiss or Override
Wrong door or dayAmber, arrow icon, the right door or dateTwo low tonesStays until tapped
Invalid or refundedRed, cross icon, the reasonTwo low tonesStays until tapped; offers Search by name
State machine of the scanner. Idle goes to Starting when staff tap Start. Starting goes to Scanning when the stream is ready, or to Denied on NotAllowedError, and Denied leads to Manual search. Scanning goes to Paused after 60 idle seconds or when the app is hidden, and back on a tap or when visible. Scanning goes to Deciding when a new code arrives. Deciding goes to Admit when valid or Reject when used, invalid, or at the wrong door. Admit returns to Scanning after 1 second; Reject returns only when staff tap.IdleStartingScanningPausedtapstreamidle 60 stap, visibleDeniedNotAllowedErrorManual searchSearchDecidingnew codeAdmitRejectvalidrejected1 s, then Scanningtap, then Scanning
One machine owns the flow, so a frame arriving while a red result is on screen cannot replace it. Only Scanning accepts codes.

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.

ts
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
}
The repeat guard sits before the decision logic. It only drops repeats of the same text, not new codes.

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.

Sequence diagram of scanning a valid ticket, between Staff, the Scanner screen, the Verifier with IndexedDB, the Sync engine, and the Server. The code comes into view; the scanner calls decide with the code. The verifier writes the check-in event and adds it to the outbox in one transaction, then returns admit, saved. The scanner shows green and plays the tone. Afterwards, the sync engine posts the check-ins to the server, which replies accepted, and the verdict is saved back to the local store.StaffScannerVerifierSyncServercode in viewdecide(code)outbox +1admit, savedgreen, tonePOST batchacceptedsave verdict
The first five steps happen on the phone in well under a second. The last three can take seconds, or hours if the phone is offline, and the door never waits for them.

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?

All estimates. The point: even a 50,000-person festival is a few MB to download and tens of MB on the phone, far below browser storage quotas. The size is not the risk; downloading it late is.
ItemPer ticket (estimate)20,000 tickets50,000 tickets
Ticket id, type, days, order ref, statusabout 70 bytes1.4 MB3.5 MB
Display name and search keysabout 60 bytes1.2 MB3 MB
Hashed email for lookup (SHA-256 as 43 Base64url characters)about 45 bytes0.9 MB2.3 MB
JSON overhead (field names, quotes)about 60 bytes1.2 MB3 MB
Total as JSONabout 235 bytes4.7 MBabout 12 MB
Gzipped on the wireabout 3 times smaller: random ids and hashes barely compressabout 1.5 MBabout 4 MB
In IndexedDB with 3 indexesabout 2 times the JSONabout 10 MBabout 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?

Fetch the whole list on every app start

GET /events/42/attendees returns everything; the app replaces its copy.

Pros

  • Simple, always complete.

Cons

  • About 4 MB on a crowded network every time the app restarts.
  • If the download fails at the door, the phone has nothing.

Paged API calls while scanning

Page through the list in the background while the scanner is already open.

Pros

  • The scanner opens instantly.

Cons

  • Scans before the last page is in see "not found".
  • Staff cannot tell whether the phone is ready.

Versioned snapshot, then deltas

Before doors, download one gzipped snapshot file with a version number, from a CDN.

After that, ask only for changes since the version: new sales, refunds, check-ins from other devices.

Pros

  • One big download, done early on good Wi-Fi.
  • During the event each sync is a few KB.
  • The ready check can say exactly which version the phone holds.

Cons

  • The server must keep a change log per event.
  • A phone that misses the delta window needs a fresh snapshot.

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.

Timeline of one event for a phone. The day before: install the app and download the 4 MB gzipped snapshot of 50,000 rows. One hour before doors: the ready check, persistent storage, and small deltas up to version 429. Doors open: scan locally, push check-ins at once, pull changes every 30 seconds. After the event: drain the outbox, send the final report, and wipe the phone. Underneath, the network: good Wi-Fi before doors, crowded or none during doors, good again after.day beforeinstall the appsnapshot 4 MB50,000 rows1 h beforeready checkpersist storagedeltas to v429doors openscan locallypush at oncepull every 30 safter eventdrain outboxfinal reportwipe the phonethe networkgood Wi-Ficrowded, nonegood again
The big download is scheduled for when the network is still good. During doors the phone only moves a few KB at a time, and nothing on the screen waits for it.

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.

ts
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 ready check is computed from local facts only, so it works with no network.

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?

Ask the server on every scan

Each scan calls the server, which answers first-wins in real time.

Pros

  • Perfect duplicate detection whenever the network works.

Cons

  • Every scan waits on the venue network: seconds, not milliseconds.
  • With no network, the door stops or falls back anyway.

Decide locally, push at once, server broadcasts

Decide on the phone, save the event to the outbox, push it right away when online.

The server stores it, applies first-wins, and sends it to every other device of the event over a WebSocket.

Pros

  • Scans stay instant.
  • Online, a duplicate is caught at the second door if it arrives more than about 1 to 2 seconds after the first (estimate).
  • Offline phones catch up on reconnect.

Cons

  • While a phone is offline, its check-ins are invisible to other doors.
  • Some duplicates are only found after the fact.

Phones talk directly to each other

Devices at the event exchange check-ins over the local network or Bluetooth, with no server.

Pros

  • Works if the internet is down but the local network is up.

Cons

  • Browsers cannot open plain local network sockets, and Web Bluetooth is not available in Safari on iOS.
  • Discovery, trust, and ordering between 40 phones is a whole project.

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.

Sequence diagram of a duplicate across two doors, between Phone A, the Server, Phone B, and Staff B. Phone A admits ticket 7QK2 at 19:02 and sends the check-in to the server. The server pushes it on the live channel, but Phone B is offline and misses it. Staff B scans a screenshot of 7QK2; Phone B admits it from its stale data. When Phone B reconnects it sends its check-in; the server replies duplicate, first at Door 1. The server alerts Phone A's door lead that 7QK2 was admitted twice. A third copy scanned at Phone B now gets ALREADY IN.Phone AServerPhone BStaff Bin 7QK2, 19:02B offline: missedscan copyADMIT (stale)online: in 7QK2duplicate, Door 1alert: 7QK2 twicescan 3rd copyALREADY IN
The failure path is not a crash, it is a stale answer. Sync cannot undo the entry, but it turns it into an alert, and it closes the window for every later copy.

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.

Door lab. 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.Phone A, Door 1onlineready0 in outboxknows checked in:nonePhone B, Door 2onlineready0 in outboxknows checked in:noneServerfirst check-insnoneduplicatesnone yetBoth phones online. Press Next attendee.
Try it with both phones online, then again with Phone B offline from the start. Online, the screenshot copies are rejected at scan. Offline, they get in, and the server only finds them when Phone B syncs.

Policies, undo, and re-entry

  • Single entry (the default): a second in without an out is rejected at scan and flagged after sync.
  • Re-entry allowed: staff check people out at the exit, which appends an out event. The next in is 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 undo event 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.
State machine of one ticket, computed from its events. Not in goes to Inside on an in event. Inside goes to Left on an out event, and Left goes back to Inside on in, when re-entry is on. An undo of the check-in returns Inside to Not in. An in event while Inside, with no out in between, produces a Duplicate admission, which is recorded and reported rather than discarded.Not inInsideLeftinoutundoin, if re-entry onin, no outDuplicate admission
The state is never stored as a flag. It is the result of replaying the events in order, skipping undone ones, which is why two phones can never disagree in a way the fold cannot handle.

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

Architecture. Inside the phone's browser: the Scanner screen sends video to the Frame grabber, which sends bitmaps to the Decoder worker. The worker sends the decoded text to the Scan machine, which shares state with the screen and asks the Verifier to decide. The Verifier reads and writes IndexedDB, which holds tickets, events, and the outbox. The Sync and live part drains the outbox. The Service worker caches the app shell. On the server side: a CDN serves the app shell and snapshot, the Check-in API receives batches, and the Live socket pushes events to the phone.Phone (browser)Scanner screenReactScan machinestates, guardVerifiersignature, rulesFrame grabbercrop, 8 per sIndexedDBtickets, eventsDecoder workerDetector or WASMService workerapp shell cacheSync and liveoutbox, socketstatedecidevideobitmaptextread, writeoutboxServerCDNCheck-in APILive socketapp shellPOSTevents
The scan loop, from screen to IndexedDB, has no arrow to the server. Everything that touches the network sits on the bottom row of the phone and can fail without stopping a scan.
PartRuns whereResponsibilityWhy the boundary is here
Scanner screenMain thread, ReactVideo preview, framing guide, result screen, Undo, torch, manual search entryOnly UI. It reads state and sends events to the scan machine.
Scan state machineMain threadThe states from idle to cooldown; repeat guard; decides when to accept the next codeOne place owns what happens next, so no race between frames and taps.
Frame grabberMain threadCrops and scales frames, sends one at a time, drops frames while busycreateImageBitmap from a video element needs the DOM.
Decoder workerWeb WorkerBarcodeDetector or WebAssembly decode of each frameDecoding takes tens of ms; off the main thread it cannot stutter the UI.
Verifier and rulesMain thread or workerParse payload, check signature, apply door and day rules, fold ticket eventsPure functions over local data: easy to unit test with real ticket fixtures.
Local storeIndexedDBTickets, events (check-ins), outbox, event meta, keysSurvives reloads and crashes; queried by id and by name.
Sync engineMain thread, plus service worker for background retriesPush outbox, pull deltas, clock offset, snapshot downloadThe generic engine from the offline-sync-engine guide, with the event log as its data.
Live channelMain threadWebSocket for check-ins and counts; polling fallbackOptional: the app is correct without it, just slower to see other doors.
Service workerService workerPrecache the app shell, decoder, and sounds; serve them offlineSo 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.

Data flow in five layers, top to bottom: Server and CDN, Sync engine, IndexedDB, Verifier, Scanner screen. Going down on the left: snapshot, deltas, and live events reach the sync engine; rows are written to IndexedDB 1,000 per batch; the verifier reads a ticket row and its events; the screen gets the decision and its reason. Going up on the right: the screen sends the code text and Undo taps to the verifier; the verifier writes the event and the outbox entry in one transaction; the sync engine reads an outbox batch; and posts check-ins to the server, safe to retry.Server and CDNsnapshot, deltas, livePOST checkins, idempotentSync enginerows, 1,000 per batchoutbox batchIndexedDBticket row + its eventsevent + outbox, 1 txnVerifierdecision and reasoncode text, UndoScanner screen
Reads flow down and are cached at every layer. Writes flow up and are saved at the first durable layer, IndexedDB, before the screen shows anything.

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?

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.

EntityLives inOwnerLifetime
Event meta: name, doors, ticket types, rules, keys, snapshot versionIndexedDB eventsServer, read-only on the phoneUntil the event is wiped
Ticket rowsIndexedDB tickets, keyed by id, indexes on event id, name tokens, email hashServer, replaced by snapshot and deltasUntil the event is wiped
Check-in eventsIndexedDB checkins, index on ticket idAppend-only; local ones created here, remote ones arrive by syncUntil synced and the event is wiped
OutboxIndexedDB outboxThis phoneUntil the server acknowledges
Device session: device id, staff, door, tokenIndexedDB plus memoryAuth serviceOne shift, hours
Scanner state, last code, torch, resultMemory, state machineUISeconds
ts
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";
};
The two records the door cares about most. Check-ins are events; a ticket's state is derived, never stored as a flag.
Data model on the phone: four IndexedDB stores. Events holds rules, doors, keys, and the snapshot version. Tickets holds id, type, days, name, and email hash, linked to events by eventId. Checkins holds id, ticketId, action, door, and time, linked to tickets by ticketId. Outbox holds the ids of check-ins not yet sent. A note says a ticket's state is a fold of its check-ins, never a stored flag.eventsrules, doors, keys, versionticketsid, type, days, name, hashoutboxcheck-in ids not yet sentcheckinsid, ticketId, action, door, ateventIdticketIdidstate of a ticket = fold of its checkins, never a stored flag
Snapshots and deltas replace rows in the two top stores. The two bottom stores are written by this phone and are never overwritten by a download.

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.

CallPurposeNotes
POST /devicesRegister this phone for an event and a door; returns a device tokenStaff signs in first; the token is scoped to one event, expires after the event day
GET /events/42/snapshotRedirect to a signed CDN URL of the gzipped snapshotResponse carries version; cached by the CDN for every phone
GET /events/42/changes?since=412Ticket and check-in changes since a versionReturns nextVersion; 410 Gone if the version is too old, so the phone downloads a fresh snapshot
POST /events/42/checkinsPush a batch of check-in events from the outboxIdempotent by event id; returns a verdict per event and the server time
GET /events/42/statsCounts per door and totalPolling fallback for the live channel
WS /events/42/liveServer pushes check-ins from other devices, count updates, and revocationsResume with the last version on reconnect
json
// 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 }
Pushing two check-ins. The first was already admitted at another door, so the server marks it a duplicate and says where.

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.

ts
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_revoked on 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-motion the 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.

MinutesWhat to doWhat to draw
0 to 5Ask about event size, doors, network, re-entry, ticket types, devicesNothing yet; write the numbers in a corner
5 to 10State the throughput budget and the offline-first ruleThe scan loop budget: aim, decode, verify, show, cooldown
10 to 18Payload: opaque id versus signed token, size versus QR version, screenshotsThree payloads with their QR sizes
18 to 25Camera and decoder: getUserMedia, BarcodeDetector or WebAssembly worker, frame dropping, fallback searchThe frame pipeline
25 to 35Offline data and duplicates: snapshot plus deltas, events not flags, first-wins, the risk windowArchitecture, then the two-door sequence
35 to 42Data model, API, security, accessibility in quick passesCheckIn type and the batch endpoint
42 to 45Summarize trade-offs and what you would do with more timePoint 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?"

Think first

Follow-up: "A reseller sells 50 copies of the same ticket screenshot. How would you limit it?"

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.

Junior 0 of 4
Mid-level 0 of 4

References

Concepts behind it

Try next