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.
| Question | Answer we assume | What 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 500 | The 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 window | A 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 RAM | Exams must continue offline; small bundles; low memory use |
| Is it proctored? | Optional per exam: none, browser signals only, or webcam snapshots | Integrity is a configurable layer, not the core |
| Do candidates need accommodations? | Yes: extra time, screen readers, zoom, no-webcam alternatives | The 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 exams | Scoring 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.
- 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.
- 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.
- 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.
- 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.
- 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.
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):
| Quantity | Estimate | How we got it |
|---|---|---|
| Candidates | 100,000 | The national mock test from the requirements |
| Paper size | about 1.5 MB | 90 questions of JSON (about 150 KB) plus diagrams and chemical structures as images |
| Data at 10:00 | about 150 GB | 100,000 times 1.5 MB, and the CDN can't help if each response is personal and authenticated |
| Requests in the first 5 seconds | about 20,000 per second | Device clocks and lobby timers differ by a few seconds, so the crowd spreads over roughly 5 seconds |
| Same data over a 20-minute lobby | about 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?
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.
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.
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;
}
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.
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 });
}
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?
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 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?
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.
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
}
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.
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.
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.
What the candidate sees
| Moment | Message | Why 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.
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.
Question types and their answer values
| Type | Input | Answer value in the log | Watch out for |
|---|---|---|---|
| Single choice | Native radio group in a fieldset with a legend | { choice: "opt_c" } | Store option ids, never the position on screen |
| Multiple correct | Checkboxes in a fieldset | { choices: ["opt_a", "opt_d"] } | Partial marking rules live on the server |
| Numeric | Text input with inputmode="decimal" | { text: "4.20" } | Avoid type="number": the scroll wheel changes it, and some locales type commas |
| Short text | Textarea with a character count | { text: "..." } | Debounced saves; the log keeps every version, so trim old events |
| File upload | File input plus drag and drop | { fileId: "f_91" } once the upload finishes | Upload separately and resumably; the answer event only points at it |
| Code | Code 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.
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;
}
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=3600for the CDN (a header from RFC 9213 that major CDNs support) andCache-Control: no-storefor the browser. - Decrypted questions live only in memory. Images become
blob:URLs, revoked withURL.revokeObjectURLwhen 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.
| Signal | Browser API | What it really tells you | Common innocent cause |
|---|---|---|---|
| Left fullscreen | fullscreenchange after element.requestFullscreen() | Fullscreen ended; the user can always press Esc | A notification, a reflex Esc, an OS dialog |
| Tab or app switch | visibilitychange with document.hidden | The page stopped being visible | Phone call, low battery warning, screen lock |
| Window lost focus | blur on window | Focus went somewhere else | Screen reader, magnifier or dictation software |
| Copy or paste | copy, paste events | The clipboard was used inside the page | Pasting their own draft from the same page |
| More than one screen | screen.isExtended (Window Management API) | The device has more than one display; per-screen details need getScreenDetails() and a permission prompt | A laptop on a docking station; Chromium only, still marked experimental |
| Face not in frame | Webcam snapshots, usually reviewed with software | The camera saw no face, or two | Poor 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?
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.
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() }));
}
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
fieldsetwhoselegendis "Question 17 of 90" plus the stem, andinput type="radio"options with real labels. Arrow keys move between options for free, and the screen reader says "3 of 4". Customdivoptions 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.
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>;
}
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.
| Part | Responsibility | Why it is separate |
|---|---|---|
| Lobby and system check | Checks the browser, storage, fullscreen and camera support; downloads the encrypted paper at a random moment | Problems surface 20 minutes before the start, not at 10:00 |
| Paper vault | Holds the encrypted blob, fetches the key at the start, decrypts in memory, applies the seeded order | The only code that touches secrets, so it is small and easy to review |
| Clock | Measures the server offset, ticks once per second, exposes time left for the paper and the section | One time source for the timer, the answer stamps and the lobby |
| Attempt store | The attempt's state machine, endsAt, the current question, palette states | One place decides whether input is allowed |
| Answer log and sender | IndexedDB outbox, debounced batches, backoff, acknowledgements, recovery on load | Must keep working when the UI is broken or the network is gone |
| Heartbeat | Every 30 seconds: offset, deadlines, takeover check, invigilator messages | Online and offline detection in one place |
| Integrity watcher | Listens for signals and appends integrity events to the log | Optional per exam; never blocks the exam itself |
| Service worker | Caches the app shell so the exam page loads offline | Never caches the paper or the API |
| Exam API and closer | Attempts, events, submit; a job that closes attempts at endsAt | The 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.
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?
React state and a mutation cache live in memory, so a crash or a refresh loses anything not yet sent, which is exactly the case we must survive. Mutation libraries also retry each request on its own, without a shared order across requests.
The answer log gives order (sequence numbers), durability (IndexedDB before anything else) and idempotency (the server keys by sequence). React only renders what the log says.
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.
| Data | Lives in | Lifetime |
|---|---|---|
| App shell (JS, CSS, fonts) | CDN, HTTP cache, service worker cache | Until the next release |
| Attempt (times, seed, variant, extra time) | Server; a copy in the attempt store and IndexedDB | The exam, then archived |
| Encrypted paper and key | CDN; IndexedDB during the attempt | Deleted from the device when the attempt closes |
| Decrypted questions | Memory only (the paper vault) | The open tab |
| Answer and integrity events | IndexedDB outbox until acknowledged; server forever | Device: minutes. Server: years, for appeals |
| Current question, palette filter | Component state and the URL fragment | The open tab |
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";
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.
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.
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" }
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] }
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?
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.
| Minutes | What to do | What to say or draw |
|---|---|---|
| 0 to 5 | Requirements | Scale and start type (fixed or window), devices and network, proctoring level, accommodations |
| 5 to 8 | What makes it hard | The five properties: herd, server clock, no lost answers, offline, weak integrity signals |
| 8 to 18 | Start and timer | The arithmetic for 10:00:00, the encrypted pre-download, endsAt on the server, the late-answer policy |
| 18 to 30 | Answer safety | The answer log with sequence numbers, IndexedDB first, acknowledgements, save status, two-phase submit; draw the save sequence |
| 30 to 38 | Architecture and API | The component diagram with the log, clock and vault outside React; the events and submit endpoints |
| 38 to 45 | Integrity, accessibility, results | Signals 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?"
The key isn't on any client before the start: the server refuses to release it until the opening time, so a leak at 09:58 has to come from the server side, which is an insider problem for access control and audit logs, not for the browser.
The real risk is right after the start, when early finishers share answers. Several variants and the seeded order mean a list like "1-B, 2-D, 3-A" matches nobody else's screen, and holding results until the window closes removes the easiest source of correct answers.
Think first
Follow-up: "The candidate opens the exam in two tabs on the same laptop. What happens?"
Both tabs share IndexedDB, so they must not both write the outbox with their own sequence counters. Use a BroadcastChannel or the Web Locks API so only one tab is the active exam; the other shows "This exam is open in another tab" with a button to switch. The multi-tab sync guide covers the patterns.
On the server, the session epoch catches anything that slips through, exactly like a second device.
Think first
Follow-up: "A test centre loses power for 20 minutes. Now what?"
Answers given before the cut are in each machine's IndexedDB or already on the server. Clocks keep running on the server, so the exam owner needs an operations tool: extend endsAt by 20 minutes for every attempt at that centre, an audited bulk action. The next heartbeat after power returns brings the new deadline to every screen.
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
endsAtper 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.
References
- Moodle quiz settings: timing, "When time expires" and grace period How a widely used LMS handles overdue attempts
- Understanding SC 2.2.1 Timing Adjustable (WCAG 2.2) The exceptions, and third-party control of time on tests
- MDN: Page Visibility API
- MDN: Fullscreen API
- MDN: Window Management API screen.isExtended and getScreenDetails()
- MDN: SubtleCrypto.decrypt() AES-GCM decryption in the browser
- MDN: Using IndexedDB
- RFC 9213: Targeted HTTP Cache Control (CDN-Cache-Control)
- MDN: Clear-Site-Data
- Safe Exam Browser: overview How a lockdown browser works with an LMS
- Safe Exam Browser: Config Key How the exam server checks requests come from SEB
- Respondus LockDown Browser
- GDPR (Regulation (EU) 2016/679), official text Article 9 and recital 51 on biometric data
- WAI-ARIA Authoring Practices: Radio Group pattern
Concepts behind it
- Offline-first designA way of building apps where the device's own copy of the data is what the UI reads and writes, and a sync engine reconciles it with the server in the background, so the app stays fast and usable on a bad connection or none at all.
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
- CDN cachingKeeping shared copies of your responses on servers near every user, so most requests never travel to your origin: the biggest lever for global speed and for surviving traffic spikes.
- Retries, timeouts, and backoffHow a client survives a flaky network and a struggling server: give every request a deadline, retry only the failures that can heal, space the retries out with exponential backoff and jitter, and stop retrying when the server or the network is clearly down.
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.
- Background sync and queued writesSaving the user's writes in a durable queue when the network is gone, then sending each one exactly once when it comes back, even if the tab was closed: the write half of every offline-capable app.
Try next
- Design an online judge like LeetCodeHard
- Design a multiplayer trivia gameHard
- Design a countdown timerEasy
- Design a multi-step form wizardMedium
- Design a long insurance application formHard
- Design an offline sync engineHard
- Design a video call web app like Google MeetHard
- Design a live cricket score page for 50 million fansHard
- Design state sync across browser tabsMedium