- Accept submissions fast, then judge asynchronously: API → submission store → priority queue → judge workers → verdict store → push to client.
- Every run is untrusted code: no network, read-only FS, non-root, cgroup CPU/memory/pids limits, seccomp syscall filter, output limits, wall-clock kill. Use gVisor or microVMs for stronger isolation than plain containers.
- Measure CPU time on dedicated, identical cores; enforce wall time too; rerun borderline TLEs.
- Contests are flash-sale traffic: pre-scale, prioritise contest queues, cache test data on workers, pretests during contest and full tests after, efficient leaderboards.
- Plagiarism: normalise, tokenise, winnow fingerprints (MOSS-style), flag high-overlap pairs, humans decide.
An online judge is one of the most fun system design questions because it contains a genuinely dangerous component: you are building a service whose job is to run arbitrary code written by strangers. A forkbomb, an infinite loop, a 10 GB allocation, a reverse shell, an attempt to read other users' solutions; your design must survive all of it while giving fair, repeatable timings to honest users.
Indian engineers know these platforms well: LeetCode, Codeforces, CodeChef, and the HackerRank/HackerEarth-style assessments used in campus and off-campus online tests. This guide designs one end to end, covering the sandbox in real depth.
Step 1: Requirements
Functional
- Browse problems; submit code in many languages (C++, Java, Python, Go, JavaScript, Rust…).
- "Run" against sample or custom input, and "Submit" against hidden test cases.
- Return a verdict: Accepted, Wrong Answer, Time Limit Exceeded, Memory Limit Exceeded, Runtime Error, Compilation Error, with runtime and memory.
- Timed contests with a live leaderboard; rejudging when tests change.
- Submission history; plagiarism detection for contests.
Non-functional
- Security: user code must not escape the sandbox, reach the network, read other submissions or harm the host.
- Fairness and determinism: the same code should get the same verdict and similar time on every run.
- Latency: practice verdicts in a few seconds; acceptable to degrade slightly under contest load, but never lose a submission.
- Burst handling: contest traffic is spiky, especially at the start (page loads) and in the final minutes (submissions).
- Availability of the submit endpoint during contests is critical; judging can lag briefly if queued.
Step 2: Capacity Estimates
| Quantity | Assumption / math | Result |
|---|---|---|
| Practice submissions | 2 million/day ÷ 86,400 | ~23/s average |
| Contest participants | Large weekend contest | 30,000 |
| Contest submissions | 30k × 4 problems × 2.5 attempts | 300,000 in 2 hours |
| End-of-contest burst | 20% in the last 10 minutes = 60k / 600 s | ~100 submissions/s |
| Judge cost per submission | Compile ~1 s + 40 tests × ~0.1 s average | ~5 CPU-seconds |
| CPU needed at burst | 100/s × 5 s | ~500 cores busy |
| Machines | 8 judging cores per machine, 2x headroom | ~125 machines at peak, far fewer off-peak |
| Code storage | 2M/day × 3 KB | ~6 GB/day |
| Test data | 5,000 problems × ~50 MB | ~250 GB, cached on workers |
The judge fleet must stretch by roughly an order of magnitude between a quiet weekday and the last ten minutes of a big contest. That screams queue plus autoscaled workers, with pre-scaling because autoscaling reacts too slowly to a 10-minute spike. If you want to sharpen this kind of estimate, see back-of-the-envelope estimation.
Step 3: API Design
POST /v1/submissions
{ "problem_id": "two-sum", "language": "cpp17", "code": "...",
"contest_id": "weekly-412", "mode": "submit" } // or "run" with custom_input
-> 202 Accepted { "submission_id": "s_01J9ZK..." , "status": "QUEUED" }
GET /v1/submissions/{id}
-> { "status": "JUDGING", "progress": "test 17/40" }
-> { "status": "DONE", "verdict": "WRONG_ANSWER", "failed_test": 23,
"time_ms": 412, "memory_kb": 18240 }
GET /v1/submissions/{id}/events (Server-Sent Events: QUEUED → COMPILING → JUDGING → DONE)
GET /v1/contests/{id}/leaderboard?page=1&size=50
GET /v1/contests/{id}/leaderboard/me
The submit call returns 202 with an ID immediately after the code is durably stored and enqueued. The client then follows progress over SSE or polling. Never hold an HTTP request open for the whole judging process; a slow judge would exhaust API server connections exactly during the contest peak.
Step 4: Data Model
CREATE TABLE submissions (
submission_id VARCHAR(32) PRIMARY KEY,
user_id BIGINT NOT NULL,
problem_id VARCHAR(64) NOT NULL,
contest_id VARCHAR(64),
language VARCHAR(16) NOT NULL,
code_ref VARCHAR(255) NOT NULL, -- object storage key, code is not in the row
status VARCHAR(16) NOT NULL, -- QUEUED, COMPILING, JUDGING, DONE
verdict VARCHAR(24),
failed_test INT,
time_ms INT,
memory_kb INT,
testset_version INT, -- which tests it was judged against
created_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_sub_user ON submissions (user_id, created_at DESC);
CREATE INDEX idx_sub_contest ON submissions (contest_id, problem_id, created_at);
-- problems: statement, limits per language, checker type, current testset_version
-- testsets: (problem_id, version) -> object storage prefix + content hash
Storing testset_version on every submission makes rejudging precise: when tests are fixed, find all accepted submissions on older versions and requeue them.
Step 5: High-Level Architecture
Deep Dive 1: The Judging Pipeline
- Dequeue. A worker pulls a job (submission ID, language, problem, testset version) from the highest-priority non-empty queue. It acknowledges only after writing the verdict, so a crashed worker's job is redelivered (at-least-once; judging is idempotent because the verdict is a pure function of code and tests).
- Fetch. Download the code; ensure test data for (problem, version) is in the local cache, keyed by content hash.
- Compile inside a sandbox with its own limits (compilers can be attacked too: a C++ template bomb can consume gigabytes). Compilation errors return immediately.
- Run tests one by one, each in a fresh or reset sandbox, feeding input on stdin and capturing stdout to a size-limited buffer.
- Check output. Exact or token-wise comparison by default; floating-point tolerance or a special checker program for problems with many valid answers (any valid path, any permutation). Checkers are trusted code but still run with limits.
- Stop early at the first failing test for "Submit" (most platforms report the first failure), unless the problem uses partial scoring like IOI-style subtasks.
- Record verdict, max time and max memory across tests; publish an event.
A teaching sketch of resource-limited execution
The Python below shows how CPU, memory, file size and process limits map to Linux setrlimit. It is not a secure sandbox by itself (there is no network, filesystem or syscall isolation), but it makes the verdict logic concrete.
import resource
import signal
import subprocess
OUTPUT_LIMIT = 16 << 20 # 16 MB of stdout at most
def _limits(cpu_s: int, mem_mb: int):
def apply():
resource.setrlimit(resource.RLIMIT_CPU, (cpu_s, cpu_s + 1)) # SIGXCPU, then SIGKILL
resource.setrlimit(resource.RLIMIT_AS, (mem_mb << 20, mem_mb << 20))
resource.setrlimit(resource.RLIMIT_FSIZE, (OUTPUT_LIMIT, OUTPUT_LIMIT))
resource.setrlimit(resource.RLIMIT_NPROC, (64, 64)) # run as a dedicated uid
return apply
def run_test(cmd: list, input_path: str, expected: str, cpu_s: int = 2, mem_mb: int = 256):
before = resource.getrusage(resource.RUSAGE_CHILDREN)
with open(input_path, "rb") as fin:
try:
p = subprocess.run(cmd, stdin=fin, capture_output=True,
timeout=cpu_s * 3, # wall-clock guard (sleep, blocked IO)
preexec_fn=_limits(cpu_s, mem_mb))
except subprocess.TimeoutExpired:
return "TIME_LIMIT_EXCEEDED", None
after = resource.getrusage(resource.RUSAGE_CHILDREN)
cpu = (after.ru_utime - before.ru_utime) + (after.ru_stime - before.ru_stime)
if p.returncode in (-signal.SIGXCPU, -signal.SIGKILL) or cpu > cpu_s:
return "TIME_LIMIT_EXCEEDED", cpu
if p.returncode != 0:
return "RUNTIME_ERROR", cpu # includes failed allocations under RLIMIT_AS
if len(p.stdout) > OUTPUT_LIMIT:
return "OUTPUT_LIMIT_EXCEEDED", cpu
got = p.stdout.decode(errors="replace").split()
return ("ACCEPTED" if got == expected.split() else "WRONG_ANSWER"), cpu
# verdict, cpu = run_test(["./a.out"], "tests/07.in", open("tests/07.out").read())
Limitations worth naming in an interview: RLIMIT_AS limits virtual memory, which penalises runtimes like the JVM and Go that reserve large address spaces, so real judges use cgroup v2 memory.max and memory.peak for accurate Memory Limit Exceeded verdicts; and RUSAGE_CHILDREN is process-wide, so concurrent runs need per-sandbox cgroup accounting (cpu.stat).
Deep Dive 2: Sandboxing Untrusted Code
Defence in depth: assume every layer can fail and make the next one stop the attack.
| Threat | Example | Control |
|---|---|---|
| CPU exhaustion | while(true); | CPU time limit (cgroup cpu.max, RLIMIT_CPU) plus wall-clock kill |
| Memory exhaustion | Allocate 10 GB | cgroup memory.max, swap disabled |
| Fork bomb | while(1) fork(); | cgroup pids.max (e.g. 64) |
| Disk filling / huge output | Print forever | Read-only root FS, small tmpfs, output size cap |
| Network abuse | Download payloads, crypto mining pools, attack internal services | Separate network namespace with no interfaces; workers in an isolated subnet |
| Reading secrets / other users' code / test answers | cat /judge/tests/*.out | Mount only this run's input; expected output never enters the sandbox; fresh FS per run |
| Kernel exploits | Privilege escalation via a syscall bug | seccomp-bpf allowlist, non-root user namespace, no capabilities, gVisor or microVM |
| Side effects between runs | Leave a process running | Kill the whole cgroup after each run, destroy the sandbox |
The building blocks are Linux namespaces (pid, mount, network, user, ipc, uts: what the process can see), cgroups v2 (how much it can use), seccomp-bpf (which system calls it can make) and capabilities (drop all). Tools like nsjail and isolate (used for programming olympiads) package these primitives specifically for judging. Containers are the same primitives with friendlier tooling; the Docker and Kubernetes side is covered in Docker and Kubernetes for backend engineers.
Deep Dive 3: Worker Isolation: Containers vs gVisor vs microVMs
| Option | Isolation boundary | Startup | Overhead | Fit |
|---|---|---|---|---|
| Plain container (runc) + seccomp | Shared host kernel | Fast (sub-second) | Lowest | Acceptable with strict seccomp; a kernel bug is a host compromise |
| gVisor (runsc) | User-space kernel intercepts syscalls | Fast | Syscall-heavy code slower; pure computation near native | Strong default for judging; CPU-bound programs barely notice |
| Firecracker / Kata microVM | Hardware virtualisation, own kernel | Very fast for a VM (designed for serverless) | Memory per VM, more ops work | Highest isolation; good for multi-tenant "run anything" (projects, notebooks) |
| Full VM per submission | Hypervisor | Tens of seconds | High | Too slow; use pools of VMs hosting many sandboxes instead |
A practical design: judge hosts are VMs in an isolated network with no access to production databases; each host runs one sandbox per physical core using gVisor or a hardened container runtime; and hosts are recycled periodically. Even if someone escapes a sandbox, they land on a disposable VM with nothing valuable on it and no route anywhere. Workers pull jobs and push verdicts through narrow, authenticated APIs rather than holding database credentials.
Warm pools
Creating a sandbox per test adds latency. Keep a pool of pre-created sandboxes per language with the runtime already loaded (a warm JVM image, Python interpreter), and reset or replace them after each run. For Java, snapshotting a warmed JVM, or at least keeping compiler containers warm, cuts seconds off each submission.
Deep Dive 4: Fair and Repeatable Time Limits
Users will compare runtimes to the millisecond, and a borderline solution flipping between Accepted and TLE on resubmission destroys trust.
- Measure CPU time, not wall time, for the limit. Wall time depends on scheduling noise. Still enforce a wall limit (say 3x) to kill programs that sleep or block on input.
- One sandbox per physical core, pinned with cpusets, no oversubscription. Hyperthread siblings share execution units, so many judges disable SMT or leave sibling threads idle.
- Homogeneous hardware: a single instance type for the judge pool, with CPU frequency scaling fixed where possible.
- Rerun near the limit: if time is within ~10% of the limit, rerun once and take the minimum.
- Language handling: either per-language limits (Python gets more time) or one limit and let users choose; document the policy. Exclude JVM startup by measuring after a warm-up where possible.
- Test-data I/O: put input on tmpfs so disk speed does not affect timing.
Deep Dive 5: Handling Contest Load
A contest is a scheduled flash sale: you know exactly when the spike comes. The same playbook used for e-commerce sales applies (see flash sale system design).
- Pre-scale. 30 minutes before the start, scale the judge fleet to the expected peak and warm sandbox pools. Reactive autoscaling would add machines after the burst is over.
- Pre-cache test data. Push contest test sets (hidden until start) to every worker in encrypted form, and release the key at contest start. No thundering herd on object storage at T+0.
- Static problem pages. Render problem statements to static HTML and serve via CDN at the start time, so 30,000 simultaneous page loads never hit app servers.
- Priority queues. Contest submissions go to a high-priority queue; practice and rejudges wait. Within the contest queue, cap in-flight submissions per user (for example 2) so one person spamming submit cannot hog workers.
- Rate limit submissions per user per minute at the API (token bucket, see rate limiter design).
- Pretests vs system tests. Codeforces-style rounds judge only a subset of tests (pretests) during the contest and run the full system tests after it ends. This cuts contest-time load drastically and is a smart point to raise.
- Graceful degradation. If the queue grows, keep accepting submissions (they are durable) and show queue position; extend the contest if judging was badly delayed. Never drop submissions.
The queue itself can be Kafka, RabbitMQ, SQS or Redis Streams. Priority queues and per-user fairness are easier in RabbitMQ or with separate queues per priority; the trade-offs are in Kafka vs RabbitMQ vs SQS.
Deep Dive 6: The Contest Leaderboard
In ICPC-style scoring, rank is by problems solved (descending), then penalty (ascending): for each solved problem, minutes from contest start to the accepted submission plus a fixed penalty (commonly 20 minutes) for each earlier rejected attempt on that problem.
- On each verdict event, the leaderboard service updates the user's per-problem state and recomputes their score, then writes to a Redis sorted set with a composite score such as
solved × 107 − penalty_minutes, so a singleZREVRANGEgives the top 50 andZREVRANKgives a user's rank in O(log n). - Ties need a deterministic tiebreak (time of last accepted submission), encoded into the score's lower digits.
- Serve the top pages from a cache refreshed every few seconds; tens of thousands of users refreshing the standings should not each hit Redis.
- Freeze the public board in the final hour (an ICPC tradition) by snapshotting it, while still updating internally.
- After system tests or plagiarism removals, rebuild the leaderboard from the submissions table, which is the source of truth.
The sorted-set approach and its sharding for very large boards are covered in real-time leaderboard system design.
Deep Dive 7: Plagiarism Detection
Copying is common in contests and online assessments, and naive text diffs fail because cheaters rename variables, reorder functions and add comments. The classic approach is MOSS (Measure of Software Similarity, by Aiken et al.), based on winnowing (Schleimer, Wilkerson and Aiken, 2003):
- Normalise. Strip comments and whitespace; replace identifiers with a placeholder and numbers with another, keep keywords and operators.
- k-grams. Hash every run of k consecutive tokens.
- Winnow. Slide a window of w hashes and keep the minimum hash in each window. This picks a small, position-robust set of fingerprints, with the guarantee that any match of at least w + k − 1 tokens shares a fingerprint.
- Compare. Index fingerprints per problem (fingerprint → submissions) and count shared fingerprints between pairs; similarity = shared / min(size).
- Review. Flag pairs above a threshold for human review with matching regions highlighted.
import re
import zlib
KEYWORDS = {"for", "while", "if", "else", "return", "int", "long", "vector", "cin", "cout"}
def normalize(code: str) -> list:
"""Tokenize, drop comments, and replace identifiers/numbers with placeholders."""
code = re.sub(r"//.*|/\*[\s\S]*?\*/", " ", code)
tokens = re.findall(r"[A-Za-z_]\w*|\d+|\S", code)
return [t if t in KEYWORDS or not (t[0].isalpha() or t[0] == "_" or t[0].isdigit())
else ("N" if t[0].isdigit() else "V") for t in tokens]
def fingerprints(tokens: list, k: int = 5, w: int = 4) -> set:
"""Winnowing: hash every k-gram, keep the minimum hash in each window of w hashes."""
grams = [" ".join(tokens[i:i + k]) for i in range(len(tokens) - k + 1)]
hashes = [zlib.crc32(g.encode()) for g in grams]
return {min(hashes[i:i + w]) for i in range(len(hashes) - w + 1)}
def similarity(a: str, b: str) -> float:
fa, fb = fingerprints(normalize(a)), fingerprints(normalize(b))
return len(fa & fb) / max(1, min(len(fa), len(fb)))
original = """
int main() { int n; cin >> n; long sum = 0;
for (int i = 0; i < n; i++) { int x; cin >> x; sum += x; } // add all
cout << sum; return 0; }"""
renamed = """
int main() { int count; cin >> count; long total = 0;
for (int j = 0; j < count; j++) { int val; cin >> val; total += val; }
cout << total; return 0; }"""
different = """
int main() { int n; cin >> n; vector<int> a(n); for (auto &v : a) cin >> v;
sort(a.begin(), a.end()); cout << a[n / 2]; return 0; }"""
print(normalize("int count = 0; // x")) # ['int', 'V', '=', 'N', ';']
print(round(similarity(original, renamed), 2), round(similarity(original, different), 2)) # 1.0 0.11
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.regex.*;
import java.util.zip.CRC32;
public class Winnow {
static final Set<String> KEYWORDS = Set.of("for", "while", "if", "else", "return",
"int", "long", "vector", "cin", "cout");
static final Pattern TOKEN = Pattern.compile("[A-Za-z_]\\w*|\\d+|\\S");
static List<String> normalize(String code) {
code = code.replaceAll("//.*|/\\*[\\s\\S]*?\\*/", " ");
List<String> out = new ArrayList<>();
Matcher m = TOKEN.matcher(code);
while (m.find()) {
String t = m.group();
char c = t.charAt(0);
if (KEYWORDS.contains(t)) out.add(t);
else if (Character.isDigit(c)) out.add("N");
else if (Character.isLetter(c) || c == '_') out.add("V");
else out.add(t);
}
return out;
}
static Set<Long> fingerprints(List<String> tokens, int k, int w) {
List<Long> hashes = new ArrayList<>();
for (int i = 0; i + k <= tokens.size(); i++) {
CRC32 crc = new CRC32();
crc.update(String.join(" ", tokens.subList(i, i + k)).getBytes(StandardCharsets.UTF_8));
hashes.add(crc.getValue());
}
Set<Long> picked = new HashSet<>();
for (int i = 0; i + w <= hashes.size(); i++)
picked.add(Collections.min(hashes.subList(i, i + w)));
return picked;
}
static double similarity(String a, String b) {
Set<Long> fa = fingerprints(normalize(a), 5, 4), fb = fingerprints(normalize(b), 5, 4);
Set<Long> common = new HashSet<>(fa);
common.retainAll(fb);
return (double) common.size() / Math.max(1, Math.min(fa.size(), fb.size()));
}
public static void main(String[] args) {
String a = "int main(){int n;cin>>n;long s=0;for(int i=0;i<n;i++){int x;cin>>x;s+=x;}cout<<s;}";
String b = "int main(){int cnt;cin>>cnt;long tot=0;for(int j=0;j<cnt;j++){int v;cin>>v;tot+=v;}cout<<tot;}";
System.out.println(similarity(a, b)); // 1.0: identical after normalisation
}
}
Dry run
The renamed version differs in every variable name and drops the comment, but after normalisation both programs become the same token stream: int V ( ) { int V ; cin >> V ; long V = N ; for ( int V = N ; …. Every 5-gram matches, so every winnowed fingerprint matches: similarity 1.0. The median-finding program shares only boilerplate like int V ; cin >> V ;, scoring 0.11.
That boilerplate is the false-positive problem in practice: short problems have few sensible solutions, and many people use the same templates (fast I/O macros, common snippets). So exclude fingerprints that appear in a large fraction of submissions or in known public templates, set thresholds per problem, and require human review before penalties. Plagiarism checks run as a batch job after the contest, never on the judging critical path. Comparing all pairs is O(n²), so use the fingerprint inverted index to compare only submissions that share at least one rare fingerprint.
Bottlenecks and Trade-offs
| Decision | Option A | Option B |
|---|---|---|
| Isolation | Containers: fast, lower security margin | gVisor/microVMs: safer, some overhead |
| Sandbox lifecycle | Fresh sandbox per test: clean, slower | Reset warm sandbox: faster, must guarantee full cleanup |
| Contest testing | Full tests live: final verdicts immediately | Pretests then system tests: far less live load |
| Result delivery | Polling: simple, more requests | SSE/WebSocket: instant, connection state to manage |
| Capacity | Always-on peak fleet: simple, expensive | Scheduled pre-scaling + autoscaling: cheaper, needs a contest calendar |
Scheduled rejudges, plagiarism batches and the system-test phase are all deferred work that fits a job scheduler; see distributed job scheduler design.
What Interviewers Look For
Security depth
You list concrete threats (fork bombs, network, reading answers, kernel exploits) and a control for each.
Async design
202 + queue + workers + push, with at-least-once job handling and idempotent judging.
Fairness
CPU time, pinned cores, identical hardware, reruns near the limit.
Contest realism
Pre-scaling, priority queues, pre-cached tests, pretests, leaderboard design.
Common mistakes
- Running code directly on the API server, or in a container with network access.
- Synchronous judging inside the HTTP request.
- Mounting expected outputs inside the sandbox.
- Using wall-clock time on shared, oversubscribed machines and calling it fair.
- Relying on autoscaling alone for a contest that starts at a known minute.
- Text-diff plagiarism detection that renaming variables defeats.
If you are preparing for this question, it helps to have been on the other side: solving timed contests teaches you exactly what users expect from verdicts and leaderboards. Pair this design with steady DSA practice and you will be comfortable with both rounds.
Frequently Asked Questions
Master System Design Interviews
Prepflix's system design course covers execution platforms, queues and isolation with mock interviews, and our DSA track gives you the contest practice to match.
Explore the Prepflix Course →