Scale Interview
System DesignMedium

Design LeetCode

Online Judge System Design

Overview

LeetCode is an online judge: you pick a problem, write a solution in the browser, hit Submit, and a few seconds later the system tells you Accepted, Wrong Answer, Time Limit Exceeded, or Runtime Error. Behind that verdict, the platform took your code — code it did not write and does not trust — compiled or interpreted it, ran it against a set of hidden test cases, compared your output to the expected output, and enforced strict time and memory limits the whole way. Then it did the same thing for the thousands of other people submitting at the same second.

It is labeled "Medium" because the shape is clear once you name the two hard parts. The first is running untrusted code safely: a stranger's program might loop forever, allocate all the memory, fork-bomb the machine, try to read other users' data, or open a network connection to exfiltrate the hidden test cases. The second is judging at scale without blocking the user: you cannot run the code inside the HTTP request (a slow solution would hold the connection for seconds), and during a contest the submission rate spikes by orders of magnitude, so judging has to be an asynchronous pipeline of queue plus workers, not a synchronous call.

A strong answer treats a submission as a job: accept it, drop it on a durable judging queue, and let a worker pool pull it, run it in a locked-down sandbox against the hidden tests, and record a verdict. On top of that sits contests: a timed window where everyone submits at once and a live leaderboard ranks them by score and penalty.

In this walkthrough you'll scope the system, size it, model the data, design the API, draw the submit and judge flows, then go deep on the three things that make or break it: sandboxed execution of untrusted code, the submission pipeline and judging at scale, and contests with a real-time leaderboard.

Out of scope (say so): the code editor UI in the browser (syntax highlighting, the text buffer), the discussion/forum and content-management side (authoring problems and editorials), and payments/subscriptions. This system is the judge — submit code, run it safely against tests, return a verdict, and rank contestants.

Functional requirements

  • Browse problems. List and open problems, each with a statement, constraints, examples, and a starter template — served like ordinary read-heavy content.
  • Submit a solution. A user submits source code in a chosen language for a specific problem. The system runs it against the problem's hidden test cases under time and memory limits and returns a verdict.
  • Return a verdict. Report the outcome — Accepted, Wrong Answer, Time Limit Exceeded, Runtime Error, Compile Error, Memory Limit Exceeded — with the failing test's summary (never leaking the full hidden input) and runtime/memory used.
  • Run untrusted code safely. Execute arbitrary user programs without letting one submission harm the host, read other users' data, reach the network, or starve other jobs.
  • Contests. Run a timed contest where many users solve a fixed problem set, with a live leaderboard ranking them by score and penalty/time.

Tip

Lead with these, then make "safely running untrusted code" and "judging asynchronously, not in the request" the headline non-functional requirements. If you describe only "run the code and diff the output," you've skipped the two things the interviewer is actually probing — isolation and the async pipeline.

Non-functional requirements

  • Strong isolation (security) — the headline. Every submission is code the platform did not write and cannot trust. A submission must not escape its sandbox, read the host or another user's data, or reach the network (deep dive 1).
  • Resource limits per run. Each run gets a hard CPU/wall-time ceiling, a memory cap, and a process/thread cap — so an infinite loop, a memory bomb, or a fork bomb hits its own limit and is killed, never taking down the worker (deep dive 1).
  • Asynchronous judging. Judging happens off the request path. Submit returns immediately with a pending status; the verdict arrives via poll or push once a worker finishes (deep dive 2). Running code inside the HTTP request is a mistake.
  • Elastic throughput for spikes. Normal load is modest, but a contest start is a thundering herd — the queue must absorb the burst and the worker fleet must scale out, so latency degrades gracefully instead of dropping submissions (deep dive 2).
  • Correct, idempotent results. A retried or resubmitted job must not corrupt a verdict; the stored result must be consistent even under worker crashes and redelivery (deep dive 2).
  • Fair, fast leaderboard. During a contest, ranking updates must feel live and be fair (same rules for everyone), while surviving a submission spike (deep dive 3).

Estimations

State assumptions; the goal is to justify async judging, the worker fleet size, and the contest spike handling — not to be exact.

