cf.completefrontend
My progress
SYSTEM DESIGN · #109

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.

QuestionAnswer we assumeWhat 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.

TraitWhat it forcesChapter
Secret answer keyServer verdicts; Run and Submit as two pathsWhere code runs, Run vs Submit
Slow, stepped resultSubmission id, status channel, state machineSubmission pipeline
Many languagesLazy language packs, per-language templates and draftsThe editor
Precious workLocal and server drafts, idempotent submitThe editor, Submission pipeline
FairnessServer-side timing, server time offsetWhere code runs, Contests
Spiky trafficCDN-released pages, backoff with jitter, throttled leaderboardContests

Think first

Modern browsers can run Python and C++ through WebAssembly. Why not judge everything in the browser and save the server cost?

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.

Wireframe of the problem workspace at judge.example.com/problems/two-sum. The left pane has Description and Submissions tabs, the title 1. Two Sum, an Easy badge, tags Array and Hash Table, the statement rendered from markdown with math and images, an example box with input nums = [2,7,11,15] and output [0,1], and the constraints. A dashed divider separates it from the right pane. The right pane has the editor with a Python3 language picker, a vim toggle, and Reset, showing the starter class Solution with def twoSum. Below it, the console has Testcase and Result tabs, the cases, and the Run and Submit buttons.judge.example.com/problems/two-sumDescriptionSubmissions1. Two SumEasyArrayHash Tablemarkdown, math, imagesnums = [2,7,11,15]output: [0,1]constraints, follow-upserver-rendered, on CDNPython3vimResetclass Solution:  def twoSum(self, ...):    seen = {}editor, loaded after paintTestcaseResultCase 1, Case 2, customRunSubmit
The statement on the left is plain HTML from the CDN and paints first. The editor on the right loads after it. Both dividers are draggable and keyboard focusable, and the ratio is saved per device.

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?

In the browser, on every view

Ship the raw markdown in JSON.

A markdown parser and KaTeX run in the browser and produce the DOM.

Pros

  • Simple to build.
  • One API for the web and native apps.

Cons

  • Adds a parser and KaTeX, often 100 KB or more gzipped, to the page's critical path.
  • The statement appears only after JavaScript runs, so LCP is late.
  • Search crawlers see an empty shell unless they run the script.

On the server, on every request

Server-side rendering (SSR) runs the markdown parser and KaTeX for each page request.

Pros

  • The HTML arrives ready; fast LCP; good for search.

Cons

  • Spends CPU on the same transform millions of times for a statement that changes once a month.
  • During a contest spike, rendering competes with everything else on the server.

Once, when the statement is published

The authoring tool converts markdown and math to sanitized HTML when an editor publishes.

The HTML is stored next to the markdown and served inside a pre-built or cached page.

Pros

  • Zero render cost per view.
  • Sanitizing happens once, in one trusted place.
  • The page can be cached on a CDN as a static file.

Cons

  • Changing the renderer means re-rendering every statement (a batch job over a few thousand problems).
  • Needs a rendering version field so old and new output can be told apart.

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.

How a statement is rendered and served. At publish time the Author's markdown with LaTeX goes to a Publish job that runs the markdown parser and KaTeX and sanitizes the result, then stores both the markdown and the HTML in Statements. Free problems are pre-rendered into pages on the CDN, which sends public cached HTML to the Browser. Premium bodies go through the Content API, which checks the user's plan and answers privately with no-store. The browser needs KaTeX's CSS and fonts but not its JavaScript.Authormarkdown, LaTeXPublish jobKaTeX, sanitizeStatementsmd + htmlCDNfree pagesBrowserHTML + KaTeX CSSContent APIchecks planpre-renderHTMLpremiumpublic, cachedno KaTeX JSprivate, no-store
The expensive work (parsing, math, sanitizing) happens once per edit, not once per view. Free pages are the same for everyone, so the CDN can cache them; premium bodies are sent with 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

A premium problem page must rank in search but not leak its statement. Which choices are safe? Select all that apply.

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?

