cf.completefrontend
My progress
SYSTEM DESIGN · #142

Design an online exam platform

A timed, high-stakes test in the browser: 100,000 candidates press Start at the same minute, often on weak mobile networks, and every answer must survive refreshes, crashes and dropped connections. The hard parts are the start-time rush, a clock the server owns, an answer log that never loses a click, a fair and accessible question screen, and integrity signals that inform humans instead of accusing candidates.

The question

Design the candidate side of an online exam platform. Universities, certification bodies and coaching companies run timed tests on it. The biggest event is a national mock test: 100,000 students start at 10:00 sharp, many on phones with patchy 4G, for a 3-hour paper of 90 questions.

Walk me through how candidates get the paper at the start time, how the timer works, how you make sure no answer is ever lost, what happens when the network drops or time runs out, and what you do about cheating and accessibility.

Real-life examples: Moodle quizzes, Canvas quizzes, Certification exams proctored by ProctorU, Examity and Pearson OnVUE, Hiring tests on HackerRank and HackerEarth, Entrance exam mock tests on Testbook and Unacademy

Requirements, and what makes an exam hard

An online exam platform shows a candidate a set of questions, gives them a fixed amount of time, records their answers, and turns those answers into a score. Moodle and Canvas quizzes do this for university courses. ProctorU, Examity and Pearson OnVUE provide remote proctoring for certification exams: a person or software (the proctor) watches the candidate.

HackerRank and HackerEarth run hiring tests. Testbook and Unacademy run mock tests for Indian entrance exams, where a popular paper can draw a very large crowd at one start time.

Nearby guides cover parts of this, and the list at the end links to them: grading code (online judge), live synchronised rounds (multiplayer trivia), the server clock offset (countdown timer), save and resume (multi-step form, insurance wizard), a general outbox (offline sync engine), webcams (video conferencing) and a crowd arriving at once (live score page). This guide covers what is special when the form is timed, high-stakes and identical for 100,000 people at once.

Start with questions. Each answer moves the design. The middle column is what this guide assumes.

QuestionAnswer we assumeWhat it changes
How many candidates start at the same time?Up to 100,000 at 10:00:00 for a national mock test (an estimate for a large coaching platform); a university quiz has 50 to 500The start-time rush needs a plan of its own
Fixed start for everyone, or a window?Both: scheduled papers start at a fixed time; practice tests open any time within a windowA fixed start creates the thundering herd; a window spreads it
How long is the paper?90 questions, 3 hours, 3 sections, about 1.5 MB with images (estimate)What we can pre-load, and how many answers we save
Which devices and networks?Phones are common, on 4G that drops for seconds at a time; low-end Android with 2 GB of RAMExams must continue offline; small bundles; low memory use
Is it proctored?Optional per exam: none, browser signals only, or webcam snapshotsIntegrity is a configurable layer, not the core
Do candidates need accommodations?Yes: extra time, screen readers, zoom, no-webcam alternativesThe timer is per candidate; the UI must be fully accessible
When do candidates see results?Configurable: instantly for practice, after a review period for real examsScoring stays on the server; the answer key never ships early

Functional requirements

  • A candidate signs in, checks their system, waits in a lobby, and starts the paper at the scheduled time.
  • They see one question at a time, with a palette to jump between questions and mark some for review.
  • Question types: single choice, multiple correct, numeric entry, short text, file upload, and code (handed to the online judge).
  • A countdown shows the time left for the paper and, if the exam has them, for each section.
  • Every answer is saved as it changes. The candidate always sees whether it is saved.
  • The candidate can submit early with a confirmation step, and gets a receipt. At the end time the attempt closes on its own.
  • Results appear instantly or later, depending on the exam settings.

Non-functional requirements

  • No lost answers: an answer the candidate saw on screen must reach the server, even through a crash, a refresh or a 10-minute outage.
  • Fair time: every candidate gets exactly their allowed time, measured by the server, whatever their device clock says.
  • Fast start: the last of 100,000 candidates sees question 1 within about 30 seconds of 10:00 (our target, not an industry figure).
  • Paper secrecy: nobody can read the questions before the start, and the paper does not linger in browser caches afterwards.
  • Accessible: WCAG 2.2 AA, keyboard only and screen reader use, extra time per candidate.
  • Light: under about 150 KB of JavaScript for the exam screen, so a cheap phone opens it quickly.

Out of scope

  • Authoring questions and building papers (a separate admin tool).
  • Grading code and running it in sandboxes: see the online judge guide.
  • The live video stream of a human proctor: see the video conferencing guide. We only cover snapshots and signals.
  • Payments, registration and admit cards.

Check your understanding · Exercise 1

Which of these answers would change the design the most? Select all that apply.

What makes an exam different from a form

On the surface an exam is a long form with a timer. Five properties make it much harder, and each one drives a later chapter.

  1. Everyone arrives at once. A shop's traffic spreads over the day; a scheduled exam sees 100,000 people press Start in the same second, each needing the same 1.5 MB paper.
  2. The clock is part of the result. Ten seconds gained is unfair; ten seconds lost is a complaint, or a legal appeal. So the clock cannot live on the device, where it can be paused or changed.
  3. Losing an answer is a disaster, not a bug. A lost answer can change someone's rank or career, and you can't ask them to sit the exam again tomorrow. Every save must be confirmed, visibly.
  4. The network will fail during the exam. Three hours on mobile 4G almost always includes a few drops, so the exam must keep working offline without stopping the clock.
  5. Some candidates will try to cheat, and you can't stop them from the browser. A web page cannot see the second phone on the desk, and treating weak browser signals as proof hurts honest candidates, disabled ones most.

One more trait shapes everything: an exam has phases, each with its own rules. Before the start, the paper must stay secret but be ready. During the attempt, answers flow constantly and must never be lost. After the end, the attempt is frozen, late answers need a policy, and the paper should disappear from the device.

The life of one attempt on a time line. Lobby from 09:40: the device holds only the encrypted paper, and the rule is keep it secret. Attempt from 10:00: the paper is decrypted in memory and answers flow through the outbox; the rule is lose nothing. Grace from 13:00, when the server closes the attempt: only the outbox remains, and late answers are trusted less. After 13:15: nothing stays on the device, and results are released fairly.LobbyencryptedsecretAttemptpaper + outboxlose nothingGraceoutboxtrust lessAfternothingfair09:4010:0013:0013:15timedevicerule
Each phase has its own rule. An answer event that arrives at 12:59 is normal; one that arrives at 13:05 needs checks; one that arrives at 13:20 is only kept for review.

Multiplayer trivia is the nearest relative, and the contrast helps. There, the server pushes each question to everyone at the same moment, and a late answer simply doesn't count. Here, every candidate gets the whole paper up front, moves at their own pace and can change answers, so the hard parts are durability and fairness over three hours, not tight synchronisation over ten seconds.

Check your understanding · Exercise 2

A candidate's phone loses signal for 4 minutes at minute 70 of a 180-minute exam. What should happen? Select all that apply.

10:00:00: when 100,000 candidates press Start together

