Building Network-Resilient Exam Sessions for a CBT System in Nigeria
A few months ago I started building KDC, a school management system for an NGO
school here in Abuja. One of the modules is a CBT (computer-based testing)
engine — students sit exams on shared school computers, timed, timetable-gated,
auto-graded.
It sounds like a standard CRUD problem until you remember where it's running.
Nigerian schools deal with unstable power and inconsistent network connections.
A student can be 12 minutes into a 40-minute exam and the connection drops —
NEPA takes the light, the router reboots, whatever. If the exam session just
dies at that point, you've got a genuinely upset student, a teacher who has to
manually intervene, and a system nobody trusts.
So "what happens when the network drops mid-exam" wasn't an edge case I could
shrug off. It had to be a first-class part of the design.
The naive approach (and why it breaks)
The obvious first design is: student starts exam → session created → student
submits answers as they go → session closes on submit or timeout.
The problem is state. If you don't explicitly track where a student is in
an exam — which questions they've seen, what they've answered so far, how
much time is actually left — then a disconnect forces you into one of two bad
options:
- Restart the exam. Unacceptable. The student loses their timer, their progress, possibly the trust of the whole system.
- Trust the client. Let the frontend hold all the state and just resubmit everything on reconnect. Fragile — browser tabs get closed, local storage gets cleared, and now you have no server-side source of truth for a graded academic exam.
Neither works. The session itself needed to be the source of truth, not the
client.
Designing the ExamSession as a resumable entity
The fix was to make ExamSession a persistent, resumable entity rather than a
transient in-memory thing tied to a socket or a request lifecycle. Every
session tracks:
- which exam and student it belongs to
- a server-authoritative start timestamp (so remaining time is always calculated server-side, never trusted from the client)
- current status (
in-progress,submitted,expired) - answers submitted so far, keyed by question ID
When a student reconnects — new tab, refreshed page, different device on the
same network — the client doesn't start a new session. It asks the server:
"does this student have an active session for this exam?" If yes, the
server returns the existing session state: time remaining (calculated from
the stored start time, not a client clock), and everything already answered.
The frontend just re-renders from that state. From the student's point of
view, it looks like nothing happened.
GET /exam-sessions/active?examId=xxxx
// server logic (simplified)
const existingSession = await ExamSession.findOne({
student: studentId,
exam: examId,
status: 'in-progress'
});
if (existingSession) {
const elapsed = Date.now() - existingSession.startedAt;
const remaining = exam.durationMs - elapsed;
if (remaining <= 0) {
existingSession.status = 'expired';
await existingSession.save();
return autoSubmit(existingSession);
}
return res.json({ session: existingSession, remainingMs: remaining });
}
The key design decision here: time is never trusted from the client.
Whatever the frontend timer shows is just a UI convenience. The moment a
session is fetched or an answer is submitted, the server recalculates
remaining time from the stored startedAt. That's what makes a disconnect
safe — the clock doesn't stop just because the connection did.
Batch-processing answers with a Map for O(1) lookup
The other piece of this was answer submission. Early on I was updating one
answer at a time — student answers a question, client fires a request,
server updates that one field in the session document. That's a lot of
round trips for a 40-question exam, and it's exactly the kind of pattern
that falls apart on a flaky connection: one failed request out of forty
and now the session state is silently incomplete.
I switched to batching. The client periodically syncs the entire current
answer set, and on the server I process it against a Map keyed by question
ID rather than looping through an array with .find() on every question
(which would be O(n) per lookup, O(n²) for a full batch):
const existingAnswers = new Map(
session.answers.map(a => [a.questionId.toString(), a])
);
for (const incoming of submittedAnswers) {
existingAnswers.set(incoming.questionId, {
questionId: incoming.questionId,
selectedOption: incoming.selectedOption,
answeredAt: new Date(),
});
}
session.answers = Array.from(existingAnswers.values());
await session.save();
This does two things: it makes each sync idempotent (resubmitting the same
batch after a dropped connection doesn't create duplicates or corrupt state),
and it keeps the update fast regardless of how many questions are in the
exam.
What I'd tell someone building this from scratch
- Server time, always. Any timed, gradeable system where you let the client be the source of truth for time is a system you don't actually control.
- Idempotency isn't optional on flaky networks. If a retry can happen (and on unreliable connections, it will), your write operations need to be safe to repeat.
- Design for the network you'll actually be deployed on, not the one on your dev machine. A lot of backend tutorials assume stable, low-latency connections. That assumption doesn't hold for a lot of the world, including where I'm building this.
KDC is still in active development — the exam module is done, results and
analytics are next. If you're building anything for an environment with
unreliable infrastructure, I'd genuinely rather over-engineer for
disconnects up front than field angry calls from a school on exam day.
Top comments (0)