Monaco (the editor inside VS Code)

Load monaco-editor lazily after the statement paints, and register each language's highlighting when the user picks it.

JavaScript and TypeScript get real completions from a built-in TypeScript worker.

Pros

  • Feels like VS Code, which most users already code in every day.
  • Highlighting for most of the 20 languages ships in the package.
  • Type-aware JavaScript and TypeScript completion with no server.

Cons

  • Megabytes of JavaScript and workers (the codepen guide has the details); acceptable on a desktop that reads the statement first, costly on a phone.
  • Its README says mobile browsers are not supported, so phone users get a weak editing experience.
  • Grabs Tab for indentation by default, so the focus escape must be taught (see the accessibility chapter).

CodeMirror 6

Compose the editor from small packages: a core, one language package per language, extensions for keymaps and completion.

Pros

  • Small core, and a C++ user never downloads Java support.
  • Works on phones and tablets, which matters if interview pads run on iPads.
  • No keyboard trap by default.

Cons

  • Some of the 20 languages have only legacy modes or community packages, so check coverage language by language.
  • No type-aware TypeScript completion out of the box.

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.

ts
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};",
};
One signature, many templates. Only 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.

Draft saving as a sequence between the Editor, the Draft store, IndexedDB, and the Server. The user types and the editor reports a change. 500 ms after typing stops, the draft store puts the draft in IndexedDB. Every 20 seconds it sends PUT with base version 7, and the server replies 200, now version 8. When the tab is hidden it sends PUT with base version 8, but the server replies 409 because another device saved version 9. The draft store keeps the local draft and asks the user which copy to keep.EditorDraft storeIndexedDBServeronChangeput after 500 msPUT, base v7200, now v8on hide: PUT v8409: server v9ask: keep which?
The local write never waits for the network, so a crash loses at most half a second. The server copy carries a version, so a second device can never be silently overwritten: a 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.

Three ways to isolate untrusted code on a judge server, side by side, all above the same host kernel. Containers: user code runs in a container with namespaces, cgroups, and seccomp, and talks straight to the host kernel; fastest and weakest. gVisor: user code talks to the gVisor Sentry, a user-space kernel, which makes only a few calls to the host kernel; a middle ground. Firecracker: user code runs on its own guest kernel inside a microVM using KVM, and only the VM touches the host; strongest and costliest.containersgVisorFirecrackeruser codeuser codeuser codecontainernamespaces, cgroupsgVisor Sentryuser-space kernelguest kernelFirecrackermicroVM on KVMall callsfew callshost kernelfast, shared kernelmiddle groundstrongest, costliest
Each step to the right puts one more layer between a stranger's code and the host kernel. The cost is start time and overhead per run, which matters when a contest needs hundreds of runs a second.

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.

ts
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 });
  });
}
Running JavaScript examples in a worker with a hard time limit. The playground guide covers console capture in more depth.

Decision Where does Run execute, and where does Submit execute?

Everything on the server

Run and Submit both create a server job; Run uses the visible examples or custom input, Submit uses hidden tests.

Pros

  • One runtime per language, so Run and Submit behave the same.
  • Same compiler versions, same limits, same timing.
  • Works for all 20 languages.

Cons

  • Every Run costs a sandbox and a queue slot.
  • Run feels slower: 1 to 3 seconds instead of instant.

Everything in the browser

Ship runtimes to the client and judge locally.

Pros

  • No server cost per run.
  • Instant feedback.

Cons

  • Hidden tests would have to be sent to the client: fatal.
  • The user controls the code that reports the verdict.
  • Timing depends on the device, so limits are unfair.
  • No good story for Java, Go, or Rust.

Hybrid: browser for a quick Run, server for everything that counts

For JavaScript (and optionally Python), Run on examples executes in a worker.

Submit, and Run for other languages, go to the server.

Pros

  • Instant Run for the most common languages.
  • Saves server capacity during contests.