Rounded assumptions

  • Registered users: ~10M, with ~500K active on a normal day.
  • Normal submissions: say each active user submits ~10 times/day → ~5M/day ÷ 86,400 s ≈ ~60 submissions/sec average, with modest peaks.
  • Contest spike. A popular contest draws ~100K concurrent contestants. At the start and around each problem, submissions cluster: realistically ~2K–5K submissions/sec for short bursts — roughly 50–80× the normal rate.
  • Per-run cost: a submission runs against ~10–100 hidden test cases; total judge time is typically ~1–5 seconds of wall time (compile + run all tests), longer for heavy problems.
  • Worker fleet. If a worker judges one submission at a time and each takes ~2 s, one worker does ~0.5 submissions/sec. Normal 60/s needs ~120 busy workers; a 5K/s contest burst would need ~10K worker-slots to keep the queue from backing up — which is why the fleet must autoscale and the queue must buffer.
  • Test data size: hidden test cases per problem range from a few KB to tens of MB (big inputs for hard problems) — small enough to cache on workers, large enough that you don't refetch from origin on every run.

How the numbers affect the design

Signal from the numbers Design decision
Runs take 1–5 s each Never judge in the HTTP request — enqueue and judge async, return pending.
Contest bursts 50–80× normal A durable queue absorbs the spike; the worker fleet autoscales; excess submissions wait, not drop.
Every run executes untrusted code Strong per-run sandbox (container / gVisor / microVM) with hard CPU, memory, and pid limits.
Same test data read on every run of a problem Cache test cases on workers (keyed by problem + version) instead of refetching from origin.
Retries and resubmits happen Idempotent judging keyed by submission id, so a redelivered job overwrites-with-same-result, never double-counts.
Contest leaderboard read by 100K viewers Rank in an in-memory sorted set, cache the top page — never sort a table per read.

The two big levers fall straight out of the numbers. Async judging is forced by the 1–5 s run time — you can't hold an HTTP connection that long at 60–5000/s. And a durable queue plus an autoscaling worker fleet is forced by the 50–80× contest spike — the queue is the shock absorber that lets you scale workers behind it without dropping load.

Core entities / data model

The model separates four things: the problem and its test cases (read-heavy, versioned), the submission (the durable record of one attempt and its verdict), the contest (a timed window over a problem set), and the ranking (a derived, in-memory structure).

Entity Key fields
Problem problem_id (PK), title, statement, constraints, time_limit_ms, memory_limit_mb, test_version
TestCase problem_id, case_id, input_ref, expected_ref, is_sample — hidden inputs/outputs in object storage
Submission submission_id (PK), user_id, problem_id, contest_id?, language, code_ref, status, verdict, runtime_ms, memory_kb, test_version, created_at
Contest contest_id (PK), title, start_at, end_at, problem_ids, scoring rules (points, penalty)
ContestScore (contest_id, user_id) → score, penalty, last_accepted_at — the durable per-user contest standing
Leaderboard a sorted set per contest: member = user_id, ordering key = (score, -penalty) — higher score first, and -penalty so lower penalty breaks ties ahead; derived, in-memory

The Submission is the source of truth for one attempt: it always exists, it carries the verdict once judged, and it pins the test_version it was judged against so a later test-data fix doesn't silently change what an old submission "meant." The actual source code and the test inputs/outputs live in object storage (referenced by code_ref / input_ref), not inline in the row — they're large and write-once.

The Leaderboard is derived from ContestScore: if the in-memory sorted set is lost, it is rebuilt by replaying the durable per-user standings. (This is the same durable-store-plus-rebuildable-index split covered in the gaming-leaderboard breakdown.)

Important

Keep the durable record (Submission, ContestScore) separate from the derived index (Leaderboard sorted set). A common mistake is to treat the in-memory ranking as authoritative — lose the node and you've lost the contest standings. The sorted set is a fast, rebuildable view of the durable scores.

API design

Three parts: read the problems, submit and poll a solution, and read the contest leaderboard. Label the request Body: and the response Returns:.

List / get problems

GET /api/problems?tag=array&difficulty=medium
Returns: 200 { "problems": [ { "problem_id": "two-sum", "title": "...", "difficulty": "easy" }, ... ] }

GET /api/problems/{problem_id}
Returns: 200 { "problem_id": "two-sum", "statement": "...", "constraints": "...",
               "time_limit_ms": 2000, "memory_limit_mb": 256, "samples": [ ... ] }

