Design the LeetCode API

Problem, submission, judge, leaderboard. The fan-out from problem-fetch to async judge to result subscription.

System Intermediate
15 min read
api-design leetcode async-judge
Companies this resembles: LeetCode

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 Accepted with a submission_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 p95 for GET /problems and GET /problems/{slug}.
  • Submission acknowledgement: <= 100 ms p95 for POST /submissions (returns 202 before the judge runs).
  • Verdict freshness: <= 10 s p95 from 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#

MethodPathPurpose
GET/v1/problemsPaginated, filtered list of problems
GET/v1/problems/{slug}Full problem detail
GET/v1/problems/{slug}/leaderboardPer-problem runtime/memory percentiles
POST/v1/submissionsCreate a submission; returns 202 + id
GET/v1/submissions/{id}Current status; clients poll this
GET/v1/submissions/{id}/streamServer-sent events; pushes verdict when ready
GET/v1/users/{handle}/submissionsA user’s submission history (paginated)
GET/v1/contests/{id}Contest metadata + problem list
GET/v1/contests/{id}/scoreboardLive 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)#

OpenAPI 3.1 — LeetCode API
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/2
Accept: text/event-stream
Authorization: Bearer eyJhbGciOi...
HTTP/2 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
event: status
data: {"id":"01HF8M3K7QJWN...","status":"running"}
event: status
data: {"id":"01HF8M3K7QJWN...","status":"running","tests_passed":12,"tests_total":40}
event: verdict
data: {"id":"01HF8M3K7QJWN...","status":"accepted","verdict":{"runtime_ms":48,"memory_kb":15700,"runtime_percentile":92.4,"memory_percentile":71.0}}
: end-of-stream

The 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.

Submit and stream verdict — Python
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")

Latency budget#

The two budgets that matter most:

PhaseBudgetNotes
POST /submissions ack100 ms p95DB write + queue enqueue; the user is waiting.
Pending → terminal10 s p95Includes queue wait under contest load.
Stream verdict push1 s p95 from terminalPub-sub fan-out cost.
GET /problems/{slug}200 ms p95Heavily 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#

DecisionWhyCost if requirements change
202 + async judge, not blocking POSTVerdicts can take seconds; long-held HTTPS is brittleClients must poll or subscribe
Idempotency-Key mandatoryNetwork retry must not double-submitAdds one header to every submit
Stream + poll both supportedMobile and browser have different constraintsTwo transports to test
Submissions are immutableRe-judge produces a new idA user history filtered to “final verdict per problem” needs a join
Verdict carries failing-test (for wrong_answer)Without it, the answer is unactionableReveals one hidden test per failure; acceptable per the LeetCode product
Leaderboard is a snapshot, not liveLive percentiles need a streaming joinAcceptable: percentiles change slowly per problem

Likely follow-up extensions and the shape of the answer:

  • Premium-only problems. A tier field on Problem; the catalogue still lists them (titles, difficulty) but GET /problems/{slug} returns 402 Payment Required for the description if the user isn’t subscribed.
  • Hints API. Hints are part of Problem today but could move to a separate GET /problems/{slug}/hints?index=0 endpoint to gate the reveal behaviour client-side.
  • Bulk submission for batch contests. Some users sweep through a contest in a script. Add POST /submissions:batch with up to 10 submissions in one call, returning 10 acknowledgements.
  • Solution analytics. A separate GET /submissions/{id}/analytics returning per-language percentiles, similar submissions, complexity breakdown. Premium-tier.
  • Real-time scoreboard. WS /contests/{id}/scoreboard pushing 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 running from 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 429 with Retry-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 accepted to wrong_answer get 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 tle if exceeded. The API never returns running indefinitely — there’s a hard 30 s ceiling from the worker side.
Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.