Cons

  • Two runtimes can disagree: a Pyodide version or a missing package can pass locally and fail on the server.
  • Timing shown in the browser must be labelled as not judged.

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.

Where code runs. In the browser zone: the Editor, a JS worker that runs JavaScript on the examples with a time limit, and an optional Pyodide runtime, drawn dashed. In the server zone: the Judge API, which answers 202 with an id, a Queue, a pool of Sandboxes, and the hidden Tests store, which feeds only the sandboxes. Code goes from the editor to the Judge API and only the verdict comes back. A line between the zones marks that hidden tests never cross into the browser.BrowserEditorJS workerexamples, 2 s capPyodideoptionalRun (JS)Judge serversJudge API202 + idQueueSandboxeslimits, timingtestshiddencodeverdicthidden tests never cross this line
The browser may run public examples for a fast Run. Everything that counts happens right of the line, where the hidden tests live, and only a verdict crosses back.

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.

AspectRunSubmit
InputsThe visible examples, plus custom input the user typesThe hidden test set, often 50 to 200 cases
What the user seesOutput for every case, with expected output when knownA verdict; details of the first failing case only if it is not secret
Stops early?No: runs every case the user gaveUsually yes: stops at the first failing test to save capacity
Recorded?No, or kept briefly for the UIYes: stored forever, shown in history, counts for streaks and contests
Where it may runServer, or the browser for some languagesServer only
Typical limitA few per minute, one at a timeOne 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-After and 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.

http
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 }
Creating a submission. The key is created once per user intent and reused on any retry.

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.
Submitting, with a failure, as a sequence between the User, the Client, the API, and the Judge. The user clicks Submit. The client sends POST with idempotency key k1. The API enqueues submission sub_81 with the judge. The 202 response is lost when the Wi-Fi drops. The user clicks again, and the client retries with the same key k1. The API finds k1 and answers 202 with sub_81 again, creating no new job. Later the judge reports Accepted to the API, and the API sends an SSE event with seq 9 to the client, which shows the verdict.UserClientAPIJudgeclick SubmitPOST, key k1enqueue sub_81202 lost (Wi-Fi)clicks againretry, same k1202: same sub_81verdict: acceptedSSE event, seq 9
The client could not know whether the first POST counted. Reusing 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:

StateWhat the user seesNotes
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.
acceptedGreen verdict, runtime and memoryFinal.
wrong_answerRed verdict, "38 / 120 testcases passed", the failing case if visibleFinal.
time_limit_exceeded, memory_limit_exceededThe limit that was hit, and on which testFinal.
runtime_errorThe error type and message, such as an index out of rangeFinal. The message is scrubbed of server paths.
compile_errorThe compiler output, mapped to editor linesFinal. 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.
The submission state machine. Submitting, the POST with a key, goes to queued with a position when the server answers 202. Queued goes to compiling for compiled languages, or straight to running for interpreted ones. Compiling goes to running when the build succeeds, or to the final state compile error. Running, test 7 of 120, ends in rejected, meaning wrong answer, time limit, memory limit, or runtime error, or in accepted. Final states are sticky and any event with a seq at or below the last one seen is ignored. An internal error is retried by the judge and never charged a penalty.submittingPOST, keyqueuedposition 14compilingC++, Javarunningtest 7 / 120202interpreted: no buildcompile errorlines mappedrejectedWA, TLE, MLE, REacceptedtime, memoryerrorfailsfinal states are stickyignore seq at or below lastinternal error: judge retries,never a penalty
Three working states, then exactly one final state. The client draws whatever the latest event says, but never moves backward: an older seq is ignored and a final state never changes.

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:

ChannelRequests or messages per secondOpen connectionsStatus delay
Poll every 500 ms15,000 ÷ 0.5 = 30,000 requests/sShort-livedUp 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/sShort-livedUp to 4 s when the job is slow
SSE stream per submissionAbout 500 × 5 events = 2,500 messages/s, all useful15,000 open streamsNear zero
WebSocket per tabSame messages as SSE, plus pingsOne per tab, 25,000 socketsNear 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?