Ordinary read-heavy content, served from cache/CDN. Note the response carries the sample cases only — the hidden test cases are never sent to the client.

Submit a solution (async — this is the important one)

POST /api/problems/{problem_id}/submissions
Body: { "language": "python3", "code": "class Solution: ...", "contest_id": "weekly-390" }
202 Accepted
Returns: { "submission_id": "sub_9f21", "status": "pending" }

This does not run the code. It stores the Submission (status pending), writes the code to object storage, and enqueues a judging job — then returns 202 immediately. The 202 (not 200) signals "accepted for processing, not done." An idempotency key (a client-supplied token, or a hash of user+problem+code) lets a retried POST return the same submission_id instead of creating a duplicate.

Poll for the verdict

GET /api/submissions/{submission_id}
Returns: 200 { "submission_id": "sub_9f21", "status": "judged",
               "verdict": "Wrong Answer", "failed_case": 7,
               "runtime_ms": 45, "memory_kb": 16384 }

The client polls (or subscribes over a WebSocket/SSE channel) until status becomes judged. The response gives the verdict and a summary of the failing case — its index, never its full hidden input.

Get the contest leaderboard

GET /api/contests/{contest_id}/leaderboard?limit=100
Returns: 200 { "entries": [ { "rank": 1, "user_id": "u_88", "score": 1200, "penalty": 34 }, ... ] }

Return the top N contestants in order — served from a cached page of the sorted set, since the front of the board is the overwhelmingly common read (deep dive 3).

High-level architecture

There's a clean split between the API/web tier (stateless, handles submit and reads — never runs user code), the judging pipeline (queue plus an isolated worker fleet, where the untrusted code actually runs), and the contest/leaderboard service. Three flows matter: submit (enqueue a job), judge (a worker runs the code against tests and records a verdict), and leaderboard update (an Accepted contest submission bumps the ranking). We'll walk each, then combine.

1. Submit (enqueue a judging job)

The API tier stores the submission durably and drops a job on the queue — it never touches user code beyond storing it.

flowchart LR
    Browser[Browser] -->|"1. POST submission"| API[API Tier]
    API -->|"2. store code"| Blob[(Object Storage)]
    API -->|"3. write Submission pending"| DB[(Submissions DB)]
    API -->|"4. enqueue judging job"| Queue[[Judging Queue]]
    API -->|"5. 202 submission_id pending"| Browser
  1. The browser submits code for a problem.
  2. The API writes the raw code to object storage (it's large and write-once).
  3. It records a Submission row with status pending.
  4. It enqueues a judging job carrying submission_id, problem_id, test_version, and language.
  5. It returns 202 with the submission_id immediately — the user is not waiting on the run.

Caution

The API tier must never execute the submitted code, not even to "quickly check it compiles." The moment untrusted code runs on a tier that also holds sessions, database credentials, or serves other users, an escape is catastrophic. All execution happens on the isolated worker fleet, behind the queue.

2. Judge (a worker runs the code against tests)

A worker pulls a job, runs the code in a sandbox against each test case, compares output, and records a verdict.

flowchart LR
    Queue[[Judging Queue]] -->|"1. pull job"| Worker[Judge Worker]
    Worker -->|"2. fetch code + tests (cached)"| Blob[(Object Storage)]
    Worker -->|"3. run in sandbox<br/>per test, with limits"| Sandbox[Isolated Sandbox]
    Sandbox -->|"4. output per test"| Worker
    Worker -->|"5. compare vs expected, decide verdict"| Worker
    Worker -->|"6. write verdict, ack job"| DB[(Submissions DB)]
  1. A judge worker pulls the next job from the queue.
  2. It fetches the code and the problem's test cases (test data is cached on the worker, keyed by problem_id + test_version, so repeated runs of the same problem don't refetch).
  3. For each test case, it runs the user program inside the sandbox under hard CPU/wall-time, memory, and pid limits, feeding the input on stdin.
  4. It captures the program's stdout (and exit status / resource usage).
  5. It compares output to the expected output; the first mismatch is Wrong Answer, a timeout is TLE, a crash is Runtime Error, over-memory is MLE. All pass → Accepted.
  6. It writes the verdict to the Submissions DB (idempotently, keyed by submission_id) and acks the job so the queue can drop it.

Note