The thundering herd is what happens when a crowd that was waiting for one moment all acts at that moment. The live score page guide covers it for fans arriving at the first ball of a match. The exam version is harsher in two ways: the response (the paper) is large and must stay secret until the second it starts, and candidates who get it late lose real exam time.

The naive start, and why it breaks

The naive design: the lobby page shows a countdown, and at 10:00:00 every browser calls GET /attempts/123/paper and renders it. Do the arithmetic (all of these are estimates for our scenario):

QuantityEstimateHow we got it
Candidates100,000The national mock test from the requirements
Paper sizeabout 1.5 MB90 questions of JSON (about 150 KB) plus diagrams and chemical structures as images
Data at 10:00about 150 GB100,000 times 1.5 MB, and the CDN can't help if each response is personal and authenticated
Requests in the first 5 secondsabout 20,000 per secondDevice clocks and lobby timers differ by a few seconds, so the crowd spreads over roughly 5 seconds
Same data over a 20-minute lobbyabout 125 MB per second (1 Gbps)150 GB spread over 1,200 seconds

Twenty thousand authenticated, uncacheable, 1.5 MB responses per second is far more than most origins can serve. Requests time out, browsers retry, retries pile onto the queue, and candidates watch a spinner while their exam time runs. The same 150 GB spread over a 20-minute lobby is a light load for a CDN. So the whole design question is: how do we move the big download out of the start second, without leaking the paper early?