Short polling at a fixed interval

GET /api/submissions/:id every 500 ms until a final state.

Pros

  • Trivial to build and to cache-bust.
  • Works through every proxy.

Cons

  • Most responses are empty news.
  • Load grows with the number of waiting users, which is worst at peak.

Polling with exponential backoff and jitter

Start at 500 ms, double each time up to a cap of about 4 s, add random jitter, honour Retry-After.

Pros

  • Simple, stateless, works everywhere.
  • Load drops sharply for slow jobs.
  • The server can slow clients down with Retry-After.

Cons

  • A verdict can sit unseen for up to the cap.
  • Still mostly empty responses.

Server-Sent Events (SSE)

new EventSource("/api/submissions/:id/events"); the server writes one event per state change and closes at the final state.

Pros

  • One-way server to client, which is exactly the need.
  • Plain HTTP; the browser reconnects on its own and sends Last-Event-ID.
  • Over HTTP/2, many streams share one connection.

Cons

  • Holds an open stream per watched submission.
  • On HTTP/1.1 each stream uses one of the browser's six connections per site, so serve streams over HTTP/2.
  • EventSource cannot set custom headers, so auth uses cookies.
  • Some corporate proxies buffer streams, so a fallback is needed.

WebSocket

One socket per tab; subscribe to submission ids; the server pushes updates.

Pros

  • Two-way, so one connection can also carry contest announcements and leaderboard deltas.
  • Lowest overhead per message.

Cons

  • Needs its own reconnect, resubscribe, and heartbeat logic.
  • Stateful servers are harder to scale and load balance.
  • Two-way is more than a status feed needs.

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.

ts
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);
}
Watch a submission: stream first, polling with backoff as the fallback. 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.

Channel
A submission with 12 tests where test 9 fails, watched with polling with backoff and jitter. At 0 ms the server is at nothing yet and the client shows nothing yet. Status requests so far: 0. POSTs sent: 0; submissions created: 0.serverclient seesqueuedqueuedcompilingcompilingrunningrunningverdictverdicttests123456789101112status requests: 0verdict not seen yetPOSTs sent: 0submissions: 0
Press Submit or Double-click Submit, then drag the time slider.

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.

Wireframe of the results panel after a wrong answer. The verdict Wrong Answer, with 38 of 120 testcases passed. The failing input is nums = [3,3], target = 6. The output tokens are 0 and 0, with the second token highlighted as different. The expected tokens are 0 and 1, with the second highlighted. A note says first difference at position 1. Below is the code's stdout, seen: {3: 0}. At the bottom, the polite status region text, and a note that these results are for the code from 10:42:07.ResultWrong Answer38 / 120 testcases passedInputnums = [3,3], target = 6Output00Expected01first difference at position 1Stdoutseen: {3: 0}role=status: "Wrong Answer, 38 of 120 passed"results for the code from 10:42:07, diff with editor
One failing case, shown only because policy allows it, and the first differing token marked in both outputs. The status region speaks the verdict once without moving focus out of the editor.

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?

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.

A contest timeline. At T minus 10 minutes, problem pages are published to the CDN. At T minus 1 minute, clients re-sync their clocks. At T0, the manifest unlocks, with each client waiting a random 0 to 2 seconds. At T plus 5 minutes, submissions peak at about 500 a second and the queue is deep. At T plus 60 minutes, the public board freezes. At T plus 90 minutes, a final burst arrives and the contest ends. Notes: score is the time the POST was accepted; a 503 comes with Retry-After and the client retries with the same key; the leaderboard is a snapshot every 10 to 30 seconds served from the CDN.T-10mT-1mT0T+5mT+60mT+90mpages to CDNre-sync clockmanifest unlocksjitter 0 to 2 s500 submits/squeue deepboard freezesfinal burst, endscore = POST accept time503: Retry-After, same keyboard: snapshot every10 to 30 s, on the CDN
Two spikes, at the start and the end, and each has its own answer: the start becomes CDN cache hits, and the submit bursts are absorbed by a queue the client waits on politely.

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 setInterval tick 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.