Short-circuit on the first failing test: as soon as one case is Wrong Answer or TLE, you can stop and return that verdict rather than running the remaining cases — it's cheaper and it's the verdict the user gets anyway. (Contests that need partial scoring run all cases; see extensions.)

3. Leaderboard update (Accepted contest submission)

When a contest submission is judged Accepted, its score is applied to the durable standing and the derived sorted set.

flowchart LR
    Worker[Judge Worker] -->|"1. verdict Accepted (contest)"| Score[Contest Service]
    Score -->|"2. update durable standing<br/>score + penalty"| CDB[(ContestScore DB)]
    CDB -.->|"3. change captured"| Stream[[Event Stream]]
    Stream -->|"4. apply to sorted set (retried)"| ZSet[(Leaderboard sorted set)]
  1. The worker reports an Accepted verdict for a contest submission.
  2. The Contest Service updates the durable ContestScore — add the problem's points, add the time/penalty (penalty for wrong attempts is standard contest scoring).
  3. The change is captured off the durable write (outbox/CDC).
  4. A consumer applies it to the in-memory leaderboard sorted set, retrying until it converges — the same "drive the index off the durable write" pattern as the gaming-leaderboard.

4. Combined architecture

Putting it together — a stateless API tier, a durable queue feeding an isolated worker fleet, and a contest/leaderboard path fed by verdicts.

flowchart LR
    Browser[Browser] --> API[API Tier]
    API --> Blob[(Object Storage: code + tests)]
    API --> DB[(Submissions DB)]
    API -->|"enqueue"| Queue[[Judging Queue]]
    Queue -->|"pull jobs"| Fleet[Judge Worker Fleet<br/>autoscaled]
    Fleet -->|"run untrusted code"| Sandbox[Sandbox per run<br/>CPU / mem / pid limits, no network]
    Fleet -->|"write verdict (idempotent)"| DB
    Fleet -->|"Accepted in contest"| Contest[Contest Service]
    Contest --> CDB[(ContestScore DB)]
    Contest --> ZSet[(Leaderboard sorted set)]
    Browser -->|"poll verdict / view board"| API

The API tier is stateless and never runs code; the queue absorbs bursts and decouples submit latency from run time; the worker fleet autoscales and runs each submission in a per-run sandbox with hard resource limits and no network; verdicts land in the durable Submissions DB and, for contests, flow into the ContestScore store and the in-memory leaderboard. Reads (poll a verdict, view the board) come back through the API from durable state and caches.

Deep dives

1. Sandboxed execution — running untrusted code safely

This is the headline. Every submission is a program a stranger wrote, and the judge runs it on your machines on purpose. The threats are concrete: the code loops forever (must be killed), allocates all memory (must be capped), fork-bombs the box (recursively spawns children until the process table is exhausted), tries to read the hidden test cases or other users' code, or opens a network socket to exfiltrate data or attack another host. The sandbox has to defeat all of these while still running the program fast enough to judge in a second or two.

The isolation primitives here are the same ladder as a remote IDE — plain container vs syscall-filtering vs microVM — and the remote-IDE breakdown derives that ladder in full. Don't re-derive it; state the choice and focus on what's different for a judge. The key difference: a judge run is a short, batch, headless execution — feed stdin, capture stdout, enforce limits, tear down — not a long-lived interactive session with a persistent filesystem. That makes the sandbox disposable and identical every time, which is a gift: you can pre-build a fresh sandbox per run and throw it away, so nothing leaks between submissions.

The isolation boundary. Pick from the same ladder, matched to the judge's constraints:

Run the code directly on the worker (or a plain shared-kernel container with no extra sandbox) (avoid)

Just exec the user's binary on the worker, or wrap it in an ordinary container and call it a day. The only boundary is Linux namespaces sharing the host kernel — or nothing at all.

Worked example — what goes wrong:

flowchart LR
    Code["User code: opens a socket,<br/>reads /proc, or exploits<br/>a kernel syscall"] -->|"no real boundary"| Escape["Reaches the host:<br/>network, other users' code,<br/>the test-case store"]
    Escape -->|"one machine = full compromise"| Leak["Exfiltrates hidden tests,<br/>attacks other hosts,<br/>or crashes the worker"]
    style Code fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
  • Pro: simplest and fastest — no sandbox overhead.
  • Con: a container is a packaging boundary, not a security boundary against hostile code; running bare is worse. A kernel exploit or a stray network call reaches the host, other users, and the hidden test data. Untrusted code is exactly what this does not defend against.
  • Verdict: avoid. Never run submitted code with only namespaces (or nothing) between it and the host.
