The situation
A national assessment body ran online examinations three times a year. Candidates sit at fixed times, which means the entire load arrives inside a five-minute window and stays for three hours.
The platform had failed publicly in two consecutive windows. The second failure made national news, extended the examination by a day and required a formal review. When they came to us they had about eleven months before the next window and no appetite for another incident.
What made it difficult
The concurrency figure gets attention and it is the least interesting constraint. 180,000 concurrent users on a mostly read workload is a solved problem with a CDN and horizontal scaling.
Four things made this genuinely hard.
The devices. A large share of candidates sit in school computer labs on hardware between four and eight years old. Our device research put the median at roughly the performance of a 2019 budget Android tablet. Anything that assumed a modern browser on capable hardware was going to exclude candidates, and excluding candidates from a national examination is not a performance regression, it is a fairness failure.
The networks. School networks under examination conditions are congested by definition, because every machine in the building is doing the same thing at the same moment. We measured effective throughput in test centres at between 200 kbps and 2 Mbps per client, with packet loss and latency spikes that would look like a bug on any normal network.
Losing an answer is unacceptable. Not "we have a retry". A candidate's answer, once entered, must survive a browser crash, a network drop, a power cut in the building and a tab close. There is no acceptable rate of loss.
Accessibility is a legal floor. WCAG 2.2 AA, verified externally, including screen reader users completing the full examination flow and candidates with extra time and other approved accommodations.
What we built
Answers persist locally first. Every keystroke and selection writes to IndexedDB immediately and synchronously from the candidate's point of view. A background worker syncs to the server with exponential backoff and jitter. The interface never blocks on the network and never shows a spinner while saving.
This inverts the usual model. The local store is authoritative during the examination, the server is a durable replica. A candidate can lose connectivity for the entire three hours and still submit a complete paper when it returns, because nothing was ever dependent on the round trip.
Answers carry a monotonic sequence number per question, so out-of-order arrival after a reconnection resolves deterministically rather than by arrival time. Submission is only accepted when the server confirms every sequence number is contiguous, and the candidate sees an explicit unsynced count rather than a false reassurance.
The examination is delivered as static assets. The entire paper, including every question, image and instruction, is compiled to a static bundle and pushed to the CDN before the window opens. Candidates download an encrypted bundle in advance, and the decryption key is released at the start time.
This means that at the moment 180,000 candidates start, they are not hitting an origin server at all. They are decrypting a bundle they already have. The synchronised start, which was the exact moment of both previous failures, no longer touches the application tier.
A budget on the client bundle, enforced in CI. 140 KB of JavaScript compressed, total. On the target device that is the difference between a page that is interactive in under two seconds and one that is not.
Getting there meant server rendering everything possible, no component library, hand-built form controls that are lighter and more accessible than most packaged ones, and system fonts only. It also meant declining three feature requests that would have been reasonable on any other project.
Load testing against the real profile. We built a k6 scenario that reproduced the actual pattern: 180,000 clients arriving inside five minutes, sustained for three hours, with a submission spike at the end and a realistic distribution of network conditions including the bad ones. We ran it eleven times against the full production infrastructure in the four months before the window.
Runs three and six found real defects. Run three exposed a Redis connection pool exhaustion under a specific reconnection storm. Run six exposed a database connection leak in the submission path that only appeared above about 140,000 sessions. Neither would have been found by testing at ten percent of scale, which is the sort of load test that gives teams false confidence.
Accessibility as a build gate. Automated axe checks in CI, plus paid testing sessions with screen reader users at three points during the build rather than an audit at the end. Two significant findings came from those sessions that no automated tool would have caught: the question navigation was technically conformant but practically unusable with a screen reader because of announcement ordering, and the countdown timer produced a live region announcement every minute that made concentration impossible. Both were straightforward to fix at the time and would have been very expensive to fix at the end.
What was harder than expected
Browser diversity on old devices. IndexedDB behaviour on some older Android WebView versions is inconsistent, particularly around quota and eviction. We built a storage abstraction with a tested fallback chain to localStorage and then to in-memory with aggressive sync, and detection logic that picks the strategy on first load. About four percent of candidates run the fallback path.
The encrypted bundle key release. Distributing a decryption key to 180,000 clients within a few seconds, without the key distribution itself becoming the bottleneck, took three attempts. The working answer was a small static file on the CDN, published at start time, cached at the edge for the duration. It is a few hundred bytes and the CDN handles it without noticing.
Convincing stakeholders that fewer features was the plan. The feature list from the previous system included several things nobody had used. Removing them was a political exercise. What made it possible was showing the client the device performance data early and framing every feature as a number of kilobytes on a specific tablet, which turned an argument about priorities into an arithmetic problem.
Results
Three examination windows have run on the platform. Peak concurrency reached 180,400 in the most recent one.
Zero answers lost. This is measured, not asserted: every submitted paper is reconciled against the local sequence log, and there have been no gaps across all three windows.
Largest contentful paint on the reference device over throttled 3G is 1.4 seconds. Time to interactive is 2.1 seconds.
External WCAG 2.2 AA audit passed with no critical or serious findings. Two moderate findings were resolved before the first window.
Infrastructure cost for a window fell by about seventy percent against the previous platform, because the CDN absorbs the load that used to require a large application tier standing by.
What we would tell another team
Design for the worst device your users actually have, not for the median. On a national platform the difference between those two is the difference between a service and a service for some people.
Load test at full scale against real infrastructure, or do not bother. Testing at ten percent tells you almost nothing about the failure modes that appear at a hundred, and both of the defects that would have caused a public failure appeared only above seventy percent of target load.
Make the local store authoritative for anything a user would be devastated to lose. The network is not reliable and designing as if it were is a choice, not a constraint.