Design an online judge like LeetCode
A site where people read a coding problem, write a solution in one of many languages, run it on examples, and submit it against hidden tests, alone or in a timed contest. The hard parts are deciding where code runs, keeping hidden tests secret, showing a submission's progress live without hammering the server, keeping drafts safe, and surviving the traffic spike when a contest starts.
The question
Design the web client for an online judge like LeetCode. A user opens a problem, reads the statement, picks a language, and writes a solution in an editor next to it. They can Run their code on the examples or on their own input, and Submit it to be checked against hidden tests. They then see a verdict such as Accepted, Wrong Answer, or Time Limit Exceeded.
The site also runs weekly contests: thousands of people open the same four problems at the same second, race a countdown, and watch a leaderboard. Walk me through the problem page, the editor, where the code actually runs, how a submission's status reaches the screen, the results view, and how contests stay fair and stay up.
Real-life examples: LeetCode, HackerRank, Codeforces, CoderPad, AtCoder
Requirements: scoping an online judge
An online judge looks like a split screen: a problem on the left, an editor on the right, and two buttons, Run and Submit. It is tempting to spend the interview on the editor. Don't. The editor is a part you pick off the shelf, and the playground guide (codepen) already covers editor choice, sandboxed iframes, and capturing a console in the browser.
What makes a judge different from a playground is the word judge. A playground runs your code and shows you what happens. A judge runs your code on inputs you cannot see, compares the output with answers you cannot see, measures time and memory, and hands out a verdict that counts: for a streak, a contest rank, or a job interview. Every scope question should tell you something about that verdict: who computes it, how long it takes, how it reaches the screen, and how fair it is.
So ask first: which languages, where does code run, how many submissions arrive at peak, and are there contests. Each answer moves a big part of the design.
| Question | Answer we assume | What it changes |
|---|---|---|
| Which languages? | About 20, led by Python, C++, Java, and JavaScript. Every problem has starter code for each. | Code must run on servers for most languages; the editor needs per-language templates and highlighting loaded on demand. |
| Who sees the tests? | Examples are public. The real test set (often 50 to 200 cases) is hidden. | Hidden tests can never be sent to the browser, so the verdict must come from a server. |
| How fast must Run feel? | Run on examples within 2 to 3 seconds, Submit within about 10 seconds on a normal day. | A status channel that shows progress, and maybe an in-browser fast path for Run. |
| How many submissions? | About 2 million a day, around 25 a second on average, with peaks of 300 to 500 a second at contest start and end. | Queues and backpressure on the server; on the client, a status channel that does not multiply that load. |
| Are there contests? | Weekly, 90 minutes, 4 problems, 20,000 to 30,000 people starting at the same second. | A server-synced countdown, static problem pages released at a set time, a throttled leaderboard. |
| Is there premium content? | Some problems and all editorial solutions are for paying users. | Free statements are server-rendered for search; premium ones are rendered only after an auth check. |
| Is it used for hiring? | Yes: a separate interview pad with a timer and a recruiter view. | Anti-cheating signals, and privacy rules about what we record. |
| Mobile? | Reading problems works on phones; writing code is a desktop task. | Responsive statement page; the split layout is desktop-first. |
Functional requirements
- Browse and filter problems by difficulty, tag, and status (solved, attempted, new).
- Read a problem: statement in markdown with math and images, examples, constraints, tags, difficulty, and hints.
- Write code in an editor with syntax highlighting, a language picker, starter code per language, and optional vim or emacs keys.
- Keep a draft per problem per language, so that a closed tab or a language switch never loses work.
- Run code on the examples or on custom input and see the output, errors, and timing.
- Submit code against hidden tests and watch its status move from queued to a final verdict, with runtime and memory.
- See the submission history for a problem and open any past submission.
- Join a contest: a countdown to start, problems released at start, a live leaderboard, penalty time, and a frozen board near the end.
Non-functional requirements
- Security: hidden tests and their expected outputs never reach the browser, not even in a bundle or an error message.
- Fairness: every submission for a problem is timed on the same kind of machine; contest start and end use server time, not the user's clock.
- Speed: a problem page shows its statement (the Largest Contentful Paint, LCP) in under 2.5 seconds on a mid-range laptop; the editor is usable within 1 second after that.
- Status latency: a verdict appears on screen within 1 second of the judge finishing.
- Load: the client must not turn a 30,000 person contest start into a flood of retries; polling and reconnects back off with random jitter.
- Durability of drafts: losing a 40-minute solution to a crash or a closed tab is the worst failure a user can feel.
- Accessibility: the editor, Run and Submit, and verdicts work with a keyboard and a screen reader.
Out of scope
The judge servers themselves (how containers are built, scheduled, and scaled) are backend work; we cover only what the client must know about them. Discussion forums, editorial videos, payments, and the recruiter dashboard are separate products. Real-time pair programming in the interview pad reuses a collaboration layer from the google-docs guide.
Check your understanding · Exercise 1
Which answers to scope questions change the client design the most? Select all that apply.
What makes a judge hard
Before picking any tool, name the traits of the problem. Each one drives a later chapter, and naming them early shows the interviewer you see the whole shape.
1. The answer key is secret
A problem like Two Sum shows 3 examples, but the real check runs maybe 60 hidden cases: empty edges, the largest input, negative numbers, duplicates. If those cases ever reach the browser, anyone can read them in the network tab and write code that prints the expected answers. So the browser can help with examples, but the verdict that counts must be computed where the hidden tests live: on a judge server. This one fact splits the product into two paths, Run and Submit.
2. The result is slow and comes in steps
A submission is not a normal request. It waits in a queue, compiles (a C++ build can take 1 to 3 seconds), then runs test after test, each with a time limit of 1 to 2 seconds. The whole thing takes from half a second to 20 seconds.
A plain fetch that waits for the answer would hit proxy timeouts and give the user nothing to look at. The client needs a submission id right away and a way to learn the status as it changes.
3. Many languages, one editor
Twenty languages means twenty sets of highlighting rules, twenty starter templates for every problem, and different ideas of what a "function signature" is (a Python class Solution method, a Java class, a C function taking a pointer and a length). The editor has to switch between them without losing the user's work in any of them.
4. The user's work is precious
People spend 20 to 60 minutes on a hard problem. Losing that code to a refresh, a crash, or a language switch is the fastest way to lose a user. Drafts are a first-class feature, not a nice extra.
5. Fairness and timing matter
A verdict like Time Limit Exceeded or a runtime of "52 ms, beats 81%" is only meaningful if every submission was measured the same way. A user's laptop is never the same way twice: a browser tab in the background is throttled, a phone is 5 times slower than a desktop. In a contest the same is true for the clock: a laptop clock that is 40 seconds fast must not start the contest 40 seconds early for that user.
6. Traffic is spiky by design
A contest is a scheduled thundering herd: 25,000 people reload the page at 08:00:00, and many submit in the first 5 minutes and the last 5 minutes. A design that is fine at the daily average of 25 submissions a second must hold at 20 times that, and the client must help instead of making it worse with eager polling and instant retries.
| Trait | What it forces | Chapter |
|---|---|---|
| Secret answer key | Server verdicts; Run and Submit as two paths | Where code runs, Run vs Submit |
| Slow, stepped result | Submission id, status channel, state machine | Submission pipeline |
| Many languages | Lazy language packs, per-language templates and drafts | The editor |
| Precious work | Local and server drafts, idempotent submit | The editor, Submission pipeline |
| Fairness | Server-side timing, server time offset | Where code runs, Contests |
| Spiky traffic | CDN-released pages, backoff with jitter, throttled leaderboard | Contests |
Think first
Modern browsers can run Python and C++ through WebAssembly. Why not judge everything in the browser and save the server cost?
Because the browser is under the user's control. To judge there, you would have to send the hidden inputs and expected outputs to the client, and then anyone can read them. Even if you sent only hashes of expected outputs, the user could change the JavaScript that reports "Accepted".
Timing is also unfair: the same code runs at different speeds on a fast desktop and an old phone, so time limits would mean different things for different people. The browser can still be useful for instant Run on public examples, which we design in a later chapter.
The problem page: statement, layout, and SEO
The problem page is where users spend most of their time, so start by drawing it: on a desktop, a split screen. The left pane holds the statement and tabs for the editorial, the solutions, and the user's past submissions. The right pane holds the editor on top and a console below it with two tabs: test cases (examples and custom input) and results. A draggable divider sits between left and right, and another between editor and console.
What a statement is made of
Problem authors write statements in markdown, because it is easy to review and diff. A statement mixes plain prose, inline code like nums[i], math like the constraint 1 ≤ n ≤ 10^5 written in LaTeX as 1 \le n \le 10^5, images (a binary tree drawing, a grid), example blocks with Input, Output, and Explanation, a constraints list, and a follow-up line such as "Can you do it in O(n) time?". Around it sit metadata: title, number, difficulty, tags (Array, Hash Table), acceptance rate, and the like count.
Math is the awkward part. Browsers have native MathML support now (Chrome added MathML Core in version 109, after Firefox and Safari), but authors write LaTeX, not MathML. The two common libraries are KaTeX, which renders LaTeX to HTML and CSS quickly and synchronously, and MathJax, which supports more of LaTeX but is heavier.
Judges mostly need simple math: subscripts, powers, inequalities, sums. KaTeX covers that well, and its renderToString runs on a server and returns a plain HTML string. The browser then needs only KaTeX's CSS and fonts, not its JavaScript.
Decision Where does markdown with math become HTML?
What we pick: Render at publish time. Statements change rarely and are read millions of times, so doing the work once is the clear win. Free problem pages are then statically generated with incremental revalidation and served from a CDN; the browser loads only KaTeX's CSS and fonts, not its JavaScript.
private, no-store so no shared cache keeps them.SEO for free problems, gating for premium ones
Free problem statements are a big source of search traffic: people search for "two sum leetcode". Those pages should be server-rendered HTML with a real title, a description meta tag, and the full statement in the markup. (See the ssr lesson for the trade-offs, and cdn-caching for how a shared cache stores them.)
Premium problems need the opposite: the statement must not be in the HTML for anyone who has not paid. A common mistake is to render it and hide it with CSS or a blurred overlay; the text is still in the page source. The safe design renders a public shell (title, difficulty, tags, a short teaser, and an upgrade prompt) that is cacheable on the CDN for everyone. The statement itself comes from an API that checks the user's subscription and answers with Cache-Control: private, no-store, so no shared cache ever stores it.
The resizable split layout
The divider is a small component with real accessibility needs. Use pointer events with setPointerCapture so a drag keeps working when the pointer leaves the thin divider. Give it role="separator" with aria-orientation, aria-valuenow (the left pane's share in percent), and tabindex="0", so keyboard users can focus it and move it with the arrow keys. Clamp the sizes (for example, neither pane below 25%), save the ratio in localStorage, and on narrow screens drop the split entirely: show the statement and editor as tabs.
Load order matters too. The statement is the LCP element, so it comes in the HTML. The editor is a large bundle (Monaco is several megabytes before compression), so load it after first paint with a dynamic import() and show a static pre block with the starter code until it is ready. A user who only reads the problem never pays for the editor.
Check your understanding · Exercise 2
The editor: languages, starter code, and drafts
The codepen guide already compares CodeMirror 6 and Monaco in depth: bundle weight, phones and touch, accessibility, and why a plain textarea is not an option. Read it there; this guide does not repeat it. A judge changes the inputs to that decision: one editor panel instead of three, about 20 languages instead of three, a desktop audience that mostly codes in VS Code, and work that must survive anything.
Those differences tilt the choice, so state them in the interview and pick. The rest of this chapter covers what is specific to a judge whichever editor you choose: starter code per language, drafts, keybinding modes, and how smart completion can be.
Decision Which editor library for a judge with 20 languages?
What we pick: Either works; say why you pick one. For a desktop-first judge whose users live in VS Code, Monaco is a fair pick (LeetCode's editor appears to be built on it). If mobile editing or bundle size matters more, pick CodeMirror 6, as the codepen guide does for its embeds. Either way, load the editor lazily after the statement paints, and load language support on demand when the user picks a language.
Starter code and function signatures
LeetCode-style judges ask for a function, not a full program. The user writes twoSum(nums, target), and hidden driver code on the server reads the test input, calls the function, and prints the result. That keeps users away from input parsing, which is different in every language.
To avoid writing 20 templates by hand for every problem, store the signature once as data: a function name, typed parameters, and a return type. A generator then produces the starter code and the driver for each language. The client receives only the starter code; the driver stays on the server, because it is part of how tests are fed and checked.
Codeforces-style judges are simpler: the user writes a whole program that reads standard input, so the template is just an empty main.
type Param = { name: string; type: "int" | "int[]" | "string" | "ListNode" };
type Signature = {
fn: string; // "twoSum"
params: Param[]; // [{ name: "nums", type: "int[]" }, { name: "target", type: "int" }]
returns: Param["type"]; // "int[]"
};
// Generated per language at publish time:
const starter = {
python3: "class Solution:\n def twoSum(self, nums: List[int], target: int) -> List[int]:\n ",
cpp: "class Solution {\npublic:\n vector<int> twoSum(vector<int>& nums, int target) {\n \n }\n};",
javascript: "/**\n * @param {number[]} nums\n * @param {number} target\n * @return {number[]}\n */\nvar twoSum = function(nums, target) {\n \n};",
};
starter reaches the client.Drafts per problem, per language
A draft is keyed by user, problem, and language: (user, twoSum, python3) and (user, twoSum, cpp) are two separate drafts. Switching language saves the current draft and loads the other one, or the starter code if there is none. That way trying C++ never wipes your Python. A Reset button restores the starter code only after a confirm, and an undo toast for a few seconds is even better.
Saving happens in two layers, like the multi-step-form guide's drafts but with a bigger payload. First, every change is written to IndexedDB about 500 ms after the user stops typing (see the indexeddb lesson). It is local, fast, and survives a crash or a refresh.
Second, the draft is sent to the server every 10 to 30 seconds while it is changing, and also on visibilitychange to hidden, so it follows the user to another device. The server copy carries a version. If the server's version is newer than the one this tab started from, another device wrote it, and the client asks which copy to keep instead of overwriting silently.
409 becomes a question, not data loss.Keybinding modes and shortcuts
Many competitive programmers want vim or emacs keys. Both editors have community packages for this (monaco-vim, monaco-emacs, and a vim mode for CodeMirror 6), so load them only when the user turns the mode on. The catch is conflicts: in vim mode, Escape leaves insert mode, so it cannot also mean "leave the editor". Pick global shortcuts that no mode uses, such as Ctrl+' (or Cmd+' on a Mac) to Run and Ctrl+Enter to Submit, and show them on the buttons.
How smart can autocomplete be?
Real completion (knowing that nums. has push and length) needs a language server that understands the language. Monaco has one built in for JavaScript and TypeScript. For C++, Java, or Python you would run a language server per user session on a backend and talk to it over a WebSocket. That is a container per active user, which is expensive at 20,000 users in a contest.
Most judges settle for keyword and word-based completion, sometimes plus a curated list of standard library names. Interview pads often let the interviewer turn completion off altogether, so it should be a setting, not hard-wired.
Check your understanding · Exercise 3
A user writes Python for 30 minutes, switches to C++, then closes the laptop lid. Which design choices protect their work? Select all that apply.
Where the code runs: server sandboxes vs the browser
This is the decision interviewers care most about, and the one the frontend has to explain even though most of it lives on servers. The question is not "can we run Python in a browser" (we can) but "whose machine do we trust for which answer".
Server sandboxes: how strangers' code is contained
A judge server runs code that anyone on the internet wrote, including code that tries to read files, open network connections, fork forever, or allocate 50 GB. So every run happens inside a sandbox: an environment that limits what the code can see and use. There are layers of strength:
- Process limits and containers. Linux namespaces give the process its own view of files, processes, and network; cgroups cap its CPU, memory, and number of processes; seccomp filters block dangerous system calls. Docker containers are built from these. They are fast to start but share the host's kernel, so a kernel bug can be an escape route. Inside the sandbox the judge sets a CPU-time limit and a wall-clock limit, and reads peak memory from the cgroup: that is where a verdict's runtime and memory numbers come from.
- A user-space kernel (gVisor). gVisor intercepts the program's system calls and handles them in its own kernel written in Go, so the program barely touches the host kernel. Stronger isolation, with some overhead on calls like file and network access.
- MicroVMs (Firecracker). Each run gets a tiny virtual machine with its own kernel. Firecracker, built by AWS for Lambda and Fargate, boots a microVM in about 125 ms. Strongest isolation, highest cost per run.
Judge0 is a well-known open-source judge you can read to see this in practice. It exposes an HTTP API (create a submission and get a token, then fetch its status by token), runs more than 60 languages, and runs each submission inside isolate, the sandbox built for the International Olympiad in Informatics, which uses namespaces and cgroups. LeetCode and HackerRank do not publish their judge internals, so in an interview say "a sandbox along these lines" rather than claiming what they use.
Runtimes in the browser
The browser can run some languages well. JavaScript runs natively; put it in a Web Worker so an infinite loop does not freeze the page, and call worker.terminate() after a time limit.
Python runs through Pyodide, which is CPython compiled to WebAssembly, also inside a worker; the first load downloads several megabytes and takes a few seconds before the first line runs, then runs are fast. C and C++ can be compiled in the browser by Clang builds compiled to WebAssembly, but the toolchain is tens of megabytes, which is a lot to ask before someone's first Run.
function runInWorker(code: string, input: unknown, limitMs = 2000) {
return new Promise<RunResult>((resolve) => {
const worker = new Worker(new URL("./js-runner.worker.ts", import.meta.url), { type: "module" });
const timer = setTimeout(() => {
worker.terminate(); // the only way to stop a busy loop
resolve({ status: "time_limit", stdout: "" });
}, limitMs);
worker.onmessage = (e: MessageEvent<RunResult>) => {
clearTimeout(timer);
worker.terminate();
resolve(e.data);
};
worker.postMessage({ code, input });
});
}
Decision Where does Run execute, and where does Submit execute?
What we pick: Submit always runs on the server; that is not negotiable. For Run, start with the server too, because one runtime per language avoids "it passed on Run but failed on Submit" support tickets. Add a browser fast path for JavaScript as an optimization, clearly labelled, and keep a server Run as the fallback. Mention Pyodide as a next step for an interview pad where server cost per keystroke-heavy session matters.
Check your understanding · Exercise 4
Which statements about running code are true? Select all that apply.
Run vs Submit: two buttons, two contracts
Run and Submit look like twins, but they make different promises. Run says "show me what my code does on this input". Submit says "judge my code, record the result, and count it". Writing that difference down as a table is a quick way to show the interviewer you know where the lines are.
| Aspect | Run | Submit |
|---|---|---|
| Inputs | The visible examples, plus custom input the user types | The hidden test set, often 50 to 200 cases |
| What the user sees | Output for every case, with expected output when known | A verdict; details of the first failing case only if it is not secret |
| Stops early? | No: runs every case the user gave | Usually yes: stops at the first failing test to save capacity |
| Recorded? | No, or kept briefly for the UI | Yes: stored forever, shown in history, counts for streaks and contests |
| Where it may run | Server, or the browser for some languages | Server only |
| Typical limit | A few per minute, one at a time | One in flight per user per problem, a burst limit per minute |
Custom input and its expected output
Custom input is how users test edge cases: an empty array, one element, the largest value. A free-form text box invites format errors, so give one field per parameter, named after the signature (nums, target), and validate the obvious things in the browser: is it valid JSON for an array, is it within the stated constraints. The server validates again, because the browser check is only for quick feedback.
A user's own input has no stored answer, so what does "Expected" show? Some judges, LeetCode among them, run a reference solution on the same input and show its output as Expected. That is a neat trick: the user gets a free oracle for any edge case, and the reference solution never leaves the server.
Snapshot the code at click time
The user may keep typing while a run is in flight. Send a snapshot of the code and language at the moment of the click, and keep that snapshot with the result. The results panel can then say "results for the code from 10:42:07" and offer a diff against the current editor, instead of pretending the result belongs to whatever is on screen now.
Rate limiting, on both sides
Every Run and Submit costs a sandbox for up to several seconds, so both are rate limited. The server is the real enforcer: it answers 429 Too Many Requests with a Retry-After header when a user is over the limit, and it refuses a second in-flight submission for the same problem. The client's job is to make the limit feel fair, not to enforce it:
- Disable Submit while a submission for this problem is in flight, and say why ("Judging your last submission").
- On a 429, read
Retry-Afterand show a short countdown on the button instead of a generic error. - Never auto-retry a 429 immediately. Retrying sooner than the server asked only adds load and extends the wait.
- Keep Run and Submit limits separate, so a user testing many edge cases does not lose the ability to submit.
Check your understanding · Exercise 5
Which behaviours belong to Submit but not to Run? Select all that apply.
The submission pipeline: ids, idempotency, and states
Start with the naive version and watch it break. The client sends POST /submit with the code and waits for the response, which is the verdict. On a quiet day that takes 4 seconds and works, but at contest start the queue is 90 seconds deep.
The load balancer closes idle requests after 60 seconds, the browser shows a network error, and the user, who has no idea whether the submission counted, clicks Submit again. Now they have two submissions, maybe a penalty for each, and the queue is longer for everyone.
Three fixes fall out of that story. Split "create the submission" from "learn its result". Make the create call safe to repeat. Model the result as a series of states the client can show.
Create, then track
The POST does only what is fast: validate, store the code, put a job on the queue, and answer 202 Accepted with a submission id and the current status, usually within 100 to 200 ms. Everything slow happens after, and the client learns about it through a status channel (the next chapter). If the tab reloads, the id is enough to pick up tracking again, so keep the in-flight id in sessionStorage or in the URL of the results panel.
POST /api/submissions
Idempotency-Key: 6f1c9e2a-0b7d-4f3e-9a51-2c8d7e4b1f60
Content-Type: application/json
{ "problemId": "two-sum", "language": "python3", "code": "class Solution: ...", "contestId": null }
HTTP/1.1 202 Accepted
Location: /api/submissions/sub_81723
Content-Type: application/json
{ "id": "sub_81723", "status": "queued", "queuePosition": 14, "seq": 1 }
Idempotent submit
An operation is idempotent when doing it twice has the same effect as doing it once. POST is not idempotent by default, so we make it so with an Idempotency-Key header, the same pattern the travel-booking and uber guides use for paying and requesting a ride.
The client creates a random key (crypto.randomUUID()) when the user means to submit. The server stores the key with the submission id for a day. If the same key arrives again, the server does not create a new submission; it returns the one it already made.
The subtle part is when to create a new key. The rule: one key per intent, not per HTTP attempt. The client keeps a small pending record of the key and a hash of the code it is sending.
- A second click while the first request is still in flight reuses the same key, so even if both requests leave the browser, the server makes one submission.
- A retry after a timeout or a dropped connection reuses the key, so the user is never charged twice for one click.
- After a verdict arrives, or if the code changed, the next click makes a new key. Submitting the same code again on purpose is allowed; it just has to be a new intent.
k1 makes the answer not matter: the server recognises the key and returns the existing sub_81, so there is one submission and one penalty at most.The states of a submission
A submission moves through a few working states and ends in exactly one final state, the verdict. The verdict names are the ones users know from LeetCode and Codeforces: Accepted, Wrong Answer, Time Limit Exceeded, Memory Limit Exceeded, Runtime Error, and Compile Error (Codeforces says Compilation Error). Naming every state lets the UI show something meaningful at every moment:
| State | What the user sees | Notes |
|---|---|---|
queued | "In queue, position 14" | Position comes from the server and can jump; show it, don't animate a fake progress bar. |
compiling | "Compiling" | Skipped for interpreted languages. |
running | "Running test 7 of 120" | A real progress bar is fine here, because the total is known. |
accepted | Green verdict, runtime and memory | Final. |
wrong_answer | Red verdict, "38 / 120 testcases passed", the failing case if visible | Final. |
time_limit_exceeded, memory_limit_exceeded | The limit that was hit, and on which test | Final. |
runtime_error | The error type and message, such as an index out of range | Final. The message is scrubbed of server paths. |
compile_error | The compiler output, mapped to editor lines | Final. Mark the lines in the editor. |
internal_error | "Something went wrong on our side; this did not count" | Final for this attempt; the judge may retry the job on its own first. Never charge a penalty for it. |
Two rules keep the client honest. First, every status event carries a seq number that only grows; the client ignores any event with a seq lower than the last one it applied. That matters when a poll response and a stream event race each other. Second, final states are sticky: once the client shows accepted, no late running event can move it back.
Check your understanding · Exercise 6
The user clicks Submit. The POST reaches the server, but the response is lost when the Wi-Fi drops. What should happen? Select all that apply.
Getting the status to the screen: polling, SSE, or WebSocket
The client has a submission id. Now it needs to learn each state change within about a second, for one submission that lives 1 to 60 seconds. Four options are worth weighing; the retries-and-backoff, server-sent-events, and websockets lessons cover each in general, so here we focus on the numbers for a judge.
Do the math at contest peak
Take a contest peak of 500 submissions a second, with each one spending about 30 seconds from POST to verdict while the queue is deep. That is 500 × 30 = 15,000 submissions being watched at any moment. Now compare what each channel costs:
| Channel | Requests or messages per second | Open connections | Status delay |
|---|---|---|---|
| Poll every 500 ms | 15,000 ÷ 0.5 = 30,000 requests/s | Short-lived | Up to 500 ms |
| Poll with backoff (0.5, 1, 2, 4 s, cap 4 s) | Once the delay reaches its cap, jitter makes the average wait 3 s: 15,000 ÷ 3 ≈ 5,000 requests/s | Short-lived | Up to 4 s when the job is slow |
| SSE stream per submission | About 500 × 5 events = 2,500 messages/s, all useful | 15,000 open streams | Near zero |
| WebSocket per tab | Same messages as SSE, plus pings | One per tab, 25,000 sockets | Near zero |
Fixed fast polling is the trap: most of those 30,000 requests a second return "still queued", and they arrive exactly when the servers are busiest. Backoff cuts it by about 6 times (a little less in practice, because the first few polls come faster), at the cost of a slower verdict. A push channel sends only real changes.
Decision How does the client learn a submission's status?
What we pick: Use SSE for submission status, with polling with backoff and jitter as the fallback when the stream fails twice in a row. It fits the one-way shape, reconnects on its own, and costs messages only when something changes. Use a WebSocket only if the page already has one for another reason (an interview pad with live collaboration); then reuse it rather than opening a second channel.
function watch(id: string, apply: (e: StatusEvent) => void) {
let failures = 0;
const es = new EventSource(`/api/submissions/${id}/events`, { withCredentials: true });
es.onmessage = (m) => { failures = 0; const e = JSON.parse(m.data); apply(e); if (isFinal(e.status)) es.close(); };
es.onerror = () => { if (++failures >= 2) { es.close(); poll(id, apply); } };
}
async function poll(id: string, apply: (e: StatusEvent) => void, delay = 500) {
const res = await fetch(`/api/submissions/${id}`, { cache: "no-store" });
const retryAfter = Number(res.headers.get("Retry-After")) * 1000;
if (res.ok) {
const e: StatusEvent = await res.json();
apply(e);
if (isFinal(e.status)) return;
}
const next = Math.min(delay * 2, 4000);
const wait = Math.max(retryAfter || 0, next / 2 + Math.random() * (next / 2)); // equal jitter
setTimeout(() => poll(id, apply, next), wait);
}
apply ignores stale seq values. The poll delay uses equal jitter (half fixed, half random), so it never drops below half a step; the retries-and-backoff lesson explains why full jitter is the usual default for retrying errors.The lab below runs the same 12-test submission through three channels. Try polling at a fixed rate, then with backoff, then the stream, and compare the request count and how late the verdict shows. Then double-click Submit with and without an idempotency key to see why the key matters.
Check your understanding · Exercise 7
At contest peak, status polling is overloading the API. Which changes help? Select all that apply.
The results view: verdicts, failing cases, and diffs
The verdict is the moment the user has been waiting for. A bare "Wrong Answer" is useless; a good results view answers the next question at once: on what input, what did you expect, what did I print, and where do they differ.
Showing the failing case without leaking tests
For Run, everything is visible: the user wrote or saw every input, so show input, expected output, actual output, and the code's own printed output (stdout) for each case, in tabs labelled Case 1, Case 2, and so on.
For Submit, the server decides what may be shown, and the client shows exactly that. If the failing test is one of the examples, show it in full. If it is hidden, the policy is a product choice: many judges reveal the one failing input in practice mode, because it helps people learn, and reveal nothing during contests, because one revealed case per submission adds up to leaking the test set.
Either way, the rule for the client is simple: the API sends a failingCase object only when it is allowed, and the client never receives the rest of the hidden set. A client-side flag that hides data already sent is not a policy.
Outputs can be huge: a test with 100,000 numbers prints 600 KB. Truncate on the server to, say, the first 2 KB of each field, with a flag saying it was cut, and render long values in a scrollable pre rather than growing the page.
Diffing expected and actual output
The code-review-diff guide covers line diff algorithms for code. Judge outputs are different: usually one or a few lines, compared the way the judge compares them. Many judges compare tokens (pieces separated by whitespace), ignoring trailing spaces and the final newline.
So the useful diff is "the first token that differs": 0 1 vs 0 0 differs at token 1, counting from 0. For a LeetCode-style array answer like [0,1], compare element by element the same way. Highlight that token in both outputs and say "first difference at position 1". If the only difference is whitespace that the checker ignores, that cannot be the reason for a Wrong Answer, so don't highlight it.
Some problems accept any of several answers (any valid ordering) or answers within a tolerance (like 10^-5 for floating point). These use a custom checker program on the server instead of plain comparison. The results view should say so ("any valid answer is accepted"), otherwise users will stare at a diff between two correct answers.
Compile and runtime errors on the right line
The judge compiles the user's code together with the hidden driver code, so the compiler's line numbers count driver lines too. The server must translate them back to the user's lines before sending them, and must strip server paths like /box/solution.cpp from messages. The client then turns each error into an editor marker (Monaco's setModelMarkers or a CodeMirror lint diagnostic) so the red underline sits on the user's own line 12.
Runtime and memory: what "beats 81%" means
After an Accepted verdict, judges like LeetCode show runtime and memory with a percentile and a histogram: "Runtime 52 ms, beats 81.3%". Its exact method is not published, so describe the general approach.
The server keeps, per problem and language, a distribution of runtimes from accepted submissions, bucketed (for example in 2 ms buckets). The percentile is the share of accepted submissions that were slower. The client receives the buckets, not raw submissions, and draws a bar chart with the user's bucket highlighted.
Be honest in the UI about noise. The same code can run 48 ms on one try and 60 ms on the next because of the machine's load. Show the number, but avoid language like "your code is faster than 81% of people". A small note such as "runtimes vary between runs" saves a lot of forum posts.
Think first
A screen reader user submits and keeps typing. When the verdict arrives, how should they learn about it without losing their place?
Announce it through a polite live region: a visually hidden element with role="status" (which is polite by default) whose text changes to "Wrong Answer, 38 of 120 tests passed". The screen reader speaks it at the next pause and does not move focus out of the editor.
Do not announce every intermediate state ("running test 7", "running test 8"); that floods the user. Announce queued once, then only the final verdict. Moving focus to the results panel would be worse, because it pulls the user out of their code.
Check your understanding · Exercise 8
Which results-view choices are correct? Select all that apply.
Contests: a fair clock, a clean release, and the spike
A contest adds a hard deadline at both ends. At 08:00:00 server time the problems appear for everyone; at 09:30:00 submissions stop. Two client problems hide in those sentences: whose clock says it is 08:00, and how do 25,000 browsers get four problem statements in the same second.
A third problem follows right after: thousands of submissions in the first and last minutes, scored fairly and shown on a live leaderboard. The timeline below places each of these on the clock; the rest of the chapter takes them in order.
The naive countdown and how it fails
The naive countdown computes startAt - Date.now() and decrements once a second. It breaks in three ways.
- Skewed clocks.
Date.now()is the device's clock, which can be off by seconds or minutes (a laptop that has not synced, a phone with the time set by hand). A clock 40 seconds fast shows "Start" 40 seconds early; the user clicks, gets an error, and hammers reload. - Drift from counting. Decrementing a counter each
setIntervaltick assumes ticks arrive on time. They don't: a busy main thread delays them, and browsers throttle timers in background tabs to once a second or less (Chrome throttles hidden tabs much harder after a few minutes). After 10 minutes in the background the counter can be far behind. - Clock changes. If the operating system corrects its clock mid-contest,
Date.now()jumps, and so does the countdown.
Fix: a server time offset and a monotonic clock
Ask the server what time it is and measure how long the question took. Record t0 = performance.now(), call GET /api/time, which returns the server's time in milliseconds, and record t1 when the response lands. The round trip (RTT) is t1 - t0.
Assume the server read its clock halfway through, so at local moment t1 the server's time is about serverTime + (t1 - t0) / 2. If the trip there and the trip back took different times, that guess is off, but never by more than half the round trip. So take 3 to 5 samples and keep the one with the shortest round trip, because its error bound is the smallest.
This is NTP, the protocol computers use to sync clocks, in miniature. Full NTP has the server report two times, when the request arrived (b) and when the reply left (c), so with the client's send time a and receive time d the offset (server clock minus client clock) is ((b - a) + (c - d)) / 2. That removes the server's own processing time from the estimate. For a countdown that only needs to be right to a fraction of a second, one server timestamp is enough.
Then never use Date.now() again for the contest: performance.now() is monotonic, which means it only moves forward at a steady rate and ignores changes to the system clock. Server time now is the synced server time plus the performance.now() that elapsed since the sync. Each tick recomputes the remaining time from that, so a late tick shows the right number instead of drifting. Some browsers pause performance.now() while the computer sleeps, which is one more reason to re-sync when the tab becomes visible again.
async function syncServerClock(samples = 5) {
let best = { rtt: Infinity, server: 0, at: 0 };
for (let i = 0; i < samples; i++) {
const t0 = performance.now();
const { now } = await (await fetch("/api/time", { cache: "no-store" })).json();
const t1 = performance.now();
if (t1 - t0 < best.rtt) best = { rtt: t1 - t0, server: now + (t1 - t0) / 2, at: t1 };
}
return {
now: () => best.server + (performance.now() - best.at),
errorMs: best.rtt / 2,
};
}
// Each tick, recompute; never decrement.
const remaining = (startAt: number, clock: { now(): number }) => Math.max(0, startAt - clock.now());
visibilitychange.performance.now() only moves forward, so a clock change or a late tick cannot shift the countdown.Try it below. Skew the device clock, then drag server time toward the start and compare two countdowns: one built on Date.now(), one built on the server offset. Raise the round trip and switch between one sample and the best of 5 to see why the shortest round trip wins. The lab assumes the request leg takes 70% of each round trip, so the midpoint guess is off by 20% of it; a single sample is modelled as a slow first request that also paid for connection setup.
Releasing problems at a set time
At 08:00:00, 25,000 clients ask for the problems within a second or two. If each request renders a page on the origin and checks auth, the origin gets a spike of tens of thousands of requests at once, exactly when it is also accepting the first submissions.
Decision How do the problem statements reach 25,000 browsers at start?
What we pick: Pre-built pages on a CDN with a manifest that unlocks at start. It turns the spike into cache hits and is easy to explain. Add 0 to 2 seconds of random jitter to the client's first manifest request, and have the manifest return the server time and "not yet" with Retry-After if a client asks early. Mention the encrypted variant as a refinement if the interviewer pushes on the start moment.
Check your understanding · Exercise 9
A user's laptop clock is 40 seconds fast and the contest tab sat in the background for 15 minutes. Which design gives them a correct countdown? Select all that apply.
Backpressure: when the queue is deep
In the first and last minutes of a contest, submissions arrive faster than the judges can run them. The queue grows, and the server must protect itself. Backpressure means the busy part tells the parts sending it work to slow down, instead of silently falling over. The client's role in that conversation:
- Show the queue position the server reports, honestly. "Position 2,140, about 40 seconds" is calmer than a spinner that never moves.
- If the POST itself gets
503 Service UnavailablewithRetry-After, keep the code and the idempotency key, show "High load, retrying in 5 s", and retry once that time has passed. The user should not have to click again. - Never retry instantly or in lockstep. Backoff with random jitter keeps 25,000 clients from retrying in the same millisecond.
One fairness rule matters more than any of the above: the contest time of a submission is when the server accepted the POST, not when judging finished. A user who submitted at 08:12:30 and waited 90 seconds in the queue is scored at 08:12:30. Say this out loud in an interview; it is what makes a deep queue merely annoying instead of unfair.
Graceful degradation
Decide in advance what turns off under load, and build the client so each part can fail alone. During a contest: runtime percentiles can wait until after the contest; custom-input Run can get a lower rate limit; discussion and editorial tabs are hidden anyway.
If the leaderboard API is slow, the leaderboard shows its last snapshot with "updated 2 minutes ago" instead of breaking the page. If the status stream fails, polling takes over. The problem statement and the editor must keep working even if everything else is down, which is another reason drafts live in IndexedDB first.
Scoring and penalty time
Rules vary by platform. The classic ICPC model ranks by problems solved, then by penalty time: the sum over solved problems of the minutes from contest start to the accepted submission, plus 20 minutes for each rejected submission before it. Codeforces's ICPC-style Educational rounds use the same sum with 10 minutes per rejection, while its regular rounds use points that shrink over time, minus 50 points per rejected attempt.
LeetCode's weekly contests, the model for ours, are a close cousin: 5 minutes per wrong submission, added to the time of your last accepted problem rather than to a sum. In all of these, wrong attempts on a problem you never solve cost nothing, and compile errors do not count. The client shows the rules, the user's running penalty, and, before a resubmit, a gentle reminder like "a wrong answer adds 5 minutes".
type Rule = { perWrong: number; total: "sum" | "last" };
const ICPC: Rule = { perWrong: 20, total: "sum" }; // sum of solve times
const WEEKLY: Rule = { perWrong: 5, total: "last" }; // LeetCode-style: last solve time
function penaltyMinutes(attemptsByProblem: Attempt[][], startAt: number, rule: Rule) {
let sum = 0, last = 0, wrongs = 0;
for (const attempts of attemptsByProblem) {
let wrong = 0;
for (const a of [...attempts].sort((x, y) => x.submittedAt - y.submittedAt)) {
if (a.verdict === "accepted") {
const minute = Math.floor((a.submittedAt - startAt) / 60000);
sum += minute;
last = Math.max(last, minute);
wrongs += wrong; // only counted because the problem was solved
break;
}
if (a.verdict !== "compile_error" && a.verdict !== "internal_error") wrong++;
}
}
return (rule.total === "sum" ? sum : last) + wrongs * rule.perWrong;
}
Freezing the scoreboard
Many contests freeze the public scoreboard near the end (ICPC freezes it for the last hour). After the freeze, other people see that a team attempted a problem but not the verdict, so the final ranking stays a surprise until the results are revealed. Each user still sees their own verdicts. For the client this means the leaderboard API returns cells with a pending state for attempts after the freeze, the UI shows a "Scoreboard frozen at 09:00" banner, and the user's own row merges their private verdicts on top of the frozen public row.
A live leaderboard that does not melt
The leaderboard has 25,000 rows and changes many times a second. Pushing every change to every viewer is 25,000 viewers times hundreds of changes a second, which is far more than anyone can read. The live-comments and analytics-dashboard guides cover throttled live updates in general; for a leaderboard the design is:
- The server computes a ranked snapshot every 10 to 30 seconds, not on every submission, and gives it a version number.
- The client shows one page at a time (the top 25, or the page around the user's own rank) and fetches it with
If-None-Match, so an unchanged page costs a304 Not Modifiedwith no body. - A snapshot page is the same for everyone, so the CDN can cache it for the snapshot interval. 25,000 viewers become a few requests to the origin.
- The user's own row is fetched separately and privately, because it may include verdicts the frozen public board hides.
- Rank changes animate at most once per snapshot, and respect
prefers-reduced-motion.
Check your understanding · Exercise 10
Which contest decisions are fair and keep the system up? Select all that apply.
Integrity: anti-cheating hooks, and their limits
Contests and hiring assessments want to know whether the code is the candidate's own. The browser can collect some signals, but it is the user's machine, so every signal can be faked or blocked. Treat everything the client reports as a hint for a human to review, never as proof and never as an automatic verdict.
| Signal | How the client sees it | What it can and cannot tell you |
|---|---|---|
| Large paste | A paste event on the editor, or a single edit that inserts many lines | Someone pasted 40 lines. It may be their own snippet from another tab, a template, or a solution from a website. |
| Leaving the tab | visibilitychange to hidden, blur on the window | The page lost focus for 3 minutes. They may have read docs (often allowed), answered a message, or looked up the answer. |
| Typing rhythm | Timestamps of edits | A solution that appears in one burst is unusual. Fast typists and people who think first and type later look the same. |
| Similar code | Not a client signal: the server compares submissions | The strongest signal by far. Done after the contest, with human review. |
The real work is server-side plagiarism detection: after the contest, compare every accepted solution for a problem with every other one, using tools that ignore renamed variables and reordered lines (MOSS from Stanford is the classic example, based on document fingerprinting). Flagged pairs go to a human. On the client, the useful job is small: record a few events in the interview pad's timeline, such as "pasted 38 lines at 10:41" or "left the tab for 2 min", so the reviewer has context.
Privacy and fairness
These hooks touch people's privacy and can be unfair, so design them carefully and say so in the interview:
- Tell users what is recorded, before the assessment starts, in plain words. Hidden tracking destroys trust when it surfaces.
- Record events, not content: "pasted 38 lines", not the clipboard text from other apps. Never read the clipboard outside a paste into the editor.
- Never block paste outright. People paste their own code, and some users rely on paste because of motor impairments or assistive tools.
- Never fail someone automatically on a tab switch. Screen magnifiers, password managers, and notifications all steal focus.
- Keep the data only as long as the review needs, and follow the hiring company's data rules.
Check your understanding · Exercise 11
Which anti-cheating designs are reasonable? Select all that apply.
Architecture, data model, and API
With the decisions made, the client falls into a few parts, a handful of entities, and a short list of API calls. This chapter draws all three. The guiding rule for the parts is that slow or risky things sit behind a boundary, so that if they fail the user can still read the problem and keep their code.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
| Problem page (server-rendered) | Statement HTML, examples, metadata, the premium shell | It must be fast and indexable without any JavaScript, and cacheable on the CDN. |
| Workspace shell | Split layout, tabs, keyboard shortcuts, the language picker | Owns layout state only, so editor or results bugs cannot break it. |
| Editor adapter | Wraps Monaco or CodeMirror behind getValue, setValue, setMarkers, onChange | Swapping editors, or loading vim mode, touches one file. |
| Draft store | Reads and writes drafts in IndexedDB; syncs to the server with versions | Durability logic in one place, testable without a browser editor. |
| Run and submit client | Builds snapshots, idempotency keys, handles 429 and 503 | The rules for safe retries live in one place. |
| Status watcher | SSE first, polling fallback, seq ordering, final-state stickiness | Swapping the channel never touches UI code. |
| Local runner (optional) | A worker that runs JavaScript examples with a time limit | Isolated in a worker so a loop cannot freeze the page. |
| Results panel | Verdict, failing case, token diff, histogram, live-region announcements | Pure rendering from a submission object. |
| Contest shell | Server clock, manifest unlock, scoreboard pages, freeze banner | Contest-only code is loaded only on contest pages. |
| Server cache | A query library cache for problems, submissions, history | Deduplicates requests and keeps lists fresh after a verdict. |
Follow the data for the main screen. The problem page arrives as cached HTML from the CDN. The workspace boots, loads the editor bundle, asks the draft store for the draft of this problem and language (IndexedDB first, then the server to check for a newer version), and falls back to the starter code.
A Submit sends the snapshot to the API, receives an id, and hands it to the status watcher, which feeds events into the server cache. The results panel and the history list both read from that cache, so a verdict updates both at once.
Two boundaries deserve a sentence in the interview. The status watcher writes into the server cache rather than into the results panel's state, because several views care about the same submission (the results panel, the history list, the contest scoreboard row). And the draft store never waits for the network: it writes locally first and syncs later, so a server outage costs sync, not work.
Check your understanding · Exercise 12
Why does the status watcher write events into a shared server cache instead of the results panel's local state? Select all that apply.
Data model: problems, templates, drafts, and submissions
The client's entities split by who owns them and how long they live. Problems are read-mostly and shared by everyone. Drafts are personal and change every few seconds. Submissions are personal, never change once final, and pile up: an active user has thousands.
type Problem = {
id: string; slug: string; title: string;
difficulty: "easy" | "medium" | "hard";
tags: string[]; premium: boolean;
statementHtml: string | null; // null in the shell of a locked premium problem
examples: { input: string; output: string; explanation?: string }[];
constraints: string[];
templates: Record<LanguageId, string>; // starter code only
};
type Draft = {
problemId: string; language: LanguageId;
code: string; updatedAt: number;
baseVersion: number; // server version this edit started from
dirty: boolean; // not yet synced
};
type Submission = {
id: string; problemId: string; language: LanguageId; code: string;
submittedAt: number; seq: number;
status: "queued" | "compiling" | "running" | Verdict;
queuePosition?: number; testsDone?: number; testsTotal?: number;
runtimeMs?: number; memoryKb?: number;
failingCase?: { index: number; input: string; expected: string; actual: string; stdout: string };
compileErrors?: { line: number; column: number; message: string }[];
};
| Entity | Lives in | Key | Notes |
|---|---|---|---|
| Problem statement | CDN HTML, then the server cache | slug | Rendered at publish time; premium body fetched privately. |
| Language templates | Inside the problem payload | problemId + language | A few KB per language; fetch only the current language's template if the list is long. |
| Draft | IndexedDB, synced to the server | problemId + language | Written on a 500 ms debounce; synced every 10 to 30 s and on hide. |
| Pending submit | Memory and sessionStorage | idempotencyKey | Survives a reload so a retry reuses the key. |
| Submission | Server cache, paginated history | id | Final ones are immutable, so they can be cached forever. |
| Per-test results | On the server, summarized for the client | submissionId + test index | The client gets counts and at most one failing case. |
| Editor settings | localStorage and the user profile | user | Font size, keymap, tab size, theme. |
| Layout ratio | localStorage | page type | Device-specific on purpose: a laptop and a big monitor want different splits. |
Per-test results deserve a word. On the server, each submission has a row per test (verdict, time, memory), and the client almost never needs that list. It needs testsDone and testsTotal while running, then the verdict, the slowest time, the peak memory, and the one failing case if allowed. Sending 200 per-test rows would leak which tests are slow (a hint about what the hidden tests are) and waste bytes.
Think first
A user has 3,000 submissions. How should the submissions tab on a problem page load them?
Fetch only this problem's submissions, newest first, with cursor pagination (20 at a time), and leave out the code field in the list. Load a submission's code only when the user opens it.
Because final submissions never change, opening one can be cached aggressively in the server cache. A new verdict is added to the top of the list from the status watcher rather than refetching the page.
The API the client calls
REST over HTTPS is the natural fit: most calls are plain reads and a few creates, and status updates are a one-way stream. Here are the calls the client makes, in the order a user meets them.
| Call | Purpose | Caching |
|---|---|---|
GET /problems/:slug (HTML page) | The server-rendered problem page | CDN, revalidated when the statement changes |
GET /api/problems/:slug/content | Premium statement body after an entitlement check | private, no-store |
GET /api/drafts/:problemId?language=python3 | The server copy of a draft and its version | No cache |
PUT /api/drafts/:problemId | Save a draft with baseVersion; 409 if the server is newer | No cache |
POST /api/runs | Run code on examples or custom input; returns a run id | No cache, rate limited |
POST /api/submissions | Create a submission with an Idempotency-Key; 202 with id | No cache, rate limited |
GET /api/submissions/:id/events | SSE stream of status events | No cache |
GET /api/submissions/:id | The latest status; the polling fallback | Final ones: private, max-age long |
GET /api/time | Server time in ms, for the contest clock | no-store |
GET /api/contests/:id/manifest | Problem paths once started; Retry-After before | CDN for 1 to 2 s after start |
GET /api/contests/:id/standings?page=3 | A page of the latest snapshot, with an ETag | CDN for the snapshot interval |
GET /api/submissions/sub_81723/events
Accept: text/event-stream
id: 2
data: {"status":"queued","queuePosition":3,"seq":2}
id: 3
data: {"status":"compiling","seq":3}
id: 4
data: {"status":"running","testsDone":7,"testsTotal":120,"seq":4}
id: 9
data: {"status":"wrong_answer","testsDone":38,"testsTotal":120,"failingCase":null,"seq":9}
id (the seq), so a reconnect resumes with Last-Event-ID.Errors use one shape everywhere, so the client can handle them in one place: a machine-readable code, a human message, and, for limits, a retryAfterMs that matches the Retry-After header. The client maps codes to UI: rate_limited shows a countdown on the button, contest_not_started re-syncs the clock, draft_conflict opens the keep-which-copy dialog, and judge_unavailable keeps the pending submit and retries.
{
"error": {
"code": "rate_limited",
"message": "You can submit again in 8 seconds.",
"retryAfterMs": 8000
}
}
Note what the API never returns: hidden test inputs, expected outputs for hidden tests, driver code, the reference solution, and per-test timings for hidden tests. Write that list down in the interview. It is the security model of the whole product in five lines.
Check your understanding · Exercise 13
Which API choices are sound for this design? Select all that apply.
Accessibility, performance, and watching it in production
A judge is a keyboard-heavy tool used for an hour at a time, so accessibility and speed are both about the same thing: never getting in the way of someone who is thinking. The keyboard-navigation and screen-readers lessons cover the basics; here is what is specific to a judge.
The editor and the Tab key
Inside a code editor, Tab inserts indentation, so a keyboard user can tab in but not out. Monaco has an accessibilitySupport option that tunes it for screen readers, and a mode where Tab moves focus instead of indenting, toggled with Ctrl+M (Ctrl+Shift+M on macOS). CodeMirror 6 does not trap Tab by default; when Tab indentation is turned on, its documented way out is Escape then Tab, and the codepen guide covers its own toggle keys.
Show the escape keys in a small hint when the editor gets focus, and make Tab behaviour a setting, not a secret.
Run, Submit, and the verdict
- Real
buttonelements for Run and Submit, with the shortcut shown in the label and declared witharia-keyshortcuts. The shortcuts must not clash with screen reader or browser keys. - While judging, the Submit button stays focusable with
aria-disabled="true"rather thandisabled, so focus does not vanish and a screen reader can still hear why it is unavailable. - A visually hidden
role="status"region announces "Submitted, in queue" once and the final verdict once. No per-test chatter. - Verdicts never rely on color alone: "Accepted" and "Wrong Answer" are words with an icon, and the token diff marks differences with underline and a label, not just red and green.
- The divider between panes is a focusable
separatorthat arrow keys move, as described in the problem page chapter.
The contest countdown needs care too. An element with role="timer" is not announced on each change by default, which is right: hearing every second is unbearable. Announce milestones instead ("10 minutes left", "1 minute left") through the same polite status region, and respect prefers-reduced-motion for any flashing at the end.
Performance budget
| Metric | Target | How |
|---|---|---|
| LCP (the statement) | Under 2.5 s at the 75th percentile | Pre-rendered HTML from the CDN; KaTeX CSS only, fonts loaded only when math is present. |
| Editor ready | Under 1 s after LCP on a warm cache | Dynamic import() after first paint; prefetch the bundle when the browser is idle; a static code preview until then. |
| INP (typing, clicking Run) | Under 200 ms | The editor is uncontrolled: React does not re-render on every keystroke. Draft writes are debounced and IndexedDB is async. |
| CLS | Under 0.1 | Images in statements have width and height; the console has a fixed height. |
| Language pack switch | Under 300 ms | Each language's highlighting loaded on demand and cached by the service worker or HTTP cache. |
The biggest trap is the editor's onChange. If every keystroke updates React state at the workspace level, the whole layout re-renders 5 times a second while someone types. Keep the code inside the editor, read it with getValue() when you need it (Run, Submit, save), and let the draft store subscribe to change events on its own debounce.
What to measure in production
- Time from Submit click to verdict on screen, split into POST time, queue time, judge time, and delivery time. Only the first and last are the client's fault, and you need to know which.
- How often the SSE stream falls back to polling, by network and browser. A spike usually means a proxy or CDN change.
- Draft sync conflicts and failures, and any session that ended with unsynced dirty drafts.
- Contest start: manifest response times at the edge, 429 and 503 rates, and the spread of client clock offsets (it tells you how many users would have been wrong with
Date.now()).
Check your understanding · Exercise 14
Which choices improve accessibility or responsiveness in the workspace? Select all that apply.
Running it in the interview
Sixty minutes is not enough to say everything in this guide, so spend them where the signal is. The interviewer wants to see that you find the trust boundary, design the slow, stepped submission flow properly, and think about the contest spike. The editor and the statement page are supporting parts.
| Minutes | What to do | What to draw |
|---|---|---|
| 0 to 6 | Scope: languages, hidden tests, contest size, premium content, hiring use | Nothing yet; write the numbers in a corner |
| 6 to 12 | Wireframe the workspace and name the parts | The split screen: statement, editor, console with tests and results |
| 12 to 22 | Where code runs; Run vs Submit | Browser and server boxes with the line that hidden tests never cross |
| 22 to 37 | Submission pipeline: 202 with id, idempotency key, states, SSE with polling fallback | The sequence diagram and the state machine |
| 37 to 48 | Contests: server clock offset, CDN release, backpressure, leaderboard snapshots | A timeline around start time |
| 48 to 55 | Drafts, data model, results view | The draft flow, one TypeScript type |
| 55 to 60 | Accessibility, metrics, and what you would do next | Nothing new; summarize |
Say the key sentences out loud: "The verdict comes from the server because only the server has the hidden tests." "The POST returns an id in 200 ms; status comes on a stream." "One idempotency key per intent, reused on retries." "Contest time is when the POST was accepted." "The countdown uses a server offset and a monotonic clock." Each one shows you saw a failure before it happened.
Follow-up questions to expect
Think first
"How would you add a live interview pad where the interviewer watches the candidate type?"
Add a collaboration layer on top of the editor adapter: a CRDT or OT document synced over a WebSocket, as in the google-docs guide, with presence for the other person's cursor. The draft store then saves the shared document instead of a local-only draft.
Run and Submit stay the same, but the results stream is broadcast to both people, and the interviewer gets controls the candidate does not have: turning autocomplete off, picking the problem, and seeing the integrity timeline.
Think first
"The user's network drops for 2 minutes during a contest. What happens?"
They can keep reading and writing: the statement is already loaded and drafts go to IndexedDB. The status stream errors and the watcher falls back to polling with backoff, which keeps failing quietly with a "reconnecting" banner. The countdown keeps running from the monotonic clock.
If they clicked Submit while offline, the pending submit holds the code and the key and retries when online fires. If the server already received it, the key returns the same id. Its contest time is when the server accepted it, so a submission that never left the laptop does not get the earlier time. Say that plainly in the UI.
Think first
"Contest participation grows to 200,000. What breaks first on the client side?"
The start moment and the leaderboard. Move further toward pre-loading: download encrypted statements before start and release only a key, and add more jitter to the first request. Serve standings pages only from the CDN and make the snapshot interval longer.
Status delivery is next: 200,000 open streams at peak may push you to one stream per user for all their submissions instead of one per submission, or to longer polling caps with Retry-After during the first minutes.
Summary
- The browser is untrusted: it can make Run feel fast, but verdicts come from server sandboxes, because only the server has the hidden tests and fair timing.
- Render statements once at publish time and serve free ones from a CDN for speed and search; fetch premium bodies privately and never hide paid text with CSS.
- Keep drafts per problem per language in IndexedDB first, sync them to the server with versions, and save on
visibilitychange. - Submit returns
202with an id fast; anIdempotency-Keyper intent, reused on retries, makes double clicks and lost responses harmless. - Deliver status over SSE with a polling fallback that backs off with jitter; order events by
seqand make final states sticky. - Show the failing case only when policy allows, diff outputs by token, and map compile errors to the user's own lines.
- Contest clocks use a server offset from the shortest round trip plus
performance.now(); the server's clock alone decides what counts. - Survive the contest start with CDN-served problems unlocked by a manifest, backpressure through
Retry-After, and leaderboard snapshots instead of a push per change. - Anti-cheating hooks in the browser are signals for a human reviewer, recorded openly; similarity detection on the server is the real check.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- MDN: Using server-sent events Event format,
idfields, reconnection, and the connection limit over HTTP/1.1. - MDN: Performance.now() The monotonic clock the contest countdown is built on.
- MDN: Page Visibility API
visibilitychange, timer throttling in background tabs, and saving state. - MDN: Retry-After How the server tells clients when to come back after a 429 or 503.
- IETF draft: The Idempotency-Key HTTP Header Field The header pattern for making POST safe to retry.
- Judge0 on GitHub An open-source judge: the submissions API with tokens, many languages, and sandboxed runs.
- isolate on GitHub The IOI sandbox Judge0 uses: namespaces, cgroups, and time and memory limits per run.
- gVisor documentation How a user-space kernel isolates untrusted code from the host.
- Firecracker MicroVMs for running untrusted workloads with a small footprint.
- Pyodide documentation CPython compiled to WebAssembly, and what it costs to load.
- KaTeX Fast LaTeX math rendering, including on the server.
- ICPC World Finals rules Penalty time of 20 minutes per rejected run, and the scoreboard freeze in the last hour.
- RFC 5905: Network Time Protocol Version 4 The four-timestamp offset and delay math the contest clock simplifies.
Concepts behind it
- Server-sent eventsOne HTTP response that never finishes: the server keeps writing small text events to it, and the browser's
EventSourceparses them, reconnects on its own, and resumes where it left off. The simplest push channel when data flows from server to client. - WebSocketsOne long-lived connection, opened by an HTTP handshake, on which browser and server can each send a message at any moment. The usual pick for chat, multiplayer, and collaboration, and the one where you own the protocol, reconnects, and scaling.
- Retries, timeouts, and backoffHow a client survives a flaky network and a struggling server: give every request a deadline, retry only the failures that can heal, space the retries out with exponential backoff and jitter, and stop retrying when the server or the network is clearly down.
- Server-side renderingThe server builds the full HTML for each request, so users see content before any JavaScript runs, then the browser hydrates it: faster first paint and better SEO, paid for with server work, a later first byte, and a second render in the browser.
- CDN cachingKeeping shared copies of your responses on servers near every user, so most requests never travel to your origin: the biggest lever for global speed and for surviving traffic spikes.
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.