Container + syscall-filtering sandbox (gVisor / seccomp) (works well for a judge)

Keep a container but put a user-space kernel (gVisor) or a tight seccomp syscall filter between the code and the host kernel, so the submission's syscalls are intercepted and most never reach the real kernel.

  • Pro: far stronger than a plain container with low overhead and fast start — attractive for a judge because runs are short and you start many of them. A judge's syscall needs are narrow (read stdin, write stdout, allocate memory), so a strict seccomp allowlist fits naturally and blocks network/exec syscalls outright.
  • Con: adds some syscall overhead, and the sandbox kernel itself must stay bug-free (it has had escape CVEs). A reduced shared-kernel surface, not a hypervisor boundary.
  • Verdict: a solid, common choice for an online judge — the short, narrow-syscall workload is a good fit for syscall filtering.
microVM per run (Firecracker) (strongest; recommended for hostile multi-tenant load)

Give each run its own tiny VM with its own guest kernel behind a hardware-virtualization boundary. A microVM like Firecracker boots in ~100 ms, so you get VM-grade isolation without a traditional VM's slow boot.

  • Pro: the strongest practical boundary — a per-run guest kernel plus a hypervisor. Escaping means defeating the hypervisor, not just the kernel. Disposable per run, so nothing leaks between submissions.
  • Con: heavier than a container (more memory per run, slightly slower start), so density is lower and you pre-warm a pool to hide boot time.
  • Verdict: the recommended default when you want maximum isolation for genuinely hostile input — used by serverless platforms for exactly this reason. Many judges run gVisor for the common case and reserve microVMs for the hostile tail.

Caution

Running submitted code with only a plain container (or worse, directly on the worker) is the single most dangerous mistake in this design. Name container escape and test-case exfiltration as the threats, and choose a real sandbox — gVisor/seccomp or a microVM — as the answer.

Resource limits — the part that stops one run killing the worker. A security boundary stops escape; it does nothing about a program that simply burns the box. Every run is pinned to hard quotas via cgroups plus ulimits:

  • Wall-clock + CPU time limit — the problem's time_limit_ms. A process that runs past it is killed and the verdict is Time Limit Exceeded. Enforce wall time too, not just CPU, so a sleeping/blocked process can't hang the slot forever.
  • Memory ceiling — the problem's memory_limit_mb. Allocate past it and the kernel kills the process → Memory Limit Exceeded, never the worker's own OOM.
  • Process/thread cap (the pids cgroup) — stops the fork bomb, the canonical untrusted-code DoS that CPU and memory caps don't directly prevent: a process recursively spawning children exhausts the process table long before it hits a memory cap.
  • No network — run in a network namespace with no route out. A judge never needs the network, so deny it entirely; this also blocks exfiltration of the hidden tests.
  • No filesystem escape — a read-only root plus a tiny writable tmpfs that's discarded after the run; the sandbox can't see the test-case store, other submissions, or host credentials.

Warning

Resource limits are part of isolation, not polish. Without a memory ceiling, one submission's runaway allocation triggers the host OOM killer, which can kill other jobs on the worker. Without a pid cap, a three-line fork bomb takes the worker down. Without a wall-time kill, one infinite loop holds a slot forever. Every run gets CPU, memory, pid, and time limits — or it isn't judged.

Killing runaway processes. The worker is a thin supervisor outside the sandbox. It starts the run with a watchdog timer; when the wall-time limit trips, it kills the entire sandbox (the whole cgroup / the whole microVM), not just the top process — so orphaned children and threads die too. Because the sandbox is disposable, teardown is total: kill it, discard its tmpfs, and the next run gets a pristine one.

Per-language runtime images. Each language (Python, C++, Java, Go, …) needs its compiler/interpreter and standard library. Bake a pre-built image per language so the sandbox has the runtime ready and you're not installing toolchains at judge time. Compilation itself is untrusted work too (a malicious #include or build script), so compile inside the same sandbox with the same limits, and treat a compile failure as the Compile Error verdict.

2. Submission pipeline + judging at scale

