Design an online judge (LeetCode)
The interviewer asks
“Design an online coding judge like LeetCode or CodeChef. Users read a problem, write code in one of 15 languages, and submit; the system compiles and runs it against hidden test cases and returns Accepted, Wrong Answer, Time Limit Exceeded and so on. It also runs weekly contests with 100,000 participants and a live leaderboard.”
What a strong answer looks like
A strong answer recognises that this is two problems: running untrusted code safely, and handling very spiky load fairly. Submissions are stored, put on a queue and processed by a fleet of judge workers; the API returns a submission id immediately and the client gets the verdict over WebSocket or polling. Each worker runs code inside a strong sandbox (gVisor or Firecracker microVM, or nsjail with seccomp) with no network, cgroup limits on CPU time, memory, processes and output size, and a fresh filesystem per run. Test cases are versioned blobs cached on workers. Contests get their own queue with reserved and pre-scaled capacity so a contest's last-10-minute rush is not stuck behind practice traffic, and the leaderboard is a Redis sorted set with a composite score. Timing fairness (CPU time, pinned cores, rejudge on noisy results) and abuse (fork bombs, crypto mining, reading test files) are what separate a senior answer.
Concepts to mention
1 Requirements
Functional
- Browse problems; submit code in ~15 languages (C++, Java, Python, Go, JavaScript, Rust...)
- 'Run' against sample or custom input, and 'Submit' against all hidden test cases
- Verdicts: Accepted, Wrong Answer, Time Limit Exceeded, Memory Limit Exceeded, Runtime Error, Compilation Error, with runtime and memory stats
- Problems with multiple valid outputs use a custom checker program
- Timed contests with a live leaderboard (score, penalty time, rank)
- Submission history per user and per problem
Non-functional
- Untrusted code must never escape the sandbox, reach the network or read test data it should not
- Verdict p95 under 5 s in practice mode, under 15 s at contest peak
- Contest scale: 100k participants, peak ~1k submissions/s in the last minutes; practice: ~5M submissions/day
- Fair, repeatable timing: the same solution should not flip between AC and TLE
- Leaderboard updates within ~5 s of a verdict
- Submission data never lost once acknowledged
Out of scope (say it out loud)
- Discussion forums and editorials
- In-browser IDE features beyond a code editor
- Interview video calls
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
POST/v1/submissions | {problem_id, language, code, contest_id?, mode: run|submit, custom_input?}; returns 202 {submission_id} |
GET/v1/submissions/{id} | Status and verdict with per-test stats (hidden test details masked) |
WS/v1/stream | Push verdict updates and contest announcements to the client |
GET/v1/contests/{id}/leaderboard?page=&around=me | Ranked page or the window around the user's rank |
GET/v1/problems/{slug} | Statement, samples, limits, languages (cached on CDN) |
POST/admin/problems/{id}/rejudge | Re-run all submissions after a test data fix |
4 Data model
problemsPostgresproblem_idslugstatement_mdtime_limit_msmemory_limit_mbchecker_typetestset_versiontest_manifest (hashes)Small, heavily cached. Statements served via CDN.
submissionsPostgres (partitioned by month) or Cassandrasubmission_iduser_idproblem_idcontest_idlanguagecode_refstatusverdicttime_msmemory_kbfailed_testcreated_atjudged_atIndexes on (user_id, created_at) and (contest_id, problem_id). Code itself in S3 for large volume.
test casesS3 + worker local SSD cacheproblem_id/version/{n}.in.outchecker binaryImmutable, content-addressed; workers download once per version. Never mounted inside the sandbox except the current input.
judge queuesKafka / RabbitMQ / SQSsubmission_idlanguageprioritycontest_idSeparate queues: contest, practice-submit, practice-run. Visibility timeout so a dead worker's job is retried.
leaderboardRedis sorted setkey contest:{id}; member user_id; score = solved × 10^9 − penalty_secondsZADD on each new AC; ZREVRANK for own rank; per-problem first-AC times in a hash. Final standings persisted to Postgres.
contest_scoresPostgrescontest_iduser_idproblem_idfirst_ac_atwrong_attemptsSource of truth to rebuild the sorted set and for final results.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Submit a C++ solution
- 1Client POSTs code; API checks size (≤64 KB), rate limit (e.g. 1 submit per 5 s per user) and stores the submission as pending
- 2API enqueues {submission_id} on the contest or practice queue and returns 202 with the id
- 3A judge worker for C++ pulls the job, fetches code, ensures the problem's test set version is in its local cache (download from S3 if not)
- 4It starts a fresh sandbox from a warm pool, compiles with fixed flags (-O2, time limit 10 s for compilation), and fails fast with Compilation Error if needed
- 5It runs the binary on each test with cgroup limits (CPU time, memory, pids ≤ 64, output ≤ 64 MB), comparing output with a token-based comparator or the problem's checker
- 6It stops at the first failing test (for Submit) and records verdict, max time and memory
- 7Worker publishes the verdict event, updates the submission row, and destroys the sandbox; the WebSocket gateway pushes the verdict to the user
Accepted during a contest
- 1Contest service consumes the AC verdict; if it is the user's first AC on that problem, records first_ac_at and wrong attempts in Postgres
- 2It recomputes score = solved × 10^9 − (sum of solve times + 5 min × wrong attempts) and ZADDs it to contest:{id}
- 3Leaderboard pages are read from Redis with a 2-5 s cache for the top pages; users get their own rank with ZREVRANK
Test data bug found mid-contest
- 1Setter uploads a corrected test set; a new testset_version is created (old files stay immutable)
- 2Admin triggers rejudge of affected submissions on a low-priority queue
- 3Changed verdicts produce events that update contest scores and the leaderboard
7 Deep dives
Sandboxing untrusted code
Every submission is hostile until proven otherwise: fork bombs, infinite memory allocation, reading /etc or test files, opening sockets to mine crypto or exfiltrate data, or exploiting the kernel. Plain Docker containers share the host kernel and are not a security boundary on their own. Use defence in depth. Isolation: gVisor (a user-space kernel that intercepts syscalls) or Firecracker microVMs (lightweight VMs with ~125 ms boot) give a much smaller attack surface; nsjail or isolate (used by many contest systems) combine Linux namespaces, seccomp-bpf syscall filters and cgroups. Limits via cgroups v2: CPU time, memory (memory.max, then MLE), pids.max to stop fork bombs, file size and output size. No network namespace interfaces at all. Filesystem: read-only base image per language, a small tmpfs as the working directory, only the current test's input mounted; expected outputs and other tests stay outside the sandbox. Run as an unprivileged user with no capabilities. Destroy the sandbox after each submission. Finally, run judge hosts in an isolated network segment with no credentials on the machine, so even a full escape finds little to steal.
Fair and repeatable timing
Users notice when the same code gets TLE once and AC the next time, and in contests it changes rankings. Sources of noise: other jobs on the same machine competing for CPU cache and memory bandwidth, CPU frequency scaling, and wall-clock delays from I/O. Mitigations: measure CPU time (user + sys from cgroup accounting), not wall time, for the limit, but also enforce a wall-clock limit (2-3× CPU limit) so sleeping programs cannot hang a worker. Pin each sandbox to dedicated physical cores (cpuset) and run fewer concurrent sandboxes than cores, avoiding hyperthread siblings. Use a homogeneous instance type for the contest pool and disable turbo or fix frequency where possible. Language multipliers (Java and Python get more time) or per-language limits keep it fair across languages. Run each test once normally; if the total is within ~10% of the limit, re-run the slow tests and take the minimum, which removes most flaky TLEs for a small extra cost. Publish the judge hardware and compiler versions so users can reason about limits.
Contest spikes and queue design
Practice traffic is steady; contests concentrate tens of thousands of users into the same 90 minutes, with peaks at the start (easy problem) and the last minutes. One shared queue would let a contest flood practice users, or worse, let practice traffic delay contest verdicts. Use separate queues: contest, practice-submit and practice-run, with workers that always pull from contest first and with a reserved worker pool for contests. Because contests are scheduled, scale before they start: a scheduled job raises the contest pool to the expected size 15 minutes early (machines need minutes to boot and warm caches), then queue-depth autoscaling (KEDA on queue length) handles the rest. Pre-load test data for that contest's problems onto workers before start. Warm sandbox pools per language avoid ~1 s startup per job. Under extreme load, degrade gracefully: 'Run' with custom input is rate-limited first, then practice is paused, and contest submissions keep flowing. Show queue position to users; transparency reduces resubmits that make the queue worse.
Judging logic: comparators, checkers and early exit
Output comparison is not a simple byte compare. Default comparators are token-based (ignore trailing whitespace and line endings) because Windows and Linux line endings otherwise cause angry Wrong Answers. Floating-point answers use an absolute or relative error tolerance such as 1e-6. Problems with many valid answers (print any shortest path) ship a checker program that reads input, expected output and the user's output and decides; checkers run in the sandbox too, with their own limits, since setters make mistakes. Interactive problems need a judge process talking to the user's program over pipes, with both limited. For speed, Submit stops at the first failing test, and tests are ordered with samples and small cases first so most wrong solutions fail in milliseconds. Large inputs (100 MB) stay on local SSD and are passed by file descriptor, not copied. LeetCode-style function problems wrap the user's function in a per-language harness that parses input and serialises output, which must be generated and tested per language.
8 Trade-offs
gVisor / Firecracker vs plain containers with seccomp
MicroVMs and gVisor are safer but add overhead (gVisor slows syscall-heavy programs; Firecracker adds boot time, mitigated with warm pools and snapshots). nsjail-style sandboxes are fast and proven in contest systems. For a public site where anyone can submit, pay for the stronger boundary.
Async verdict vs synchronous API
Holding the HTTP request open for 5-15 s ties up connections and breaks on retries. Async with WebSocket push (polling as fallback) is more robust and lets the queue absorb spikes.
Redis sorted set vs DB queries for the leaderboard
Redis gives O(log n) updates and rank lookups for 100k users easily. Postgres stays the source of truth so the board can be rebuilt; final standings are frozen from Postgres.
Own fleet vs serverless execution
Lambda-style functions scale instantly but have cold starts, inconsistent CPU and per-invocation cost that hurts at contest scale. A dedicated, homogeneous fleet with scheduled scaling gives fairer timing; use spot instances only for practice runs.
9 Bottlenecks
Judge capacity in the last 10 minutes
3-4k cores needed for a short time. Pre-scale, reserve on-demand capacity for contest pools, and keep practice on spot instances that can be preempted.
Leaderboard reads
100k users refreshing the board. Cache top pages for a few seconds, serve 'around me' with ZREVRANK + ZREVRANGE, and freeze the board in the last 15 minutes like many contests do, which also reduces load.
Test data downloads
Hundreds of new workers pulling 2 GB each at contest start can saturate S3 bandwidth for one prefix. Pre-warm caches before the contest and share via a node-local cache.
10 Failure handling
Worker dies mid-judging
The job's visibility timeout expires and another worker re-runs it. Judging is idempotent: same code, same tests, same verdict written to the same submission row.
Sandbox escape attempt detected
Seccomp violations kill the process with Runtime Error and log a security event; repeated attempts suspend the account. Host-level anomaly detection drains and reimages the machine.
Redis leaderboard lost
Rebuild the sorted set from contest_scores in Postgres in seconds (100k rows). Users see the board briefly unavailable, never wrong final results.
11 What impresses
- Stating that containers alone are not a security boundary and layering defences
- Explaining CPU time vs wall time and noisy-neighbour effects on verdicts
- Pre-scaling for scheduled contests rather than relying only on reactive autoscaling
- Keeping expected outputs outside the sandbox
- Graceful degradation order under overload (custom runs, then practice)
- Encoding rank as one composite sorted-set score
12 Mistakes
- Running user code directly on the API server or in a plain container with network access
- Measuring wall-clock time only, leading to flaky TLEs
- One queue for contests and practice
- Synchronous judging inside the HTTP request
- Mounting all test cases (including outputs) into the sandbox
- Recomputing the leaderboard with SQL ORDER BY on every request
13 Follow-ups
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.