ts
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());
A server clock: best of 5 samples, then monotonic time. Re-sync every few minutes and on visibilitychange.
Server clock sync as a sequence between the Client and the Server. Sample 1: the client asks GET /api/time and the server answers now = 60,300 after a 300 ms round trip. Sample 2: the client asks again and the server answers now = 60,560 after an 80 ms round trip. The client keeps sample 2, with t0 = 500 and t1 = 580 on performance.now(), so the error is at most 40 ms and the server time at t1 is 60,560 plus 40. From then on, serverNow equals 60,600 plus performance.now() minus 580, recomputed on every tick.ClientServersample 1: GET /api/timenow = 60,300 (rtt 300)sample 2: GET /api/timenow = 60,560 (rtt 80)keep sample 2t0 500, t1 580error at most 40at t1: 60,600serverNow() = 60,600 + (performance.now() - 580)each tick recomputes from this; Date.now() is never used
The shortest round trip has the smallest possible error, half of it. After the sync the device clock is never read again: 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.

Sync with
Two countdowns at server time -20 seconds from the start. The Date.now() countdown shows started and says started 20 s early. The server-offset countdown shows 0:20.0, off by 42 ms. On the timeline from 60 seconds before to 60 seconds after the start, the device believes the start is at -40 seconds.Date.now()startedsays started 20 s earlyserver offset0:20.0off by 42 msdevice T0now-60 sT0+60 ssynced T0
Device clock +40 s. The Date.now() countdown says started 20 s early. The server-offset countdown is off by 42 ms, from a 210 ms round trip, the shortest of 5 samples.

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?

Origin renders each problem on request after start

The contest page calls GET /api/contests/:id/problems at start; the origin checks the time and renders.

Pros

  • Simple; nothing exists anywhere before start.

Cons

  • The whole herd hits the origin at once.
  • Slow responses cause retries, which make it worse.

Pre-built pages on a CDN, unlocked by a small manifest

Before start, statements are rendered and pushed to the CDN under random, unguessable paths.

At start, a tiny manifest endpoint begins returning those paths; it is cached at the edge for a second or two.

Pros

  • Statements are served from the edge, close to users.
  • The origin sees only manifest misses, a handful per edge location per second.

Cons

  • An unguessable URL is only as secret as the people who can see it; staff must not leak it.
  • Needs a publish step that runs reliably before every contest.

Encrypted statements preloaded, key released at start

Clients download encrypted statements minutes early.

At start, a tiny response returns the key; the client decrypts with the Web Crypto API.

Pros

  • The start moment moves almost no bytes: one small key response per user.

Cons

  • More moving parts; a bug in decryption breaks the contest for everyone.
  • Early downloads still need a CDN.

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 Unavailable with Retry-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".

ts
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;
}
Penalty time under both models. The server is the source of truth; the client shows the same math.

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 a 304 Not Modified with 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.

SignalHow the client sees itWhat it can and cannot tell you
Large pasteA paste event on the editor, or a single edit that inserts many linesSomeone pasted 40 lines. It may be their own snippet from another tab, a template, or a solution from a website.
Leaving the tabvisibilitychange to hidden, blur on the windowThe page lost focus for 3 minutes. They may have read docs (often allowed), answered a message, or looked up the answer.
Typing rhythmTimestamps of editsA solution that appears in one burst is unusual. Fast typists and people who think first and type later look the same.
Similar codeNot a client signal: the server compares submissionsThe 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.