Judging cannot happen in the request (runs take 1–5 s) and the contest spike is 50–80× normal, so the pipeline is submit → durable queue → autoscaled worker pool → verdict, engineered to absorb bursts and stay correct under retries.

Why a durable queue, not a synchronous call. Two designs, and only one survives a contest.

Judge synchronously inside the submit request (avoid)

The submit handler runs the code against the tests and returns the verdict in the same HTTP response.

Worked example:

POST /submissions  →  handler runs code (1–5 s) →  returns verdict
 at 60/s normal:  ~60–300 requests held open at once, each burning a worker thread
 at a 5,000/s contest burst:  thousands of multi-second requests pile up
   → connection pool and thread pool exhausted → the whole API tier stalls
   → submissions time out and are dropped
  • Pro: simplest to reason about — one call, one verdict.
  • Con: holds an HTTP connection for seconds per submission; a burst exhausts the API tier; there's no shock absorber, so excess load is dropped instead of queued. Untrusted code also now runs on the request-serving tier — a security no-go.
  • Verdict: avoid. Judging belongs off the request path.
Enqueue on submit, judge on an autoscaled worker pool behind a durable queue (recommended)

Submit writes the submission, enqueues a job, and returns 202 pending in milliseconds. A pool of judge workers pulls jobs at its own pace; the client polls or subscribes for the verdict.

Worked example:

POST /submissions  →  write row + enqueue  →  202 pending (milliseconds)
 queue holds N pending jobs
 worker pool pulls, judges (1–5 s each), writes verdict
 contest burst of 5,000/s:  queue depth spikes, autoscaler adds workers,
   queue drains as the fleet grows — submissions WAIT, never drop
 client polls GET /submissions/{id} until status = judged
  • Pro: submit latency is decoupled from run time; the durable queue absorbs the spike (buffer, don't drop); the worker fleet scales on queue depth; untrusted code runs only on isolated workers.
  • Con: eventual verdict (the user waits a few seconds and polls), plus the operational cost of a queue and an autoscaler. Both are worth it.
  • Verdict: the standard online-judge pipeline. The queue is the shock absorber; the fleet is the elastic capacity behind it.

Scaling the worker fleet for spikes. Scale on queue depth / age of the oldest job, not CPU — a backing-up queue is the true signal that you're behind. During a contest, pre-scale ahead of the known start time (you know when the contest begins), then let the autoscaler track depth. Cap concurrency per worker so each judge run gets a full, uncontended CPU slice — over-subscribing a worker makes timing unfair (a solution's measured runtime would depend on how loaded the worker was).

Important

Judge timing must be fair and reproducible. Two identical submissions should get the same runtime verdict regardless of fleet load. That means one run per CPU slot (no over-subscription), pinned resources, and ideally normalizing against a reference machine — otherwise TLE becomes a lottery based on which worker you landed on.

Caching problem test data. Every submission to a problem reads the same hidden test cases. Refetching tens of MB from origin on every run wastes bandwidth and adds latency, so workers cache test data locally, keyed by problem_id + test_version. The version is the correctness hinge: when a problem's tests are fixed or extended, bump test_version, and caches naturally miss and refetch the new set — old cached data is never silently served. This is also why each Submission records the test_version it was judged against.

Idempotent judging. Queues deliver at-least-once: a worker can crash after judging but before acking, so the job is redelivered and judged again. That must not corrupt results. Make the verdict write idempotent, keyed by submission_id: judging is a pure function of (code, test_version, limits), so re-judging produces the same verdict, and the write is an upsert on submission_id (or a compare-and-set that only transitions pending → judged once). A resubmit is a different submission_id, so it's a new row, not an overwrite.

Warning

With at-least-once delivery, a non-idempotent pipeline double-counts: re-judging a contest submission could add its points twice. Key the verdict write and the score application by submission_id so a redelivered job is a no-op the second time. Never assume exactly-once delivery from the queue.

Result storage. Verdicts land in the durable Submissions DB (the source of truth for "what happened"). It's write-heavy (one row per submission, updated once from pending → judged) and read-heavy on the poll path, so it's a straightforward indexed store — partitioned by submission_id, with secondary access by (user_id) for a user's history and (problem_id, verdict) for stats. Large artifacts (the raw code, full outputs) stay in object storage, referenced by id.

