Design the LeetCode API
Problem, submission, judge, leaderboard. The fan-out from problem-fetch to async judge to result subscription.
Context#
A competitive-programming judge API is the canonical “async-job over HTTP” shape. The user clicks Submit, the server says “got it”, a sandboxed worker compiles and runs the code against hidden tests, and a few seconds later a verdict appears. The API has to make all of that feel synchronous to a curious user mashing F5, without actually being synchronous on the server.
LeetCode is the well-known instance. The interviewer’s hidden objectives:
- Can you separate the catalogue API (problems, hints, descriptions — read-heavy, cacheable) from the judge API (submissions, verdicts — write + async)?
- Can you model an async job correctly — return
202 Acceptedwith asubmission_id, expose status via polling and push? - Can you draw the submission state machine without inventing states that don’t exist (e.g., the verdict is final; there is no “Re-Judging” state in the public contract)?
- Can you defend why the judge isn’t part of the API surface? Sandbox internals (Docker, gVisor, Firecracker, custom seccomp) are an implementation detail.
- Can you handle the leaderboard / contest side without claiming to write Elo in the room?
Scope cuts to declare upfront: the sandbox/judge implementation, the discuss section, premium-only filtering. We design the contract; the worker is a black box.
Requirements (functional and non-functional)#
Functional — in scope:
- List problems with filters (difficulty, topic, paid/free, status — solved/attempted/none).
- Fetch a single problem’s full description, examples, constraints, hints.
- Submit a solution: code + language + problem reference. Returns a submission handle immediately.
- Poll a submission’s status, or subscribe to a stream that pushes the verdict when it lands.
- View a user’s submission history.
- View a problem’s leaderboard (runtime / memory percentiles) and a contest’s scoreboard.
Functional — out of scope:
- The sandbox itself (Docker / gVisor / seccomp / language runtime versions — implementation, not contract).
- The discuss section (forum, comments, solutions — a separate API).
- Premium-only filtering (an authorization concern; not part of the core contract).
- The plagiarism / similarity detection pipeline (offline, not request-path).
Non-functional:
- Catalogue read latency:
<= 200 ms p95forGET /problemsandGET /problems/{slug}. - Submission acknowledgement:
<= 100 ms p95forPOST /submissions(returns202before the judge runs). - Verdict freshness:
<= 10 s p95from submission to terminal state. Push delivery within 1 s of terminal state. - Availability: 99.9% on the read path; 99.95% on the submission path (a flaky submit endpoint loses contest users instantly).
- Throughput: 5k QPS sustained reads, 500 submissions/s steady-state, 20k submissions/s during a global weekly contest.
Use case diagram#
┌──────────────────┐ │ End user │ └─────────┬────────┘ │ ┌─────────────────────┼─────────────────────┐ │ │ │ │ │ ▼ ▼ ▼ ▼ ▼ [browse] [open] [submit] [watch] [view scoreboard] │ │ │ │ │ ▼ ▼ ▼ ▼ ▼ ┌────────────────────────────────────────────────────┐ │ LeetCode Public API │ └────────────────────┬──────────┬────────────────────┘ │ │ ▼ ▼ ┌──────────────┐ ┌──────────────┐ │ Catalogue │ │ Judge │ … sandbox, hidden │ service │ │ workers │ └──────────────┘ └──────────────┘The judge workers are internal infrastructure. The public surface is the catalogue + submission contract.
Class diagram#
┌──────────────────────┐ ┌──────────────────────┐ │ Problem │ 1 * │ Submission │ ├──────────────────────┤◄───────┤──────────────────────┤ │ slug : string │ │ id : ULID │ │ title : string │ │ user_id : string │ │ difficulty : enum │ │ problem_slug : str │ │ description : md │ │ language : enum │ │ examples : Example[] │ │ code : string │ │ constraints : md │ │ status : enum │ │ topics : string[] │ │ created_at : ts │ │ test_meta : counts │ │ verdict : Verdict? │ └──────────────────────┘ └──────────┬───────────┘ │ ▼ ┌──────────────────────┐ ┌──────────────────────┐ │ User │ │ Verdict │ ├──────────────────────┤ ├──────────────────────┤ │ id : string │ │ status : terminal │ │ handle : string │ │ runtime_ms : int │ │ rating : int │ │ memory_kb : int │ │ solved_count : int │ │ runtime_pct : float │ └──────────────────────┘ │ memory_pct : float │ │ failing_test? : Test │ └──────────────────────┘
┌──────────────────────┐ ┌──────────────────────┐ │ Leaderboard │ │ Contest │ ├──────────────────────┤ ├──────────────────────┤ │ problem_slug : str │ │ id : string │ │ entries : Entry[] │ │ starts_at : ts │ │ generated_at : ts │ │ ends_at : ts │ └──────────────────────┘ │ problems : slug[] │ │ scoreboard : Entry[] │ └──────────────────────┘Submission carries the runtime state. Verdict is the immutable, attached-once-terminal payload — splitting it keeps the Submission shape simple while the verdict is null.
Sequence diagram (key flows)#
The full submission flow, from click to verdict:
Client API Gateway SubmissionAPI Queue JudgeWorker PubSub │ POST /submissions │ │ │ │ │ │──────────────────►│ │ │ │ │ │ auth, rate │ │ │ │ │ │ limit │ │ │ │ │ │ │ persist │ │ │ │ │ │────────────►│ │ │ │ │ │ │ enqueue job │ │ │ │ │ │────────────►│ │ │ │ 202 + id │ │ │ │ │ │◄──────────────────│ │ │ │ │ │ │ │ │ dequeue │ │ │ │ │ │─────────────►│ │ │ │ │ │ │ compile │ │ │ │ │ │ run tests │ │ │ │ │ │ score │ │ │ │ UPDATE │ │ │ │ │ │ verdict │ │ │ │ │ │◄──────────────────────────│ │ │ │ │ publish │ │ │ │ │ │────────────────────────────────────────► │ │ (SSE stream) │ │ │ │ │ │ GET /submissions/{id}/stream │ │ │ │ │──────────────────►│ │ │ │ │ │ │ subscribe │ │ │ │ │ │────────────────────────────────────────────────────────► │ event: status │ │ │ │ │ │◄────────────────────────────────────────────────────────────────────────── │ │ event: verdict │ │ │ │ │ │◄────────────────────────────────────────────────────────────────────────── │Polling (GET /submissions/{id} every 500 ms until status is terminal) is the simpler fallback. SSE is the preferred path on a browser. Both work against the same persisted state — the API doesn’t care which one the client picks.
Activity diagram (for non-trivial state)#
The submission state machine:
[POST /submissions] │ ▼ ┌────────────┐ │ Pending │ (queued, not yet picked up) └─────┬──────┘ │ worker picks up ▼ ┌────────────┐ │ Running │ (compiling / executing) └─────┬──────┘ │ ┌─────────┬───────────┼───────────┬─────────┬─────────┐ ▼ ▼ ▼ ▼ ▼ ▼ ┌─────────┐ ┌─────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────────┐ │Accepted │ │ Wrong │ │ TLE │ │ MLE │ │Runtime │ │ Compile │ │ │ │ Answer │ │ │ │ │ │ Error │ │ Error │ └─────────┘ └─────────┘ └────────┘ └────────┘ └────────┘ └────────────┘ (terminal — no further transitions)Six terminal states; no re-judge transition in the public contract. If an internal re-grade happens (test data correction, sandbox bug fix), it produces a new submission marked regrade_of: <original_id> — keeping each submission immutable from the client’s perspective.
API implementation#
Endpoint catalogue#
| Method | Path | Purpose |
|---|---|---|
GET | /v1/problems | Paginated, filtered list of problems |
GET | /v1/problems/{slug} | Full problem detail |
GET | /v1/problems/{slug}/leaderboard | Per-problem runtime/memory percentiles |
POST | /v1/submissions | Create a submission; returns 202 + id |
GET | /v1/submissions/{id} | Current status; clients poll this |
GET | /v1/submissions/{id}/stream | Server-sent events; pushes verdict when ready |
GET | /v1/users/{handle}/submissions | A user’s submission history (paginated) |
GET | /v1/contests/{id} | Contest metadata + problem list |
GET | /v1/contests/{id}/scoreboard | Live scoreboard during a contest |
Nine endpoints, three resource families (problems, submissions, contests). Idempotency keys mandatory on POST /submissions — a flaky network shouldn’t double-submit the same code.
OpenAPI schema (excerpt)#
paths: /v1/problems: get: operationId: listProblems parameters: - name: difficulty in: query schema: { type: string, enum: [easy, medium, hard] } - name: topic in: query schema: { type: string } - name: status in: query description: User-relative status; requires auth schema: { type: string, enum: [solved, attempted, todo] } - name: cursor in: query schema: { type: string } - name: page_size in: query schema: { type: integer, minimum: 1, maximum: 100, default: 50 } responses: '200': description: Page of problems content: application/json: schema: { $ref: '#/components/schemas/ProblemPage' } /v1/submissions: post: operationId: createSubmission parameters: - name: Idempotency-Key in: header required: true schema: { type: string, format: uuid } requestBody: required: true content: application/json: schema: type: object required: [problem_slug, language, code] properties: problem_slug: { type: string } language: { type: string, enum: [cpp, python, go, java, js, rust] } code: { type: string, maxLength: 65536 } responses: '202': description: Accepted; judge pending content: application/json: schema: type: object required: [id, status, status_url] properties: id: { type: string } status: { type: string, enum: [pending] } status_url: { type: string, format: uri } stream_url: { type: string, format: uri } /v1/submissions/{id}: get: operationId: getSubmission parameters: - name: id in: path required: true schema: { type: string } responses: '200': description: Current submission state content: application/json: schema: { $ref: '#/components/schemas/Submission' } '404': { description: Unknown submission }components: schemas: ProblemPage: type: object required: [items] properties: items: type: array items: { $ref: '#/components/schemas/ProblemSummary' } next_cursor: { type: string, nullable: true } ProblemSummary: type: object required: [slug, title, difficulty] properties: slug: { type: string } title: { type: string } difficulty: { type: string, enum: [easy, medium, hard] } topics: { type: array, items: { type: string } } acceptance_rate: { type: number, format: float } user_status: type: string enum: [solved, attempted, todo, unknown] Submission: type: object required: [id, status, problem_slug, language, created_at] properties: id: { type: string } status: type: string enum: [pending, running, accepted, wrong_answer, tle, mle, runtime_error, compile_error] problem_slug: { type: string } language: { type: string } created_at: { type: string, format: date-time } verdict: type: object nullable: true properties: runtime_ms: { type: integer } memory_kb: { type: integer } runtime_percentile: { type: number, format: float } memory_percentile: { type: number, format: float } failing_test: type: object nullable: true properties: input: { type: string } expected: { type: string } got: { type: string }Wire-level: SSE stream framing#
The push channel uses Server-Sent Events. A typical stream for an Accepted submission:
GET /v1/submissions/01HF8M3K7QJWN.../stream HTTP/2Accept: text/event-streamAuthorization: Bearer eyJhbGciOi...
HTTP/2 200 OKContent-Type: text/event-streamCache-Control: no-cache
event: statusdata: {"id":"01HF8M3K7QJWN...","status":"running"}
event: statusdata: {"id":"01HF8M3K7QJWN...","status":"running","tests_passed":12,"tests_total":40}
event: verdictdata: {"id":"01HF8M3K7QJWN...","status":"accepted","verdict":{"runtime_ms":48,"memory_kb":15700,"runtime_percentile":92.4,"memory_percentile":71.0}}
: end-of-streamThe trailing : line is an SSE comment — keeps the connection open for one more frame so the client can cleanly process the verdict before the server closes. The event: field discriminates progress updates from the terminal payload.
Client samples — three languages#
Submit, then watch the stream until terminal.
import uuid, requests, json
API = "https://api.leetcode.example.com/v1"TOKEN = "eyJhbGciOi..."
def submit_and_wait(slug, language, code): headers = {"Authorization": f"Bearer {TOKEN}"} body = {"problem_slug": slug, "language": language, "code": code} ack = requests.post( f"{API}/submissions", json=body, headers={**headers, "Idempotency-Key": str(uuid.uuid4())}, timeout=5, ) ack.raise_for_status() sub = ack.json() print("acknowledged", sub["id"])
# SSE stream with requests.get(sub["stream_url"], headers=headers, stream=True, timeout=30) as resp: resp.raise_for_status() for line in resp.iter_lines(decode_unicode=True): if not line or line.startswith(":"): continue if line.startswith("event: verdict"): # next non-empty line is the data: payload data_line = next(resp.iter_lines(decode_unicode=True)) payload = json.loads(data_line.removeprefix("data: ")) return payload
verdict = submit_and_wait("two-sum", "python", "class Solution:\n def twoSum(self, nums, target):\n ...")print(verdict["status"], verdict.get("verdict", {}).get("runtime_ms"), "ms")package main
import ( "bufio" "bytes" "encoding/json" "fmt" "net/http" "strings"
"github.com/google/uuid")
const API = "https://api.leetcode.example.com/v1"const Token = "eyJhbGciOi..."
type Ack struct { ID string `json:"id"` StreamURL string `json:"stream_url"`}
func submitAndWait(slug, lang, code string) (map[string]any, error) { body, _ := json.Marshal(map[string]string{ "problem_slug": slug, "language": lang, "code": code, }) req, _ := http.NewRequest("POST", API+"/submissions", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+Token) req.Header.Set("Idempotency-Key", uuid.NewString()) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var ack Ack if err := json.NewDecoder(resp.Body).Decode(&ack); err != nil { return nil, err } fmt.Println("acknowledged", ack.ID)
sreq, _ := http.NewRequest("GET", ack.StreamURL, nil) sreq.Header.Set("Authorization", "Bearer "+Token) sresp, err := http.DefaultClient.Do(sreq) if err != nil { return nil, err } defer sresp.Body.Close()
sc := bufio.NewScanner(sresp.Body) var event string for sc.Scan() { line := sc.Text() switch { case strings.HasPrefix(line, "event: "): event = strings.TrimPrefix(line, "event: ") case strings.HasPrefix(line, "data: ") && event == "verdict": var payload map[string]any json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &payload) return payload, nil } } return nil, fmt.Errorf("stream ended without verdict")}import { randomUUID } from "node:crypto";
const API = "https://api.leetcode.example.com/v1";const TOKEN = "eyJhbGciOi...";
export async function submitAndWait(slug, language, code) { const ack = await fetch(`${API}/submissions`, { method: "POST", headers: { Authorization: `Bearer ${TOKEN}`, "Idempotency-Key": randomUUID(), "Content-Type": "application/json", }, body: JSON.stringify({ problem_slug: slug, language, code }), }).then((r) => r.json()); console.log("acknowledged", ack.id);
const stream = await fetch(ack.stream_url, { headers: { Authorization: `Bearer ${TOKEN}`, Accept: "text/event-stream" }, }); const reader = stream.body.getReader(); const decoder = new TextDecoder(); let buf = "", event = ""; while (true) { const { value, done } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); const lines = buf.split("\n"); buf = lines.pop() ?? ""; for (const line of lines) { if (line.startsWith("event: ")) event = line.slice(7); else if (line.startsWith("data: ") && event === "verdict") { return JSON.parse(line.slice(6)); } } }}Latency budget#
The two budgets that matter most:
| Phase | Budget | Notes |
|---|---|---|
POST /submissions ack | 100 ms p95 | DB write + queue enqueue; the user is waiting. |
| Pending → terminal | 10 s p95 | Includes queue wait under contest load. |
| Stream verdict push | 1 s p95 from terminal | Pub-sub fan-out cost. |
GET /problems/{slug} | 200 ms p95 | Heavily cached, mostly CDN-served. |
Under contest spikes (20k submissions/s), the queue can back up. The contract still says <= 10 s p95 — we meet it by auto-scaling worker pools, not by changing the contract.
Trade-offs and extensions#
| Decision | Why | Cost if requirements change |
|---|---|---|
202 + async judge, not blocking POST | Verdicts can take seconds; long-held HTTPS is brittle | Clients must poll or subscribe |
| Idempotency-Key mandatory | Network retry must not double-submit | Adds one header to every submit |
| Stream + poll both supported | Mobile and browser have different constraints | Two transports to test |
| Submissions are immutable | Re-judge produces a new id | A user history filtered to “final verdict per problem” needs a join |
Verdict carries failing-test (for wrong_answer) | Without it, the answer is unactionable | Reveals one hidden test per failure; acceptable per the LeetCode product |
| Leaderboard is a snapshot, not live | Live percentiles need a streaming join | Acceptable: percentiles change slowly per problem |
Likely follow-up extensions and the shape of the answer:
- Premium-only problems. A
tierfield onProblem; the catalogue still lists them (titles, difficulty) butGET /problems/{slug}returns402 Payment Requiredfor the description if the user isn’t subscribed. - Hints API. Hints are part of
Problemtoday but could move to a separateGET /problems/{slug}/hints?index=0endpoint to gate the reveal behaviour client-side. - Bulk submission for batch contests. Some users sweep through a contest in a script. Add
POST /submissions:batchwith up to 10 submissions in one call, returning 10 acknowledgements. - Solution analytics. A separate
GET /submissions/{id}/analyticsreturning per-language percentiles, similar submissions, complexity breakdown. Premium-tier. - Real-time scoreboard.
WS /contests/{id}/scoreboardpushing rank changes. Apache Kafka behind it for the ordered event stream.
Mock interview follow-ups#
- “What happens if the judge worker crashes mid-execution?” — The worker doesn’t ack the queue message until it has persisted a terminal state. If it crashes, the message redelivers after the visibility timeout and another worker picks it up. The submission stays in
runningfrom the user’s perspective. - “How do you prevent abuse — 1000 submissions per second from one user?” — Per-user token-bucket rate limit (e.g., 1 submission per 5 seconds outside contests, 10 per minute during). Return
429withRetry-After. The judge fleet is finite; rate limiting is the only sane backpressure. - “What about a ‘rejudge all submissions for this problem’ admin action?” — Out of the public API. Internal admin tool enqueues new submissions tagged
regrade_of: <id>and links them in the user’s history. - “Why not WebSockets instead of SSE?” — SSE is one-way (server-to-client), which is exactly what the verdict push needs. WebSockets buy nothing here and cost firewall and proxy compatibility. Reserve WebSockets for the real-time scoreboard, which is many-to-many.
- “How do you handle ties in the leaderboard?” — By
(runtime_ms ASC, submitted_at ASC). The percentile field is computed offline, refreshed every hour, served from cache. - “What if a contest problem has a bug and needs a test added mid-contest?” — Re-grade is an offline batch job. Submissions that flip from
acceptedtowrong_answerget an email and an updated scoreboard at the end of the contest, not mid-run. Public contract: no in-flight rejudge. - “How does the API handle code submissions with arbitrarily long execution?” — Worker enforces a per-language CPU and wall-clock cap. Verdict is
tleif exceeded. The API never returnsrunningindefinitely — there’s a hard 30 s ceiling from the worker side.
Related#
- Design a Search Service API — the read-API foundation;
GET /problemsis a faceted search. - Design a Pub-Sub Service API — the verdict fan-out uses this shape internally.
- WebSockets — Bidirectional Streaming — the alternative push transport; here we picked SSE.
- The API-Design Walk-through — the seven-step recipe this writeup followed.
- REST — The Architectural Style — the architectural style behind the endpoint shape.