Split what is public, what is per candidate, and what is secret

  • The app shell (HTML, JavaScript, CSS, fonts, and KaTeX's styles for maths) is the same for every candidate and not secret. Serve it from the CDN with long-lived hashed file names, and load it in the lobby. See the CDN caching lesson.
  • The attempt (candidate id, the server's start and end times, the shuffle seed, which paper variant they got, extra time) is personal but tiny. Fetch it in the lobby.
  • The paper is secret until 10:00 but identical for every candidate who has the same variant. A national test may have 4 to 8 variants (sets A to H), not 100,000 different papers. That makes it cacheable.

Decision How does the paper reach 100,000 browsers at the start time?

Everyone fetches at 10:00

The lobby counts down; at zero every client requests its paper from the origin.

Retries with backoff and jitter soften the failures.

Pros

  • Simplest to build and explain.
  • The paper never exists on the device before the start.

Cons

  • The origin takes the whole spike: about 20,000 large requests per second.
  • Candidates on slow 4G spend the first minute of their exam downloading 1.5 MB.
  • Backoff helps the server recover but makes the slowest candidates wait longest.

Waiting room with staggered admission

At 10:00 candidates join a queue; the server admits, say, 4,000 per second (about what one origin can serve, our estimate).

Each candidate's clock starts when they are admitted, so nobody loses exam time.

Pros

  • The origin load is capped and predictable.
  • Works for any paper, even one that can't be pre-loaded.

Cons

  • The last of 100,000 candidates waits about 25 seconds, plus their own download.
  • Start and end times differ by up to that much between candidates, which breaks a strictly identical national schedule.

Encrypted pre-download, key at the start

During the lobby, each client downloads its variant's paper, encrypted with AES-GCM, from the CDN at a random moment in a 20-minute window.

At 10:00 the server releases a 32-byte key per variant; the client decrypts in memory with Web Crypto and renders.

Pros

  • The big download happens before the start, spread out and cached at the CDN edge.
  • The start-second request is tiny and identical per variant, so the CDN can serve it.
  • A candidate on slow 4G already has the paper at 10:00.

Cons

  • Encrypted questions sit on the device before the start; if a key leaks early, the paper leaks.
  • More moving parts: key release, signed CDN URLs, decryption errors, late arrivals.

What we pick: Use the encrypted pre-download for scheduled papers, with the app shell from the CDN, and keep a small waiting room as the fallback for candidates who arrive after the lobby closes or whose download failed. A leaked key only matters during the few minutes before 10:00, and the key never leaves the server until the start. For practice tests that open any time in a window, skip the encryption: the window spreads the load on its own.

everyone fetches at 10:00: Peak is above what the origin serves: timeouts and retries roughly double the wait.

Start-time lab: 100k candidates, a 1.5 MB paper, strategy everyone fetches at 10:00. Origin requests at the start: 100k. Peak origin requests per second: 20k. Paper and key requests from the CDN: 0. Data sent by the origin: 150.0 GB. Last candidate sees question 1 after: 56.0 s.Strategy: everyone fetches at 10:00Origin requests at the start100kPeak origin requests per second20kPaper and key requests from the CDN0Data sent by the origin150.0 GBLast candidate sees question 1 after56.0 s
All numbers are estimates from a simple model: one origin serves about 4,000 papers a second, a slow phone downloads at 2 Mbps, the crowd spreads over 5 seconds, and the paper has 4 variants cached at 20 CDN edges. Change the inputs and watch which strategy keeps the last candidate's wait short.

How the key release works

In the lobby the client gets a signed, short-lived CDN URL for its variant (CloudFront and most CDNs support signed URLs or cookies). It downloads the encrypted blob at a random moment, so 100,000 downloads spread over 20 minutes instead of landing together. Configure the CDN to check the signature but leave it out of the cache key, so everyone with that variant shares one cached copy. The client keeps the blob in memory, and in IndexedDB so a refresh doesn't download it again.

Before 10:00 the key endpoint refuses with a not_open error and the opening time, sent with Cache-Control: no-store so no edge keeps the refusal. At the server's 10:00:00 plus a random jitter of 0 to 2 seconds, each client asks for the key. The origin answers once per variant per edge, and the CDN serves the rest, because the response is the same for everyone with that variant.

ts
async function openPaper(blob: ArrayBuffer, keyB64: string): Promise<Paper> {
  const raw = Uint8Array.from(atob(keyB64), (c) => c.charCodeAt(0));
  const key = await crypto.subtle.importKey("raw", raw, "AES-GCM", false, ["decrypt"]);
  const iv = new Uint8Array(blob, 0, 12); // first 12 bytes: the nonce
  const data = new Uint8Array(blob, 12);
  const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, data);
  return JSON.parse(new TextDecoder().decode(plain)) as Paper;
}
Decrypting the paper at the start time with Web Crypto

AES-GCM also checks integrity: if the blob was cut short or tampered with, decrypt throws instead of returning garbage. Treat that as "download again": the client fetches the blob fresh, which takes seconds on a good network and is exactly what the waiting room fallback is for.

Per-candidate order without per-candidate papers

Candidates sitting side by side in a test centre should not see the same question order, but a separate paper per candidate would leave the CDN nothing to cache. Instead, the blob holds the questions in a fixed order, and the attempt carries a seed: a number that drives a deterministic shuffle on the client. The same seed always gives the same order, so a refresh, a new device and the examiner's review all show the same paper. The question delivery chapter shows the shuffle.

Check your understanding · Exercise 3

Why can the CDN serve the key at 10:00 but not the paper in the naive design? Select all that apply.

The exam clock belongs to the server

The naive timer stores "seconds left" in the page, counts down with setInterval, and saves the number to localStorage so a refresh can continue. It fails in four ways. Timers are throttled in background tabs, so it drifts, and after a refresh or crash it resumes from the last saved number, so the time the page was closed is never counted.

Worse, a candidate can edit localStorage or pause the JavaScript in devtools and gain time. And if the laptop dies and they continue on a phone, the phone knows nothing.

The fix is to store a moment, not a duration, and to store it on the server. When the attempt starts, the server writes startedAt and endsAt into the attempt record. The client never decides how much time is left: every tick it computes endsAt minus the current server time, using the server time offset that the countdown timer guide shows how to measure.

Refresh, crash, device switch: the new page fetches the attempt, gets the same endsAt, and shows the right number. There is nothing on the client to tamper with, because the client's number is only a display.

ts
type Clock = { offsetMs: number }; // serverNow minus Date.now(), from the countdown timer guide

function remainingMs(endsAt: string, clock: Clock): number {
  const serverNow = Date.now() + clock.offsetMs;
  return Math.max(0, Date.parse(endsAt) - serverNow);
}

// The server may move endsAt (an invigilator grants 10 more minutes).
// Every heartbeat reply carries the current endsAt, so the display follows.
function onHeartbeat(reply: { endsAt: string; sectionEndsAt?: string }) {
  attemptStore.update({ endsAt: reply.endsAt, sectionEndsAt: reply.sectionEndsAt });
}
The client only displays the server's deadline

Extra time and sectional timers

Because endsAt is per attempt, accommodations are just data. A candidate with 25% extra time on a 3-hour paper gets endsAt = startedAt + 225 minutes. The admin sets this before the exam; an invigilator can extend one attempt during the exam (after a fire alarm, say), and the next heartbeat brings the new endsAt. The accessibility chapter explains why WCAG accepts this kind of third-party control of time on a test.

Some papers have sectional timers: section A gets 60 minutes, then it locks and section B opens. Store a deadline per section on the server, scaled by the same extra-time factor. When a section's time ends, the client moves to the next section, and the server answers saves to the closed one with section_closed, keeping them only as flagged events. The client shows two clocks: the section's and the whole paper's.

Auto-submit when the device is offline

At zero, the client freezes the inputs, shows "Time is up. Sending your last answers", and flushes any unsent answers. But we can't rely on the client: the laptop may be closed, the phone may be in a tunnel. So the server closes the attempt on its own: a scheduled job (or a lazy check on the next request) moves every attempt past its endsAt into the grace window, then closes it and scores whatever it has.

That leaves the real decision: what about answers the candidate gave before the end that reach the server after it?

Decision What happens to answers that arrive after the end time?

Strict: reject anything after endsAt

The server ignores every save received after endsAt.

Pros

  • Simplest rule; nothing to trust from the client.

Cons

  • A candidate who lost signal at minute 175 loses 5 minutes of answers they gave in time.
  • Punishes weak networks, which are often the poorest candidates.

Grace window with client timestamps

Each answer event carries the server-adjusted time it was given.

The server accepts events stamped before endsAt if they arrive within a grace window, say 15 minutes.

Pros

  • Honest offline candidates keep every answer.
  • Matches how exams on bad networks really behave.

Cons

  • Timestamps come from the client, so a cheater can backdate answers typed after the end.

Grace window, but only for submitting

After endsAt the candidate may press Submit, but can't change answers. Moodle offers this as one of its "When time expires" choices.

Pros

  • No new answers after the end, ever.

Cons

  • Only helps if the answers were already saved on the server; offline answers still in the queue are lost.

What we pick: Use the grace window with client timestamps, and limit the trust. Accept late events only if they are stamped before endsAt, arrive within the window, and the server's own heartbeat log shows the device was disconnected at the time. Late events that fail a check are kept, not dropped, and flagged for human review. The policy is the exam owner's call, so make the window a per-exam setting.

The attempt state machine. Lobby moves to In progress when the key is released at 10:00. In progress moves to Submitted when the candidate confirms and the server has every event, or to Closing when endsAt passes. Closing is the grace window, and moves to Closed when the window ends. Submitted and Closed both move to Scored. An invigilator can extend endsAt while the attempt is in progress, as an audited action.LobbyIn progressSubmittedClosinggrace windowScoredserver onlyClosedfrozenkey at 10:00submit confirmedendsAt passeswindow endsscoreinvigilator mayextend endsAt(audited)
The server owns every move except the candidate's submit. Writing the states down settles questions such as whether an attempt past its deadline can take a save: only during Closing, and only for events stamped before the end.

The figure shows the attempt as a state machine (see the state machines lesson): a small set of named states with explicit moves between them. Writing it down settles questions that otherwise turn into bugs, such as "can an attempt past its deadline receive a save?" (only while Closing, and only for events stamped in time) and "can a submitted attempt be reopened?" (only by an invigilator, as an audited action).

Check your understanding · Exercise 4

A candidate sets their phone clock 20 minutes back during the exam. What happens with this design? Select all that apply.

Answers that never get lost, even offline

The naive save posts the whole answer sheet when the candidate presses Next, and three things go wrong. If the browser crashes between a click and Next, the answer is gone. If two tabs are open, the older tab's sheet can arrive last and overwrite newer answers. And if a slow request is retried while a newer one succeeds, the server may apply them out of order (see the request races lesson), silently turning a correct answer into a blank.

Decision What does the client send to the server when an answer changes?

The whole sheet

PUT /attempts/123/answers with all 90 answers, last write wins.

Pros

  • One simple endpoint; the server always has a complete snapshot.

Cons

  • A stale copy overwrites a fresh one.
  • Every save sends every answer, so 100,000 candidates send far more data than needed.
  • No history: you can't tell what happened when a candidate disputes a lost answer.

One write per question

PUT /attempts/123/answers/q17 with the new value.

Pros

  • Small requests; different questions can't overwrite each other.

Cons

  • Two saves for the same question can still arrive out of order.
  • Still no history for disputes.

An append-only answer log

Every change is an event with a sequence number: { seq: 42, q: "q17", value: { choice: "opt_b" }, at }.

The client batches events; the server stores each once, keyed by device and seq, and the latest event per question wins.

Pros

  • Retries are harmless: the same seq is stored once (an idempotent save).
  • Order is explicit, so late or duplicate requests can't undo newer answers.
  • A full history for disputes and for spotting odd patterns.

Cons

  • More rows on the server (a few hundred events per attempt).
  • The client needs a small outbox and acknowledgements.

What we pick: Use the answer log. The extra rows are cheap, and it is the only option where retries, duplicates, refreshes and crashes are safe by construction. The offline sync engine guide covers the general outbox pattern; here it is a narrow, single-writer version.

Write locally first, then send

When a candidate picks option B, the click handler does two things in order. First it appends the event to an IndexedDB store (see the IndexedDB lesson) and waits for the transaction to complete; only then is the answer considered "saved on this device". Then it schedules a send: a debounce of about 1 second after the last change, but never more than 5 seconds after the first unsent one, so a long text answer goes out every few seconds instead of on every key.

For comparison, Moodle saves a changed response after its "Auto-save delay", a site setting that defaults to 60 seconds. Our 5 seconds is a choice for a high-stakes exam, not a standard.

ts
async function recordAnswer(q: string, value: AnswerValue) {
  const event: AnswerEvent = {
    attemptId, deviceId, seq: ++lastSeq, q, value,
    at: Date.now() + clock.offsetMs, // server-adjusted time, for the grace window
  };
  await db.put("outbox", event);     // resolves after the IndexedDB transaction commits
  ui.setAnswer(q, value);
  ui.setSaveStatus(online ? "saving" : "offline-saved");
  sender.schedule();                 // debounced: 1 s quiet, 5 s at most
}

async function flush() {
  const batch = await db.getAll("outbox");
  if (batch.length === 0) return ui.setSaveStatus("saved");
  const res = await api.post(`/attempts/${attemptId}/events`, { deviceId, epoch, events: batch });
  await db.deleteUpTo("outbox", res.ackedSeq); // the server's highest stored seq
}
Every change becomes a numbered event, stored before it is sent

The server answers with ackedSeq, the highest sequence number it has stored for this device with no gaps before it. The client deletes acknowledged events and keeps the rest. If the request fails or times out, nothing is deleted and the next flush sends the same events again; the server ignores the ones it already has. Retries use exponential backoff with jitter, as in the retries and backoff lesson, so 100,000 phones that reconnect together don't retry in lockstep.

Sequence for saving one answer. The user clicks option B. The question view calls record on the answer log with q17 and B. The answer log puts event seq 42 into IndexedDB, which replies committed. The answer log posts the events to the API; the request times out. After a backoff it retries seq 42, and the API replies ackedSeq 42. The answer log deletes the event and tells the question view the status is Saved, which re-renders.UserQuestionAnswer logIndexedDBAPIclick Brecord q17put seq 42committedPOST seq 42timeoutretry seq 42ackedSeq 42Saved
The answer is safe once IndexedDB commits, before any network call. The timeout is harmless: the retry sends the same seq, the server stores it once, and only the acknowledgement lets the client delete it.

Show the candidate what is saved

Candidates under pressure need one honest line of status, always visible near the timer. Four states are enough: Saved (the server has everything), Saving (a request is in flight), Offline: 3 answers saved on this device (stored locally, will be sent), and Not saved (IndexedDB failed, for example storage is full, and the candidate must keep this tab open). Don't flash "Saving" on every click: show it only if the send takes more than about half a second, or it becomes noise.

Recovery after a crash, and only one active device

When the exam page loads, it fetches the attempt from the server (answers, endsAt, and the last acknowledged seq per device) and reads its own outbox from IndexedDB. Unsent events from before the crash are sent first, then the screen renders the merged answers. If the candidate switched to a new device, its outbox is empty and the server's state is the truth.

To keep ordering simple, the attempt allows one active device at a time. Opening it on a second device takes over the session and bumps the attempt's session epoch, a counter that every write carries. On the first device's next save, the server stores any events stamped before the takeover, flags later ones, and answers 409 Conflict ("this exam continued on another device"); that device then goes read-only. Calling navigator.storage.persist() lowers the chance that the browser evicts the outbox under storage pressure, but it is a request, not a guarantee.

Final submit: confirm, flush, then a receipt

Moodle's button says "Submit all and finish" and asks for confirmation; most exam platforms show a summary first. Our submit has two phases. Phase one is the candidate's: a review screen with counts ("82 answered, 5 not answered, 3 marked for review") and a confirm dialog.

Phase two is the system's: the client flushes the outbox, waits for ackedSeq to reach lastSeq, then calls POST /attempts/123/submit with lastSeq and an Idempotency-Key. If the server is missing any sequence numbers it refuses and lists them, so a submit can never close an attempt with answers still on the device.

On success the server returns a receipt: an id, the server time, the answer count, and a short hash of the stored answers. The candidate sees it on screen and by email, which settles most "I answered that!" disputes in seconds.

Sequence for the final submit. The user presses Submit; the review screen shows 82 answered and 5 blank. The user confirms. The review screen asks the answer log to flush; the answer log sends events 208 to 212 to the API, which replies ackedSeq 212. The review screen then calls submit with lastSeq 212 and an idempotency key. The API closes the attempt and replies with receipt R-8F2K, which the review screen shows to the user.UserReviewAnswer logAPISubmit82 done, 5 blankConfirmflush()events 208-212ackedSeq 212submit, lastSeq 212receipt R-8F2Kshow receipt
Phase one is the candidate's confirm; phase two is the system proving the server has every event up to lastSeq. If it doesn't, the API answers 409 missing_events and the client sends them and tries again, so an attempt never closes with answers still on the device.

Check your understanding · Exercise 5

The client sends events with seq 41 to 45. The request times out, but the server had stored 41 to 43. What happens on the next flush? Select all that apply.

Flaky networks: the exam goes on offline

Three hours on mobile data will include outages: a lift, a train, a router that restarts. The rule: the exam never waits for the network. The paper, the outbox and the deadline all work offline, so what needs design is detecting the outage, telling the candidate the truth, and catching up afterwards.

A heartbeat, not navigator.onLine

navigator.onLine only says whether the device has a network connection; it reports true on a Wi-Fi network with no internet behind it. So the client sends a small heartbeat every 30 seconds (or piggybacks on an answer flush if one just happened). Two missed heartbeats in a row, or a failed flush, switch the client to offline mode.

The reply carries the server time (to refresh the clock offset), the current endsAt and section deadlines, the session epoch (to detect a takeover), and any invigilator message such as "Question 41 has a typo: option C should read 4.2". The server keeps its own log of heartbeats per attempt; that log is what the grace window checks late answers against.

Surviving a refresh while offline

A candidate who refreshes during an outage would get the browser's offline page and panic. A service worker that caches the exam app shell (see the service workers and offline-first lessons) lets the page load without a network and run the same recovery from IndexedDB as after a crash. For that to work, the encrypted paper and its key stay in IndexedDB until the attempt closes; the decrypted questions only ever live in memory.

Time line from minute 70 to 76 of the exam. The network is offline from 71:00 to 74:30. Heartbeats every 30 seconds succeed before and after, and fail during the outage. Question 30 is answered at 70:30 and reaches the server at once. Questions 31, 32 and 33 are answered offline and wait in the outbox. At reconnect the client flushes all three, and the server stores them.networkoffline 71:00 to 74:30heartbeatoutboxq30q31q32q33serversent at onceq30flush 3 eventsq31q32q3370:0072:0074:0076:00
The candidate kept working for the whole outage. The server's failed heartbeats are its own record of when this device was cut off, which is what the grace window checks if an outage overlaps the end.

What the candidate sees

MomentMessageWhy this wording
Outage detected"You're offline. Keep going: your answers are saved on this device. The timer keeps running."Says what to do, what is safe, and the one thing that doesn't change
Still offline"Offline: 6 answers saved on this device, not yet sent"A count makes the risk concrete without alarm
Back online"Back online. All answers sent." (shown for 5 seconds)Closes the loop the first message opened
Time ends offline"Time is up. 6 answers are waiting to be sent. Keep this page open or reopen it with internet within 15 minutes."The grace window, in words a candidate can act on
Storage failed"Your answers can't be saved on this device. Keep this tab open and reconnect as soon as you can."The one case where the risk is real, stated plainly

On reconnect, the client flushes the outbox, measures the clock offset again, and takes endsAt from the reply. If the attempt closed while the candidate was away, the reply says which late events were accepted and which were flagged, and the client shows the receipt. The Background Sync API (see the background sync lesson) can send the outbox even after the tab is closed, but it is Chromium-only, so treat it as a bonus, never as the plan.

The lab puts the chapter together: answer, go offline, crash, and let time run out, first with the answer log and then with memory only.

Minute 172 of 180. Answer questions, go offline, refresh, crash, or let time run out.

Answer safety lab, minute 172 of 180, online, attempt open. Status: Saved. Device outbox: empty. Server: 0 events, 0 accepted late, 0 flagged. Answers lost: 0.Minute 172 of 180SavedThis device: IndexedDB outboxempty: nothing waitingServer: answer events by seqnothing stored yetHighest acked seq: 0 Unsent: 0Answers lost: 0
Each cell is one answer event: sequence number, question and option. On the server, green means stored in time, warm with a thick outline means accepted late inside the grace window, and red means flagged for review. Switch to memory only and crash the browser while offline to see answers disappear.

Check your understanding · Exercise 6

Why does the client detect outages with a heartbeat rather than the online and offline events? Select all that apply.

Delivering questions: types, order, maths and the palette

The exam screen is one question at a time, with a question palette beside it and the timer and save status on top. The wireframe names the parts; the rest of this chapter goes through the decisions inside them.

Wireframe of the exam screen. Top bar: Section B Chemistry, Time left 1:42:10, and the save status Saved. Question panel: Question 17 of 90, the stem, four options as a radio group with option A selected, and buttons Mark (for review), Clear, and Save, next. Palette panel: numbered cells for questions 11 to 25 in five states (answered, not answered, marked, current, not visited), with a Submit button underneath.exam.example/mock-12Section B: ChemistryTime left 1:42:10SavedQuestion 17 of 90Which sample occupies 22.4 L at STP?A 1 mol of any ideal gasB 2 g of heliumC 16 g of oxygenD none of theseMarkClearSave, nextPalette111213141516171819202122232425green: answeredred: blankhatched: markedSubmitfocus moves to the new question
Timer and save status sit together in the top bar, always visible, because they answer the two questions a candidate keeps asking: how long have I got, and is my work safe? The palette is a grid of buttons, one per question, each with a state you can tell apart without colour.

Question types and their answer values

TypeInputAnswer value in the logWatch out for
Single choiceNative radio group in a fieldset with a legend{ choice: "opt_c" }Store option ids, never the position on screen
Multiple correctCheckboxes in a fieldset{ choices: ["opt_a", "opt_d"] }Partial marking rules live on the server
NumericText input with inputmode="decimal"{ text: "4.20" }Avoid type="number": the scroll wheel changes it, and some locales type commas
Short textTextarea with a character count{ text: "..." }Debounced saves; the log keeps every version, so trim old events
File uploadFile input plus drag and drop{ fileId: "f_91" } once the upload finishesUpload separately and resumably; the answer event only points at it
CodeCode editor{ code, language }Running and grading belong to the online judge guide

A seeded shuffle

Shuffling question and option order makes copying from a neighbour harder. The order must be the same every time this candidate's paper is opened (after a refresh, on another device, in an examiner's review), so instead of Math.random, the attempt's 32-bit seed drives a tiny deterministic generator and a Fisher-Yates shuffle. Options such as "None of the above" are marked pinLast and stay at the end. Because answers are stored as option ids, the server scores them without knowing the display order.