3. Contests + real-time leaderboard

A contest is a timed window over a fixed problem set. Everyone starts together, submissions cluster hard, and a live leaderboard ranks contestants by score and penalty. Three things matter: absorbing the submission spike (deep dive 2 already handles it), ranking live, and fairness.

The ranking structure. Ranking millions of contestants by score can't be a COUNT(*)-per-read table scan — that's the exact trap the gaming-leaderboard breakdown dismantles. Reuse its answer: keep the standings in an in-memory sorted set where the ordering key encodes contest scoring — primary by score descending, tie-broken by penalty (lower time/penalty ranks higher). Then:

  • Top-N (the front page everyone stares at) is a range-by-rank read of the first N, served from a cached page refreshed every second or two.
  • A contestant's own rank is a rank-of-member lookup — O(log N), never a scan.
  • Around-me is a small range read around that rank.

Don't re-derive the skip-list/span-count mechanics here; cite the leaderboard breakdown for why a sorted set gives O(log N) rank. The judge-specific part is what feeds it: only an Accepted verdict changes a score, so the leaderboard is updated off the verdict stream (flow 3), and the durable ContestScore is the rebuildable source behind the in-memory board.

flowchart LR
    V["Accepted verdict<br/>(contest submission)"] -->|"points + penalty"| CS["ContestScore (durable)"]
    CS -->|"apply (retried, idempotent by submission_id)"| Z["Leaderboard sorted set"]
    Z -->|"top-N (cached page)"| Page["Front page: refreshed every 1–2 s"]
    Z -->|"rank-of-member O(log N)"| Me["My rank / around-me"]
    style Z fill:#dcfce7,stroke:#16a34a,color:#14532d

Scoring and penalty. Standard contest scoring: each solved problem is worth points, and the tie-breaker is penalty — typically the time from contest start to the Accepted submission, plus a fixed penalty per wrong attempt on a problem you eventually solve. This rewards solving fast and discourages spraying wrong guesses. The (score, -penalty) composite becomes the sorted-set ordering key so rank is deterministic.

Fairness. A contest is competitive, so fairness is a first-class requirement, not a nicety:

  • Reproducible timing — one run per CPU slot, pinned resources, normalized against a reference machine (deep dive 2), so TLE isn't a function of which worker you got.
  • Consistent judging window — a submission at second 3599 (just before the buzzer) is judged the same as any other; the leaderboard freezes new scoring at end_at, but in-flight submissions are still judged and counted by their submit time.
  • Anti-cheat basics — rate-limit submissions per user (stop brute-forcing the hidden tests by submitting thousands of variants), and run plagiarism detection offline after the contest (structural similarity across submissions) rather than blocking live. Flag, don't silently drop, so appeals are possible.

Rejudge. Sometimes a problem ships with a weak or wrong test set, or a bug is found mid-contest. You must be able to rejudge: bump test_version, re-enqueue the affected submissions, and recompute standings from the new verdicts. Because judging is idempotent and keyed by submission_id, a rejudge is just re-running the pipeline with a new test_version and rebuilding ContestScore — the durable submissions are all still there to replay.

Live-rejudge and mutate the leaderboard mid-contest (situational — disruptive)

When a bad test case is found during the contest, immediately rejudge everyone and update the live board.

  • Pro: the board is "correct" as soon as possible.
  • Con: ranks jump around under contestants mid-solve, which feels unfair and confusing; a flood of rejudge jobs competes with live submissions for the fleet exactly when it's busiest.
  • Verdict: situational. Prefer fixing the test set and rejudging after the contest for standings, or freezing the visible board and announcing a correction, unless the bug is severe enough to warrant a live fix.
Freeze the visible board near the end, rejudge for final standings after (recommended)

Freeze the public leaderboard in the final minutes (a classic ICPC-style freeze — submissions still judge, but the displayed ranks stop updating), and apply any needed rejudge to compute final standings after the contest closes.

  • Pro: a dramatic, fair finish (nobody sees the final shuffle live), and rejudges don't yank ranks around mid-solve; the fleet isn't fighting a rejudge storm during peak load.
  • Verdict: the recommended pattern — freeze for suspense and fairness, rejudge for final correctness once the window closes.

