Learning platforms have gentle traffic. A few hundred concurrent users, a smooth daily curve, comfortable headroom. Then results are published at 9am and every student, every parent, and half the staff hit one endpoint simultaneously.
The system that has been fine for a year falls over in ninety seconds.
The shape is a spike, not a curve
Ordinary capacity planning assumes load grows and you scale with it. Results day is different: it is a step function with no ramp. Autoscaling reacts in minutes; the spike is over in ten, and the failure happens in the first thirty seconds while new instances are still booting.
You cannot scale into it. You have to already be able to serve it.
Precompute the payload
At the moment of publication every student’s result is fixed. It will not change while they are reading it. So it should not be assembled per request.
Generate each student’s result document when results are finalised — before publication — and store it as a single row keyed by student. The request becomes one primary-key lookup returning a blob. No joins, no aggregation, no permission tree walked per request.
The expensive work happens once, an hour early, against a system nobody is using.
Publish by flipping a flag, not by writing data
The release itself must be instantaneous and reversible. A published_at on the assessment, checked at read. Setting it exposes precomputed rows that already exist; unsetting it hides them again if something is wrong.
Writing the results at publication time is the version that fails, because the write load and the read spike arrive together.
Queue the things that can wait
Notification emails, parent SMS, analytics rollups, PDF generation — none of that needs to happen in the request. Push it onto a queue and let it drain over the following hour. The student sees their result in 200ms; the PDF arrives when it arrives.
Test at the real number
Load test with the actual cohort size in the actual window. Not 10% and extrapolate — the failure modes at 5,000 concurrent are connection pool exhaustion and cache stampede, and neither appears at 500. If you cannot generate that load, at minimum verify the read path is a single indexed lookup, because that is the property that makes the spike survivable.