PartResponsibilityWhy the boundary is here
Problem page (server-rendered)Statement HTML, examples, metadata, the premium shellIt must be fast and indexable without any JavaScript, and cacheable on the CDN.
Workspace shellSplit layout, tabs, keyboard shortcuts, the language pickerOwns layout state only, so editor or results bugs cannot break it.
Editor adapterWraps Monaco or CodeMirror behind getValue, setValue, setMarkers, onChangeSwapping editors, or loading vim mode, touches one file.
Draft storeReads and writes drafts in IndexedDB; syncs to the server with versionsDurability logic in one place, testable without a browser editor.
Run and submit clientBuilds snapshots, idempotency keys, handles 429 and 503The rules for safe retries live in one place.
Status watcherSSE first, polling fallback, seq ordering, final-state stickinessSwapping the channel never touches UI code.
Local runner (optional)A worker that runs JavaScript examples with a time limitIsolated in a worker so a loop cannot freeze the page.
Results panelVerdict, failing case, token diff, histogram, live-region announcementsPure rendering from a submission object.
Contest shellServer clock, manifest unlock, scoreboard pages, freeze bannerContest-only code is loaded only on contest pages.
Server cacheA query library cache for problems, submissions, historyDeduplicates requests and keeps lists fresh after a verdict.
The client architecture. In the browser, the left column has the Workspace shell for layout and keys, the Editor adapter around Monaco or CodeMirror 6, the Draft store on IndexedDB, and an optional Local runner, a JavaScript worker. The right column has the Results panel, the Server cache, the Status watcher using SSE with polling, and the Run and submit client that handles keys and retries. On the server side are the CDN with problem pages, the Contest service for time and standings, the Event stream over SSE, and the Judge API over REST. The workspace hosts the editor; the editor feeds the draft store and gives snapshots to the run and submit client, which calls the Judge API, can use the local runner, and hands the new submission id to the status watcher. The event stream feeds the status watcher, which writes into the server cache, which the results panel reads. Contest standings also arrive through the server cache.BrowserWorkspacelayout, keysEditor adapterMonaco or CM6Draft storeIndexedDBLocal runnerJS workerResults panelverdict, diffServer cachequery cacheStatus watcherSSE, then pollRun/submitkeys, retriesServersCDNproblem pagesContesttime, standingsEvent streamSSEJudge APIREST
Each risky or slow piece sits behind one small interface: the editor behind an adapter, the network behind the run and submit client and the status watcher, durability behind the draft store. Views only read the server cache.

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.

Data flow for the main screen. Read path, top row: Statements, stored as pre-rendered HTML, go to the CDN, which caches the page, then to the Browser, where the statement paints, which boots the Draft store to load the draft from IndexedDB. Write path, middle row from right to left: the Editor saves to the draft store after a debounce, and sends a code snapshot with a key to the Judge API, which answers 202 with an id and enqueues the job in the Judge queue, which runs hidden tests and emits status to the Event stream. Bottom row: the event stream sends events to the status Watcher, which orders them by seq into the Server cache, which re-renders the Results panel and history.StatementshtmlCDNpage cachedBrowserpaints firstDraft storeIndexedDBHTMLpagebootEditorcode snapshotJudge API202 + idJudge queuehidden testsEvent streamSSEdebouncedcode + keyenqueuestatusWatcherorders by seqServer cachesubmissionsResultsand historyone write, every view re-renders
Three kinds of things cross the wire: cached HTML on the way in, a code snapshot with its key on the way out, and small status events on the way back. The hidden tests stay inside the judge box.

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.

ts
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 }[];
};
The client-side shapes. Hidden tests and driver code are deliberately absent.
The data model. A Problem, cached from the CDN, has one Template per language inside its payload. Drafts, stored in IndexedDB and on the server, are keyed by problem and language. A Pending submit in sessionStorage holds the idempotency key and code hash and becomes a Submission, held in the server cache with its id, status, and seq. Each submission has per-test results that stay on the server only. Problems and templates are public and cacheable, drafts and submissions are personal, and per-test results never reach the client.ProblemCDN, cacheTemplateper languageDraftproblem + langSubmissionid, status, seqPending submitkey, code hashTest resultsserver onlybecomespublic, cacheablepersonalnever on the client
Public things are cached widely, personal things are keyed by user, problem, and language, and the per-test rows stay on the server, because their timings would hint at what the hidden tests contain.
EntityLives inKeyNotes
Problem statementCDN HTML, then the server cacheslugRendered at publish time; premium body fetched privately.
Language templatesInside the problem payloadproblemId + languageA few KB per language; fetch only the current language's template if the list is long.
DraftIndexedDB, synced to the serverproblemId + languageWritten on a 500 ms debounce; synced every 10 to 30 s and on hide.
Pending submitMemory and sessionStorageidempotencyKeySurvives a reload so a retry reuses the key.
SubmissionServer cache, paginated historyidFinal ones are immutable, so they can be cached forever.
Per-test resultsOn the server, summarized for the clientsubmissionId + test indexThe client gets counts and at most one failing case.
Editor settingslocalStorage and the user profileuserFont size, keymap, tab size, theme.
Layout ratiolocalStoragepage typeDevice-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?

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.