Tradeoffs & bottlenecks

  • Isolation strength vs speed/density. A microVM per run is the strongest boundary but heavier (more memory, slower start, fewer runs per host); gVisor/seccomp is lighter and fits a judge's narrow syscall set well; a plain container is fast but unsafe for untrusted code. Since the code is always hostile, you pay for a real sandbox — the central tradeoff.
  • Sync vs async judging. Synchronous judging is simpler but holds connections for seconds and collapses under a contest burst; async (queue + workers) decouples submit from run time and absorbs spikes, at the cost of an eventual, polled verdict. Async wins here.
  • Pre-warmed vs cold sandboxes. A warm pool of pre-booted sandboxes cuts per-run start latency (important for microVMs) but costs idle compute; cold-start-per-run is cheaper at rest but adds boot time to every judge. Warm the pool ahead of a known contest start.
  • Fleet size vs cost. A bigger standing fleet judges bursts instantly but wastes money at idle; autoscaling on queue depth is cheaper but lags the spike, so you pre-scale before a scheduled contest. Sizing is a forecast against the contest calendar.
  • Strong vs eventual leaderboard. A strongly-consistent, always-exact live board is expensive under a spike; an eventually-consistent board (cached top-N, async score application) is cheap and feels live, at the cost of a second or two of staleness — fine for ranking, which is what makes the leaderboard design affordable.
  • Short-circuit vs full run. Stopping at the first failing test is cheaper and gives the same pass/fail verdict, but partial scoring (extensions) needs every case run — a per-problem choice.

Extensions if asked

Add only the extension that changes the design discussion:

  • Multiple languages. A per-language runtime image and per-language time-limit multipliers (an interpreted language gets a looser wall-clock limit than C++ for the same problem, so the limit tests the algorithm, not the language).
  • Custom / interactive problems. Some problems aren't "diff my stdout against expected" — they need a special judge / checker (multiple correct answers, floating-point tolerance) or an interactive protocol where the judge program talks back and forth with the submission. Both run the checker in the same sandbox.
  • Partial scoring / subtasks. Award points per subtask instead of all-or-nothing, which means running all test groups (no short-circuit) and scoring each group — common in olympiad-style contests.
  • Plagiarism detection. Offline structural-similarity analysis across submissions after a contest, flagging suspicious pairs for review rather than blocking live.
  • Premium / rate limits. Per-user submission rate limits (anti-abuse and anti-brute-force) and priority queues for paying users during heavy load.

What interviewers look for & common mistakes

What interviewers usually reward:

  • Leading with isolation of untrusted code — naming container escape and test-case exfiltration as the threats, and choosing a real sandbox (gVisor/seccomp or a Firecracker microVM), not a plain container.
  • Hard resource limits per run — CPU/wall-time, memory, and a pid cap for fork bombs, plus no network and no filesystem escape — as part of isolation, and killing the whole sandbox on timeout.
  • Async judging — enqueue on submit and return pending, never run code in the request; a durable queue absorbs the contest spike and the worker fleet autoscales on queue depth.
  • Idempotent judging — keyed by submission_id under at-least-once delivery, and versioned test data so a rejudge is a clean replay.
  • A sorted-set leaderboard with a cached top-N, fed off the verdict stream, with the durable ContestScore as the rebuildable source — matching precision to the read-heavy front page.
  • Fairness — reproducible timing (one run per slot), a board freeze near the end, rate limits, and offline plagiarism detection.

Before you finish, do a quick mistake check:

  • Did you treat running untrusted code as the headline, choosing a strong sandbox with container escape / exfiltration as the named threat — not a plain container?
  • Did you add CPU/wall-time, memory, and pid limits (fork bomb!), plus no network and no filesystem escape, and kill the whole sandbox on timeout?
  • Did you judge asynchronously — enqueue on submit, return pending, judge on an autoscaled worker fleet behind a durable queue — instead of in the request?
  • Did you make judging idempotent (keyed by submission_id) under at-least-once delivery, and version the test data?
  • Did you cache test data on workers keyed by problem_id + test_version, rather than refetching from origin every run?
  • Did you rank the contest with a sorted set and a cached top-N, fed off the verdict stream, instead of scanning a table per read?
  • Did you address fairness — reproducible timing, a board freeze, rate limits, and offline plagiarism detection?

Practice this live

Run this exact question with our AI voice interviewer and get feedback.

Start the interview →