An online assessment platform should never trust the student's browser with the exam clock, and it should never hold answers only in memory. Put the deadline in Redis, let Node.js work out the remaining time from server time, and save every answer as it changes, with a sequence number so retries do no harm. A student whose Wi-Fi drops at minute 40 then reconnects to the same attempt, with the same answers and the correct remaining time.
If your support inbox fills up after every exam with "my timer reset" or "my answers vanished", this is the build that stops it. Below is working code, followed by the failure modes that only show up when thousands of students start at the same second.
Why does a client-side exam timer break?
A JavaScript countdown in the browser measures the student's device, not the exam. A refresh restarts it. A sleeping laptop throttles it. A changed system clock moves the deadline.
There is a worse problem. If the browser decides when time is up, the browser also decides when to submit, and a dropped connection at that moment means no submission at all.
The fix: the server owns one number, the deadline, and everything else comes from it.
Store the deadline in Redis, not the browser
// attempts.js
import Redis from 'ioredis'
export const redis = new Redis(process.env.REDIS_URL)
const keyFor = (attemptId) => 'attempt:{' + attemptId + '}'
async function serverNowMs() {
const [sec, usec] = await redis.time()
return Number(sec) * 1000 + Math.floor(Number(usec) / 1000)
}
export async function startAttempt(attemptId, durationSec) {
const now = await serverNowMs()
// HSETNX: a double click or retried request never resets the clock
const created = await redis.hsetnx(keyFor(attemptId), 'deadline', now + durationSec * 1000)
if (created) {
await redis.zadd('deadlines', now + durationSec * 1000, attemptId)
}
return getRemaining(attemptId)
}
export async function getRemaining(attemptId) {
const deadline = Number(await redis.hget(keyFor(attemptId), 'deadline'))
return Math.max(0, deadline - (await serverNowMs()))
}
Two details matter here. Time comes from Redis TIME, not from Date.now() on whichever Node instance served the request, so ten app servers with drifting clocks still agree. And HSETNX makes "start" idempotent: a retry returns the existing deadline instead of granting a fresh one.
The browser still shows a countdown. It re-syncs from getRemaining on load, on reconnect and every 30 seconds. The display is cosmetic.
The deadline is not.
How do you autosave answers without duplicates or overwrites?
Save each answer as its own small write, tagged with a per-question sequence number that the client increments on every change. The server applies a write only if its sequence is higher than the stored one. Retries become no-ops, and a slow, older request can never overwrite a newer answer.
A Lua script keeps the check and the write atomic:
-- save-answer.lua
-- KEYS[1] attempt hash | ARGV questionId, seq, answer, graceMs
local deadline = tonumber(redis.call('HGET', KEYS[1], 'deadline'))
if not deadline then return {'err', 'no_attempt'} end
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
if now > deadline + tonumber(ARGV[4]) then return {'err', 'closed'} end
if redis.call('HGET', KEYS[1], 'status') == 'submitted' then return {'err', 'closed'} end
local seqField = 'seq:' .. ARGV[1]
local current = tonumber(redis.call('HGET', KEYS[1], seqField) or '0')
if tonumber(ARGV[2]) <= current then return {'ok', 'stale', current} end
redis.call('HSET', KEYS[1], seqField, ARGV[2], 'ans:' .. ARGV[1], ARGV[3])
return {'ok', 'saved', tonumber(ARGV[2])}
// routes.js (check that the attempt belongs to req.user before this runs)
redis.defineCommand('saveAnswer', { numberOfKeys: 1, lua: fs.readFileSync('./save-answer.lua', 'utf8') })
app.post('/attempts/:id/answers', async (req, res) => {
const { questionId, seq, answer } = req.body
const [kind, status, savedSeq] = await redis.saveAnswer(
'attempt:{' + req.params.id + '}', questionId, seq, JSON.stringify(answer), 5000
)
if (kind === 'err') return res.status(409).json({ error: status })
res.json({ status, seq: savedSeq })
})
A stale reply still counts as success for the client. It means the server already holds that answer or a newer one. The {attemptId} hash tag keeps every key for one attempt in the same Redis Cluster slot, which Lua scripts need.
The browser side: a queue that survives reloads
const STORE_KEY = 'attempt-' + attemptId
const store = JSON.parse(localStorage.getItem(STORE_KEY) || '{}')
const persist = () => localStorage.setItem(STORE_KEY, JSON.stringify(store))
function recordAnswer(questionId, answer) {
const seq = (store[questionId]?.seq || 0) + 1
store[questionId] = { seq, answer, acked: false }
persist()
flush()
}
let retryDelay = 1000
async function flush() {
for (const [questionId, e] of Object.entries(store)) {
if (e.acked) continue
try {
const res = await fetch('/attempts/' + attemptId + '/answers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ questionId, seq: e.seq, answer: e.answer }),
})
if (res.status === 409) return showClosedBanner()
if (!res.ok) throw new Error('save failed')
e.acked = true
persist()
retryDelay = 1000
} catch {
retryDelay = Math.min(retryDelay * 2, 30000)
return setTimeout(flush, retryDelay / 2 + Math.random() * retryDelay)
}
}
}
window.addEventListener('online', flush)
Debounce free-text answers by a second or two before calling recordAnswer. Multiple choice can save on every click.
What happens when a student reconnects?
The client calls a resume endpoint. It runs one HGETALL and returns the remaining time plus every saved answer with its sequence number. Then the client compares the two copies. Where the local sequence is higher, the browser re-sends its copy. Where the server's is higher, or the student switched devices, the server copy wins and seeds the local store.
That comparison also covers the rare case where Redis acknowledged a write and then lost it in a crash. The browser still has the answer and puts it back.
Should the clock pause while a student is offline? That is an exam policy call, not an engineering one. If your rules credit lost time, have the client send a heartbeat every 15 seconds and record lastSeen on the server. On reconnect, add the measured gap to the deadline with HINCRBY, capped at a maximum per attempt. Never accept a gap the client reports.
Which failure modes only show up at exam scale?
None of these show up in a test with ten users. They all show up on the first big exam day.
- The reconnect storm. A campus Wi-Fi blip drops two thousand students, and they all come back within the same few seconds. Jittered backoff spreads the retries, and the resume path touches only Redis, never your primary database. The same pattern hits your login page at exam start, which our guide on handling an EdTech login storm covers in depth.
-
The final-second spike. Every last edit and every submit lands at the deadline. The grace window absorbs requests already in flight. A worker polls the
deadlinessorted set withZRANGEBYSCORE, marks expired attempts as submitted, and copies each hash to Postgres in one transaction. Nobody depends on the browser pressing submit. -
Silent eviction. With
maxmemory-policy allkeys-lru, Redis can drop an active attempt under memory pressure and report nothing. Setnoeviction, turn on AOF withappendfsync everysec, and run a replica. - Database write pressure. Writing every keystroke from thousands of students straight to Postgres turns a busy exam into a lock contention problem. Redis takes the hot writes, and Postgres receives one clean write per attempt at close.
Geminate Solutions has handled 10M+ requests per minute in production on an exam platform. The spike at the deadline, not the average load, is what shaped every one of these choices.
Who can skip all of this?
If you run low-stakes quizzes with a few dozen students and no hard time limit, a database row with started_at and a save button is enough. Add Redis when a lost answer would mean a re-sit, a complaint or a grade dispute.
If you are planning for exam day and want the rest of the stack to hold up, start with the login storm guide above, or ask Geminate Solutions for a free review of your current setup. We reply within 24 hours.
Top comments (0)