CallPurposeCaching
GET /problems/:slug (HTML page)The server-rendered problem pageCDN, revalidated when the statement changes
GET /api/problems/:slug/contentPremium statement body after an entitlement checkprivate, no-store
GET /api/drafts/:problemId?language=python3The server copy of a draft and its versionNo cache
PUT /api/drafts/:problemIdSave a draft with baseVersion; 409 if the server is newerNo cache
POST /api/runsRun code on examples or custom input; returns a run idNo cache, rate limited
POST /api/submissionsCreate a submission with an Idempotency-Key; 202 with idNo cache, rate limited
GET /api/submissions/:id/eventsSSE stream of status eventsNo cache
GET /api/submissions/:idThe latest status; the polling fallbackFinal ones: private, max-age long
GET /api/timeServer time in ms, for the contest clockno-store
GET /api/contests/:id/manifestProblem paths once started; Retry-After beforeCDN for 1 to 2 s after start
GET /api/contests/:id/standings?page=3A page of the latest snapshot, with an ETagCDN for the snapshot interval
text
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}
The status stream. Each event has an 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.

json
{
  "error": {
    "code": "rate_limited",
    "message": "You can submit again in 8 seconds.",
    "retryAfterMs": 8000
  }
}
One error format for every endpoint.

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 button elements for Run and Submit, with the shortcut shown in the label and declared with aria-keyshortcuts. The shortcuts must not clash with screen reader or browser keys.
  • While judging, the Submit button stays focusable with aria-disabled="true" rather than disabled, 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 separator that 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

MetricTargetHow
LCP (the statement)Under 2.5 s at the 75th percentilePre-rendered HTML from the CDN; KaTeX CSS only, fonts loaded only when math is present.
Editor readyUnder 1 s after LCP on a warm cacheDynamic import() after first paint; prefetch the bundle when the browser is idle; a static code preview until then.
INP (typing, clicking Run)Under 200 msThe editor is uncontrolled: React does not re-render on every keystroke. Draft writes are debounced and IndexedDB is async.
CLSUnder 0.1Images in statements have width and height; the console has a fixed height.
Language pack switchUnder 300 msEach 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.

MinutesWhat to doWhat to draw
0 to 6Scope: languages, hidden tests, contest size, premium content, hiring useNothing yet; write the numbers in a corner
6 to 12Wireframe the workspace and name the partsThe split screen: statement, editor, console with tests and results
12 to 22Where code runs; Run vs SubmitBrowser and server boxes with the line that hidden tests never cross
22 to 37Submission pipeline: 202 with id, idempotency key, states, SSE with polling fallbackThe sequence diagram and the state machine
37 to 48Contests: server clock offset, CDN release, backpressure, leaderboard snapshotsA timeline around start time
48 to 55Drafts, data model, results viewThe draft flow, one TypeScript type
55 to 60Accessibility, metrics, and what you would do nextNothing 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?"

Think first

"The user's network drops for 2 minutes during a contest. What happens?"

Think first

"Contest participation grows to 200,000. What breaks first on the client side?"

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 202 with an id fast; an Idempotency-Key per 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 seq and 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.

Junior 0 of 4
Mid-level 0 of 4

References

Concepts behind it

Try next