ts
function mulberry32(seed: number) {
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function shuffle<T>(items: readonly T[], rand: () => number): T[] {
  const out = [...items];
  for (let i = out.length - 1; i > 0; i--) {
    const j = Math.floor(rand() * (i + 1));
    [out[i], out[j]] = [out[j], out[i]];
  }
  return out;
}
Same seed, same order, on every device

Maths and images

Physics and maths papers are full of formulas. Rendering LaTeX in the browser means shipping a renderer and doing work on a cheap phone at 10:00:00, the worst moment. Instead, render once when the paper is published: KaTeX on the server turns each formula into HTML plus hidden MathML, so the client only needs KaTeX's CSS and fonts (part of the cached app shell), and screen readers get the MathML.

Diagrams are compressed images inside the encrypted blob, each with alt text the author must write. A paper without alt text for its diagrams should fail the publishing check, not the candidate.

The question palette

Candidates for Indian entrance exams practise on the NTA computer-based test interface, so mock test platforms copy its question palette closely. NTA's instructions describe five states, each with its own symbol: Not visited, Not answered (opened but left blank), Answered, Marked for review (no answer), and Answered and marked for review. They also say that a question answered and marked for review is still evaluated.

Coaching sites usually describe the symbols as white or grey, red or orange, green, and purple, with the last state shown as purple plus a green tick; shades vary between sources, so copy them from the official mock test, not from a blog. Our palette adds a shape or icon and a text label to every state, because colour alone fails people with colour blindness. Each cell is a button whose accessible name says it all: "Question 17, answered, marked for review".

Keeping the paper out of caches

  • The encrypted blob must be cached by the CDN but not by the browser's HTTP cache. Send CDN-Cache-Control: max-age=3600 for the CDN (a header from RFC 9213 that major CDNs support) and Cache-Control: no-store for the browser.
  • Decrypted questions live only in memory. Images become blob: URLs, revoked with URL.revokeObjectURL when the attempt closes.
  • The exam page is sent with Cache-Control: no-store, and on submit the app clears the paper from memory and the DOM before showing the receipt, so the Back button shows nothing useful.
  • On shared machines in test centres, the sign-out response sends Clear-Site-Data: "cache", "storage" to wipe IndexedDB, service worker registrations and the HTTP cache for our origin.

Check your understanding · Exercise 7

Why does the answer log store option ids such as opt_c instead of "the third option"? Select all that apply.

Integrity signals and their limits

Exam owners will ask: can we stop cheating? From a normal browser tab, honestly, no: a web page can't see a second phone, a friend in the room, or a second laptop. What it can do is collect signals: events that sometimes go with cheating, and often go with nothing at all. The design question is what to do with them.

SignalBrowser APIWhat it really tells youCommon innocent cause
Left fullscreenfullscreenchange after element.requestFullscreen()Fullscreen ended; the user can always press EscA notification, a reflex Esc, an OS dialog
Tab or app switchvisibilitychange with document.hiddenThe page stopped being visiblePhone call, low battery warning, screen lock
Window lost focusblur on windowFocus went somewhere elseScreen reader, magnifier or dictation software
Copy or pastecopy, paste eventsThe clipboard was used inside the pagePasting their own draft from the same page
More than one screenscreen.isExtended (Window Management API)The device has more than one display; per-screen details need getScreenDetails() and a permission promptA laptop on a docking station; Chromium only, still marked experimental
Face not in frameWebcam snapshots, usually reviewed with softwareThe camera saw no face, or twoPoor light, a turned head, a wheelchair angle, a face detection model that works worse for some skin tones

Fullscreen has limits of its own: requestFullscreen only works inside a user gesture such as a click, the browser always lets the user leave with Esc, and iPhone Safari doesn't support it for ordinary page elements. So "force fullscreen" can only ever mean "ask, and notice when it ends".

Decision What do we do when a signal fires?

Block and punish automatically

Disable copy, paste and right-click; end the attempt after three tab switches.

Pros

  • Feels strict; easy to explain in the exam rules.

Cons

  • Honest candidates lose their exam to a phone call or a low battery warning.
  • Hits disabled candidates hardest: assistive tools steal focus and paste text.
  • Determined cheaters use a second device, which no rule here can see.

Record as evidence for human review

Every signal becomes an integrity event in the attempt's log, with a server timestamp.

After the exam, a reviewer sees a timeline only for attempts that cross a threshold.

Pros

  • No candidate is punished by a script.
  • Patterns (40 tab switches, each before a correct answer) stand out; one blip doesn't.

Cons

  • Needs reviewers and a written appeal process.
  • Deters less than an instant penalty.

Lockdown browser

Candidates install Safe Exam Browser (open source; Windows, macOS, iOS) or Respondus LockDown Browser, which lock the device to the exam page.

The exam server checks that requests come from the configured lockdown browser (Safe Exam Browser sends hashed key headers with each request).

Pros

  • Real control: other apps, system shortcuts and, depending on the platform, screen capture are blocked.

Cons

  • An install step that fails on some devices; neither runs on Android, and Respondus supports iPad but not iPhone, so phone candidates are left out.
  • Doesn't stop a second device either.

What we pick: Treat browser signals as evidence for human review, never as automatic proof. Offer a lockdown browser as an exam-level option for high-stakes papers that run on managed or laptop devices. Show candidates up front which signals are recorded: knowing is itself a deterrent, and it is fairer.

ts
function watchIntegrity(log: (e: IntegrityEvent) => void) {
  document.addEventListener("visibilitychange", () =>
    log({ kind: document.hidden ? "hidden" : "visible", at: serverNow() }));
  document.addEventListener("fullscreenchange", () =>
    log({ kind: document.fullscreenElement ? "fullscreen-on" : "fullscreen-off", at: serverNow() }));
  document.addEventListener("paste", (e) =>
    log({ kind: "paste", chars: e.clipboardData?.getData("text").length ?? 0, at: serverNow() }));
}
Signals go into the same log as answers, nothing more
Integrity pipeline. Browser signals such as hidden, blur and paste become events in the same answer log outbox, which sends them in batches to the attempt log on the server with server timestamps. After the exam, a threshold check looks for patterns, not single blips. Only flagged attempts reach a human reviewer, who sees a timeline, and the decision comes with an appeal. A note says: never an automatic fail.Browser signalshidden, blur, pasteAnswer logsame outboxAttempt logserver timestampseventsbatchedafter the examThresholdpatterns, not blipsReviewersees a timelineDecisionwith an appealflaggedhumannever an automatic fail
Signals are evidence, collected cheaply and judged slowly. The browser never decides anything; a person does, after the exam, with the full timeline and a way for the candidate to respond.

Webcam proctoring, privacy and consent

Webcam snapshots (the video conferencing guide covers getUserMedia) are personal data of the most sensitive kind: a face, a bedroom, sometimes family members. Under laws such as the EU's GDPR, face images processed to identify someone can count as biometric data with extra rules. In 2021, Italy's data protection authority fined Bocconi University 200,000 euros over its remote proctoring, finding among other things that consent required to sit an exam was not freely given. This is not legal advice: the exam owner's legal team decides.

Practical rules that hold almost everywhere: explain clearly, before the exam, what is captured and why; offer a route that doesn't need the webcam; take snapshots at intervals rather than recording continuously if that serves the purpose; keep them only for the review and appeal period; restrict who can view them; and log every view.

Check your understanding · Exercise 8

A candidate's log shows 3 visibilitychange events in a 3-hour exam. What should the system do? Select all that apply.

Accessibility and accommodations

An exam is the place where inaccessible UI does the most damage: a candidate who can't operate the palette with a keyboard loses marks for reasons that have nothing to do with what they know. Universities and licensing bodies usually have legal duties here, so treat WCAG 2.2 AA as the floor, and test with real screen readers (see the screen readers lesson).

Questions a screen reader can answer

  • A single-choice question is a native radio group: a fieldset whose legend is "Question 17 of 90" plus the stem, and input type="radio" options with real labels. Arrow keys move between options for free, and the screen reader says "3 of 4". Custom div options need all of this rebuilt by hand (see the ARIA patterns lesson), so don't.
  • Multiple correct uses checkboxes, and the legend says so: "Select all that apply".
  • When the candidate goes to another question, focus moves to the new question's heading, so a screen reader starts reading the new stem instead of staying on the Next button.
  • Avoid single-letter keyboard shortcuts ("press A to pick option A"): they fire by accident for speech input users. If you add them, let candidates turn them off, which is one of the fixes WCAG 2.1.4 Character Key Shortcuts accepts.

Announcing time without nagging

A live region that reads the timer every second makes the exam unusable for screen reader users. Instead, the visible timer is silent, and a separate polite live region announces at fixed points: by default 30, 10, 5 and 1 minutes left, with a setting to change the schedule or turn it off. A "Time left" button reads the time on demand, and sectional deadlines get the same treatment.

tsx
const MILESTONES = [30, 10, 5, 1]; // minutes, changeable in settings

function TimeAnnouncer({ minutesLeft }: { minutesLeft: number }) {
  const [message, setMessage] = useState("");
  const said = useRef(new Set<number>());
  useEffect(() => {
    const m = MILESTONES.find((x) => minutesLeft <= x && !said.current.has(x));
    if (m === undefined) return;
    said.current.add(m);
    setMessage(`${m} ${m === 1 ? "minute" : "minutes"} left`);
  }, [minutesLeft]);
  return <p className="sr-only" aria-live="polite">{message}</p>;
}
Announce milestones once, in a polite live region

Accommodations are data, not special builds

WCAG 2.2.1 Timing Adjustable (level A) normally requires that users can turn off, adjust or extend a time limit. It has three exceptions: a real-time event such as an auction, a limit that is essential because extending it would invalidate the activity, and a limit longer than 20 hours. Its Understanding document adds that when user control would invalidate the outcome, a third party can control the time, and gives "granting double time on a test" as the example.

For us that means the candidate can't extend their own clock, but the exam owner sets extra time per candidate, as part of the attempt. Rest breaks work the same way: an approved break is a server-recorded pause that moves endsAt by its length, so the clock stays server-owned.

  • Zoom and reflow: at 400% zoom (a 320 CSS pixel wide viewport, as WCAG 1.4.10 Reflow describes) the palette becomes a drawer, and the timer and save status stay in a compact bar.
  • Contrast and colour: palette states have shapes and labels as well as colours; the last-minute timer turns bold, it doesn't flash.
  • Reduced motion: with prefers-reduced-motion: reduce, no animated transitions between questions.
  • Maths and images: KaTeX's MathML for screen readers, and author-written alt text for every diagram.
  • No-webcam route: a documented alternative for proctored exams, as the integrity chapter describes.

Check your understanding · Exercise 9

Which timer designs work for a screen reader user in a 3-hour exam? Select all that apply.

Architecture, data model and API

With the hard parts decided, the architecture follows from them. The guiding idea: the pieces that protect the candidate (the clock, the answer log, the paper) are plain modules outside React, so a rendering bug, a slow re-render or a crashed component can't lose an answer or a second. React components read from them and call them; they never own the truth.

Component architecture. In the browser: Exam UI (question, palette, timer) and Paper vault (decrypts) on top; Attempt (state machine), Heartbeat (every 30 s) and Answer log (outbox, acks) below; then Clock (server offset), Integrity (optional) and IndexedDB; and a Service worker that caches the shell only. On the server side: CDN (shell, paper), Exam API (events), Closer (at endsAt) and Scoring. The CDN sends the blob and key to the paper vault, which feeds the Exam UI. The Exam UI reads the attempt and calls setAnswer on the answer log, which puts events in IndexedDB and sends them to the Exam API. The Exam API feeds the closer, and the closer feeds scoring.BrowserServerExam UIquestion, palette, timerPaper vaultdecryptsAttemptstate machineHeartbeatevery 30 sAnswer logoutbox, acksClockserver offsetIntegrityoptionalIndexedDBoutbox, blobService worker: shell onlyCDNshell, paperExam APIeventsCloserat endsAtScoringblob, keysetAnswereventsput
The clock, the answer log and the paper vault are plain modules, not components: React reads them but never owns the truth. The heartbeat (not drawn as an arrow) carries the clock offset, deadline changes and takeover checks between the client and the API.
PartResponsibilityWhy it is separate
Lobby and system checkChecks the browser, storage, fullscreen and camera support; downloads the encrypted paper at a random momentProblems surface 20 minutes before the start, not at 10:00
Paper vaultHolds the encrypted blob, fetches the key at the start, decrypts in memory, applies the seeded orderThe only code that touches secrets, so it is small and easy to review
ClockMeasures the server offset, ticks once per second, exposes time left for the paper and the sectionOne time source for the timer, the answer stamps and the lobby
Attempt storeThe attempt's state machine, endsAt, the current question, palette statesOne place decides whether input is allowed
Answer log and senderIndexedDB outbox, debounced batches, backoff, acknowledgements, recovery on loadMust keep working when the UI is broken or the network is gone
HeartbeatEvery 30 seconds: offset, deadlines, takeover check, invigilator messagesOnline and offline detection in one place
Integrity watcherListens for signals and appends integrity events to the logOptional per exam; never blocks the exam itself
Service workerCaches the app shell so the exam page loads offlineNever caches the paper or the API
Exam API and closerAttempts, events, submit; a job that closes attempts at endsAtThe server is the judge of time and of what was answered

Most of the client is ordinary: a single-page app built with hashed bundles on the CDN, kept small (about 150 KB of JavaScript, our budget) because the exam page must open fast on a cheap phone. Rendering on the server buys little here: the exam page is personal and secret, and the lobby is the first page anyone sees. What matters is that the shell is cached long before 10:00.

Data flow for the exam screen. Reads: the CDN sends the encrypted blob and key to the paper vault, which gives questions to the Exam UI; the Exam API sends the attempt to the attempt store, which gives time left to the Exam UI. Writes: the Exam UI sends an answer event to the answer log, which puts it in IndexedDB and posts events to the Exam API; the API returns acks, and the answer log sends the save status back to the Exam UI.CDNExam APIPaper vaultmemory onlyAttempt storeendsAt, statusAnswer logseq, outboxIndexedDBExam UIblob, keyattempteventsacksquestionstime leftanswer eventputstatus
Reads flow down from the edge and the API into plain modules and then into components. Every write goes up through the answer log, and the only thing that comes back down is an acknowledgement and the save status.

The data flow figure follows one paper and one answer. Reads flow down from the CDN and the API into the vault and the stores, then into components. Writes flow up: nothing reaches the server without passing through the answer log, and only the acknowledgement comes back, as the save status.

Think first

Why not keep answers in React state and save them with a mutation library such as TanStack Query?

Data model: what lives where

Six kinds of data, each with a different home and lifetime. Keeping them apart is what makes recovery and secrecy simple.

DataLives inLifetime
App shell (JS, CSS, fonts)CDN, HTTP cache, service worker cacheUntil the next release
Attempt (times, seed, variant, extra time)Server; a copy in the attempt store and IndexedDBThe exam, then archived
Encrypted paper and keyCDN; IndexedDB during the attemptDeleted from the device when the attempt closes
Decrypted questionsMemory only (the paper vault)The open tab
Answer and integrity eventsIndexedDB outbox until acknowledged; server foreverDevice: minutes. Server: years, for appeals
Current question, palette filterComponent state and the URL fragmentThe open tab
ts
type Attempt = {
  id: string; examId: string; variant: "A" | "B" | "C" | "D";
  seed: number;                       // drives question and option order
  startedAt: string; endsAt: string;  // ISO, server time; extra time already included
  sections: { id: string; endsAt?: string; locked: boolean }[];
  status: "lobby" | "in_progress" | "closing" | "submitted" | "closed";
  sessionEpoch: number;               // bumps when another device takes over
};

type AnswerValue =
  | { choice: string } | { choices: string[] } | { text: string }
  | { fileId: string } | { code: string; language: string } | { cleared: true };

type AnswerEvent = { attemptId: string; deviceId: string; seq: number; q: string; value: AnswerValue; at: number };
type MarkEvent = { attemptId: string; deviceId: string; seq: number; q: string; marked: boolean; at: number };

type PaletteState = "not_visited" | "not_answered" | "answered" | "marked" | "answered_marked";
The client's main types

Notice that palette states are not stored: they are derived from the events. A question is "answered" if its latest answer event isn't cleared, "marked" if its latest mark event says so, and "not visited" until a visit is recorded. Deriving instead of storing means the palette can never disagree with the answers, even after a crash and a replay. "Marked for review" goes through the log too, so it survives a device switch.

Data model by location. Memory: Questions (decrypted) and Palette (derived from events). IndexedDB: Paper blob (encrypted) and Outbox (answer and integrity events). Server: Attempt (endsAt, seed), Results (after close) and Event log (keyed by device and seq). The paper blob is decrypted with the key into the questions. The outbox events feed the palette. The outbox sends events to the event log and deletes them on ack. The attempt and the event log feed results.MemoryIndexedDBServerQuestionsdecryptedPalettederivedPaper blobencryptedOutboxeventsAttemptendsAt, seedResultsafter closeEvent logdevice + seqdecrypteventssend, delete on ack
Secrets are encrypted on disk and plain only in memory; answers are on disk until the server confirms them, then on the server for good. The palette is never stored: it is computed from the events, so it can't disagree with them.

Check your understanding · Exercise 10

Where should the decrypted question text be kept during the exam? Pick one.

The API between the exam page and the server

Plain REST over HTTPS is enough. Nothing needs a WebSocket: the server rarely has anything to push, and the 30-second heartbeat carries deadline changes and invigilator messages. Fewer long-lived connections also means 100,000 idle candidates cost almost nothing between saves.

http
GET /api/attempts/at_77                    -> 200
{ "id": "at_77", "variant": "B", "seed": 2918841, "status": "lobby",
  "opensAt": "2026-11-15T04:30:00Z", "durationMin": 180, "extraTimePct": 25,
  "paperUrl": "https://cdn.example.com/papers/mock-12/B.bin?Signature=...",
  "serverTime": "2026-11-15T04:11:02.381Z" }

GET /api/papers/mock-12/B/key             (before opensAt)
-> 403 { "code": "not_open", "opensAt": "2026-11-15T04:30:00Z" }

GET /api/papers/mock-12/B/key             (after opensAt, cached at the edge)
-> 200 { "key": "q1V0...base64", "startedAt": "2026-11-15T04:30:00Z" }
Lobby: the attempt, the encrypted paper, and the key at the start
http
POST /api/attempts/at_77/events
{ "deviceId": "dv_3", "epoch": 1, "events": [
  { "seq": 41, "q": "q17", "value": { "choice": "opt_c" }, "at": 1794717320113 } ] }
-> 200 { "ackedSeq": 41, "endsAt": "2026-11-15T08:15:00Z" }
-> 409 { "code": "taken_over", "epoch": 2 }        (another device is active)
-> 409 { "code": "section_closed", "q": "q17" }     (event kept, flagged)

POST /api/attempts/at_77/heartbeat  { "deviceId": "dv_3", "epoch": 1, "outbox": 0 }
-> 200 { "serverTime": "...", "endsAt": "...", "sectionEndsAt": "...", "messages": [] }

POST /api/attempts/at_77/submit     Idempotency-Key: sub_at_77_dv_3
{ "deviceId": "dv_3", "lastSeq": 212 }
-> 200 { "receiptId": "R-8F2K-19QX", "submittedAt": "...", "answered": 82, "hash": "c41e9a" }
-> 409 { "code": "missing_events", "missing": [208, 209] }
During the attempt: answer events, heartbeat, submit

A few rules make this API safe to retry. Every write carries the device id and session epoch, so a stale device is told it was replaced instead of quietly writing. Events are keyed by device and sequence number, so sending them twice is harmless, and submit carries an Idempotency-Key, so a double tap or a retried request returns the same receipt.

Every response that knows the deadline returns it, so the client's endsAt is never more than one request old. Errors have a stable code the client switches on, never a message it parses.

Integrity events use the same events endpoint with a different kind, so they share the outbox, the retries and the ordering. Inside the client, components only see a small interface: useAttempt() for status and deadlines, useTimeLeft() for the clock, useAnswer(q) returning the value and a setAnswer function, and useSaveStatus().

Scoring and results

Scoring happens on the server, always. The answer key never reaches the browser, not even encrypted, because anything on the device can be read with enough effort. When an attempt closes, a scoring job reads the latest answer event per question, compares option ids with the key, applies the marking scheme (for example negative marks for wrong single-choice answers), and stores a result. Code questions go to the online judge's grading pipeline; file uploads and long text go to human markers.

Decision When does the candidate see their score?

Instantly, on the receipt screen

Score right after submit, with correct answers and explanations.

Pros

  • Great for practice and mock tests: learning happens while the questions are fresh.

Cons

  • Early finishers can share the answers while others are still writing.
  • A wrong key entry is seen by everyone at once.

Released later, for everyone at once

Scores are computed but hidden until a release time, after the exam window closes for every candidate.

Pros

  • No leaks during the window; time to check the key and handle challenges.

Cons

  • Candidates wait; the release time is a new thundering herd.

What we pick: Make it an exam setting: instant for practice tests, later release for real or ranked exams. Never show answers or explanations until the last candidate's window, including extra time and the grace period, has closed.

High-stakes exams often publish a provisional answer key and let candidates challenge it before the final key; NTA does this for JEE Main. The frontend for that is a small form per question, but the design point is the data: because every answer is stored by option id, changing the key and re-scoring 100,000 attempts is a batch job, not a migration.

And because the release is a known moment, treat it like the start: precompute every result, serve the results page from a cache keyed by candidate, and expect most of the crowd in the first minutes.

Check your understanding · Exercise 11

A practice test shows answers and explanations right after submit. Which change makes it safe to reuse the same paper for a ranked mock test? Select all that apply.

Running it in the interview

In 45 minutes you can't cover all of this. Spend the time where the problem is unusual, and say out loud why you are skipping the ordinary parts.

MinutesWhat to doWhat to say or draw
0 to 5RequirementsScale and start type (fixed or window), devices and network, proctoring level, accommodations
5 to 8What makes it hardThe five properties: herd, server clock, no lost answers, offline, weak integrity signals
8 to 18Start and timerThe arithmetic for 10:00:00, the encrypted pre-download, endsAt on the server, the late-answer policy
18 to 30Answer safetyThe answer log with sequence numbers, IndexedDB first, acknowledgements, save status, two-phase submit; draw the save sequence
30 to 38Architecture and APIThe component diagram with the log, clock and vault outside React; the events and submit endpoints
38 to 45Integrity, accessibility, resultsSignals as evidence for humans; radio groups and timer announcements; scoring on the server

Draw the attempt state machine early: it answers half the follow-up questions before they're asked. When you mention a number, say whether it is an estimate. Interviewers trust "about 150 GB, my estimate from 100,000 times 1.5 MB" more than a confident figure from nowhere.

Think first

Follow-up: "What if someone leaks the decryption key at 09:58?"

Think first

Follow-up: "The candidate opens the exam in two tabs on the same laptop. What happens?"

Think first

Follow-up: "A test centre loses power for 20 minutes. Now what?"

Summary

  • Do the start-time arithmetic: 100,000 candidates times a 1.5 MB paper is a spike at 10:00:00 but a light load over a 20-minute lobby. Pre-download the paper encrypted, per variant, from the CDN, and release a small key at the start.
  • The server owns the clock. Store endsAt per attempt with extra time included; the client only displays it from a measured server offset, so refreshes, device switches and changed clocks don't matter.
  • The server closes attempts at the deadline. Late answers are a policy: accept events stamped before the end inside a grace window, check them against the heartbeat log, and flag the rest for review.
  • Every answer is an event with a sequence number, written to IndexedDB before it is sent, and stored once on the server. Retries, duplicates and crashes are safe by construction.
  • Show an honest save status, and make submit two-phase: the candidate confirms, the system proves it has every event, then a receipt.
  • Shuffle with a seed and store answers by option id; render maths at publish time; keep decrypted questions in memory only.
  • Browser integrity signals are evidence for human review, never automatic proof. Lockdown browsers and webcams are exam options with consent, retention limits and accessible alternatives.
  • Accessibility is part of fairness: native radio groups, focus on the new question, timer announcements at a few milestones, extra time as data.

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 3
Mid-level 0 of 4

References

Concepts behind it

Try next