Step 2 of 10 · 30 min

Client, API and Load Balancing

One server is a limit and a single point of failure. Stateless servers plus a balancer let you add capacity by adding machines.

✓ Explain the request path from DNS to the database✓ Choose an LB algorithm and a health-check strategy✓ Design REST endpoints with correct status codes, cursor pagination and idempotency
Say it in 30 seconds
  • 3 down, 2 uphealth-check failures and successes to flip state
  • 2 random pickspower of two choices samples, takes the less loaded
  • 30 spassive ejection after 5 consecutive 5xx
Show what to say

“I make the services stateless, with sessions in Redis and files in object storage, and put a load balancer in front, so adding capacity means adding a machine and a crash loses nothing. For uneven requests I use least connections or power of two choices, with health checks that need three failures to mark an instance down so a blip can't flap it.”

Trade-offs

  • Stateless vs sticky sessions: sticky pins warm caches and WebSockets, but deploys, crashes and autoscaling break the pin, so state goes in Redis.
  • L4 vs L7: L4 forwards fast at very low cost, while L7 reads URLs and headers for routing and retries at more CPU per request.
  • Round robin vs least connections: round robin is simple but keeps feeding a slow server; least connections needs an accurate count per server.

Bring it up before they ask

Failing readiness on an optional dependency like recommendations takes the whole fleet out at once, so only fail it for what the instance truly needs.

New to the words? Read the one-minute primer

0 of 0 sections ticked
30-second answer

Make services and put a in front: capacity becomes "add a machine", and a crash loses nothing. Then give the API correct status codes, and .

Why it matters

One server is a ceiling and a .

Read more · 200 words

servers behind a balancer make crashes and spikes routine: the balancer routes around a dead instance. The API on top is every client's contract, so status codes and safe retries are design, not polish.

Do you need a balancer yet? At 200 req/s one server has room, but a second behind a balancer means deploys and crashes cost nothing. At 5,000 req/s peak you need it for capacity too.

Each hop before your code adds one thing:

HopWhat it adds
DNS and CDNThe nearest region; static files from the edge
Load balancerSpreads requests over healthy instances
API gatewayAuth, rate limits, routing to the right service
Services and dataStateless logic; state lives in databases, caches and queues

Play Happy path to follow one request, then Sticky-session pitfall, Fix: shared session store and Instance failure for the stories the next sections explain.

This page follows the order you would say it in an interview: where state lives, how requests are spread, what goes wrong when one server misbehaves, and how the API survives retries.

Press Play to watch it run · 6 steps

Requests enter through the API gateway, the load balancer spreads them over identical stateless instances, and every instance shares one database and session store.

Request pathRequests enter through the API gateway, the load balancer spreads them over identical stateless instances, and every instance shares one database and session store.sessionClientbrowser / appAPI gatewayauth, rate limitsLoad balancerL7, health checksService AService BService CShared session store: any instance can read any user's sessionRedis sessions~1 msDatabase

Tap any box to see what it is and what it talks to.

  • Client
  • API gateway
  • Load balancer
  • Service
  • Cache
  • Database
Open in playground
Text description
  1. Requests arrive at the API gateway, the single front door.
  2. The gateway checks the token and rate limit, then hands each request to the load balancer.
  3. The load balancer spreads requests over the healthy instances: one to A, the next to B, the next to C.
  4. The instances are identical and stateless: each reads what it needs from the shared database.
  5. Responses flow back. Need more capacity? Add another instance behind the load balancer.
  6. The client never knows which instance served it.

Stateless services

doesn't mean no state.

Read more · 283 words

It means no instance owns any, so any instance can serve any request, and any can die.

StateWhere it goes instead
Sessions, loginRedis with a TTL, or a signed token
Uploaded filesObject storage (S3) behind the CDN
Counters, locksRedis
Business dataThe database

Long jobs go to a queue. The hardest piece to move is the login session, and there are three places for it:

OptionHow it worksUse it forWatch out for
Sticky sessionsThe balancer sends a user to the same instance every time, so it keeps the session in its own memoryA stopgap, or an instance that really holds a connection (a WebSocket)Every deploy, crash and scale-in logs out everyone pinned to that instance. A hot user group cannot be rebalanced, because the pin overrides the algorithm
Shared session store (the default)The session lives in Redis, keyed by a random id in a cookie. Any instance serves any userLogged-in web apps. Lookup is about 1 ms; 1 million active sessions at about 1 KB each is about 1 GB of RedisOne more dependency on the hot path: if Redis is down, nobody can log in, so run it replicated
Signed tokenThe session sits inside the token the client carries; the server verifies the signature with a shared key, in microseconds, with no network callAPIs called by apps and other services, where a lookup per request is unwelcomeRevocation: a token stays valid until it expires. Keep lifetimes short (15 minutes) with a refresh token, or keep a small deny list in Redis for forced logouts

Play Pinned, then B restarts to see why sticky is the weakest.

Press Play to watch it run · 4 steps

A cookie pins Alice to Service B, which keeps her session in memory. It works until B restarts, crashes or is scaled in.

Sticky sessions pin a user to one instanceA cookie pins Alice to Service B, which keeps her session in memory. It works until B restarts, crashes or is scaled in.pinnedAliceLoad balancercookie affinityService AService Bher session in RAM

Tap any box to see what it is and what it talks to.

  • Client
  • Load balancer
  • Service
Open in playground
Text description
  1. Alice logs in. The balancer picks Service B and sets an affinity cookie.
  2. B stores her session in its own memory.
  3. Every later request carries the cookie…
  4. …so it lands on B again. Alice sees nothing wrong. Service A stays idle for her.

L4 vs L7 and balancing algorithms

L4 sees connections; L7 reads requests. Most stacks use both: L4 spreads connections over L7 proxies.

Read more · 738 words

Pick by what you need to see.

L4 (TCP/UDP)L7 (HTTP)
SeesIP and portURL, headers, cookies
Can doFast forwarding, any protocol; picks a server once per TCP connectionPath routing, TLS termination, retries, canaries; decides per request
CostVery lowMore CPU per request (it parses every request and holds the TLS keys)
Use it forRaw TCP or UDP, databases, game traffic, the outer tier in front of a proxy fleetThe default for HTTP services
ExamplesAWS NLB, IPVS, MaglevAWS ALB, NGINX, Envoy, HAProxy (HTTP mode)
  • The long-connection trap: because L4 chooses per connection, one or connection that carries 1,000 requests a second sends all 1,000 to one server.
  • : with L7, a canary at 5% means 1 request in 20 reaches the new version, so a bad release hurts 5% of users for ten minutes instead of all of them.
  • Both together: when you outgrow one proxy, a pair of L4 balancers gives clients one stable IP and spreads connections over a fleet of L7 proxies. Clients never notice when you add or remove a proxy. On a managed cloud, a single L7 balancer (an ALB) is enough until traffic or protocol needs say otherwise. Start with one box and name the reason before adding a second tier.

Which healthy instance gets the next request? Round robin is simple, until one server is slow: it stays on the rotation, its queue grows, timeouts trigger retries, and the retries bury it.

AlgorithmHow it worksUse it forWatch out for
Round robin (default)Hands out requests in turn: A, B, C, A, B, C. No state, perfectly even when servers and requests are alikeStateless services with uniform calls (5 ms lookups on identical machines)Ignores load: a slow server keeps a third of the traffic
Weighted round robinEach server gets a share by weight: a 16-core machine with weight 5 beside two 4-core machines with weight 1. Weights 5, 1, 1 give a a b a c a a, not five a in a row, because the smooth variant spreads the turnsA migration between instance types, or a few much bigger machinesStill ignores live load; it only fixes a size mismatch
Least connectionsSends each request to the server with the fewest requests in flight, so slow servers and long requests balance themselvesMixed costs: a 5-second CSV export beside 5 ms lookupsNeeds an accurate count, which one balancer has but a fleet of balancers does not share. A new instance starts at zero and gets flooded (see failure modes)
Power of two choices (P2C)Picks two servers at random and takes the less loaded. Almost as good as least connections, with no shared state and no scan of the fleet. Random assignment alone leaves the busiest server far above average; comparing two picks removes most of the gapMany proxies or balancers that each see part of the traffic (Envoy, a service mesh)Slightly coarser balance than a global count
HashingPicks the server from a key such as user id or client IP, so the same key reaches the same server and per-key state (local caches, shards, WebSocket sessions) stays warmPer-key affinity, like cache serversA hot key: one celebrity user overloads their server while the others idle

Use , not hash mod N. Going from 4 nodes to 3 with plain mod moves 75% of keys, because only keys where k mod 4 equals k mod 3 stay put. Consistent hashing moves only the 25% that lived on the removed node. See the sharding strategies deep dive.

Not sure which fits? Walk the tree. Then, in the simulator, make server C slow under round robin and watch its queue grow, then switch to P2C.

One box is missing from the request path. Put it back.

Press Play to watch it run · 3 steps

A cheap L4 balancer spreads connections over a fleet of L7 proxies, and each proxy routes requests to services. Add proxies without clients noticing.

L4 in front of L7A cheap L4 balancer spreads connections over a fleet of L7 proxies, and each proxy routes requests to services. Add proxies without clients noticing.ClientL4 balancerone stable IPProxy 1 (L7)Proxy 2 (L7)Services

Tap any box to see what it is and what it talks to.

  • Client
  • Load balancer
  • API gateway
  • Service
Open in playground
Text description
  1. Clients know one address. The L4 balancer is cheap, so one pair handles huge connection counts.
  2. It spreads connections over the L7 proxies.
  3. Each proxy reads requests and routes them to the right service, with retries.
Which balancing algorithm?yesnoyesyesyesnononoConsistent hashRound robinMust a key stick to oneserver?Weighted round robinPower of two choicesLeast connectionsMany balancers share onefleet?Only the server sizesdiffer?Same-size servers,similar requests?

Same-size servers, similar requests?

Interactive

Load balancer simulator

Requests take 0.3–0.8 s. Make server C slow, then compare round robin with least-connections and power-of-two-choices.

0Server Aserved 00Server Bserved 00Server Cserved 00Server Dserved 0

p95 latency: 0.00 s · in flight: 0 · dropped (no healthy server): 0

// Round robin that skips unhealthy servers (a health checker flips `up`)
export const servers = [{ url: 'a:3000', up: true }, { url: 'b:3000', up: true }, { url: 'c:3000', up: false }];
let i = 0;

export function pick() {
  for (let n = 0; n < servers.length; n++) {
    const s = servers[i++ % servers.length];
    if (s.up) return s.url;       // skip unhealthy nodes
  }
  throw new Error('no healthy servers');
}
// pick() × 4 → 'a:3000', 'b:3000', 'a:3000', 'b:3000' (c is down)
Interactive

Fill in the missing component

One box in this diagram is empty. Drag the component that belongs there, or press its Place button.

Request pathRequests enter through the API gateway, the load balancer spreads them over identical stateless instances, and every instance shares one database and session store.sessionClientbrowser / appAPI gatewayauth, rate limitsMissing component: drag one here?Service AService BService CShared session store: any instance can read any user's sessionRedis sessions~1 msDatabase

Tap any box to see what it is and what it talks to.

Requests enter through the API gateway, the load balancer spreads them over identical stateless instances, and every instance shares one database and session store.

  • Client
  • API gateway
  • Service
  • Cache
  • Database
Open in playground

Pick the component that belongs in the empty box.

Health checks and failure modes

The balancer can only route around failures it can see. Four mechanisms cover it, and each answers a different question.

Read more · 937 words
MechanismHow it worksWatch out for
Active checks (the baseline)The balancer probes each instance on a schedule, usually /readyz every few seconds. 3 failures mark it down, 2 successes mark it up, so a blip cannot flap itDetection time: with a 5 s interval and 3 failures, a dead instance takes about 15 s to be noticed. At 900 req/s over three instances that is 300 req/s sent to a corpse for 15 s, about 4,500 failed requests, unless the proxy retries them
Passive checksWatch real traffic: an instance that returns 5 consecutive 5xx is ejected for 30 seconds. Catches an instance that passes /readyz but fails real requestsUse next to active checks, never instead. They only learn from real users failing, so pair them with a retry on another instance for idempotent requests
Liveness vs readinessLiveness asks "is it alive?" (fail: restart the process). Readiness asks "should it get traffic?" (fail: stop routing, leave it running)Keep readiness narrow: check only what this instance cannot work without, such as its own database connection. If /readyz also calls an optional recommendations service, then when it goes down every instance fails at once and the whole site returns 503 over a carousel. Stay ready, drop the feature, and put a short timeout and a circuit breaker on the call
Connection drainingOn SIGTERM the instance fails readiness, the balancer stops sending new requests, and in-flight ones completeWithout it, every deploy cuts requests in half. Set the grace period above your slowest normal request, for example 30 s. Cloud balancers call this deregistration delay; AWS target groups default to 300 s

Most outages are not the balancer dying, they are a small fault that the balancer spreads. Play The spiral, then The fix.

The slow-server spiral. 13:00, a sale starts at 900 req/s, and server C exhausts its database pool. Its calls go from 20 ms to 2 s. Round robin still sends C 300 req/s, and by (in flight = rate times ) that is about 600 requests in flight on C instead of about 6. Clients time out at 1 s and retry, which pushes more load onto A and B. Fix it in three layers: least connections or P2C so a slow server gets less, outlier ejection so a failing one gets none, and timeouts plus a retry budget so clients do not multiply the load.

FailureWhat happensFix
Retry stormRetries add load to something already failing. Three retries per failed call turn 1,000 req/s into 4,000 req/s on a server that could not handle 1,000Exponential backoff with jitter; a retry budget (retries add at most about 10% extra requests); a circuit breaker. Retry only idempotent requests, on a different instance than the one that just failed
A new instance gets floodedThe autoscaler adds one during a spike, it reports ready with 0 open requests, and least connections sends it nearly everything while its caches, connection pools and runtime are coldSlow start: ramp the instance from 10% to full weight over 30 to 60 s, and report ready only after a warm-up call. Round robin and P2C hit this far less
The balancer is a single point of failureOne balancer is one more machine that can fail, and everything sits behind itRun at least two, in different zones. DNS with health checks can drop a dead address, but clients that cached it keep trying until the TTL (60 s here) runs out. Managed balancers (ALB, NLB) already spread across zones. On your own hardware, use a floating IP shared by a pair, or anycast

Compare no retries, fixed retries and with full in the simulator.

Sizing the fleet. Size for the peak, not the average, and run well below full. A server at 90% busy has almost no room for a spike and answers slowly even before it fails. In the simplest queueing model (random arrivals, one queue), average response time is the service time divided by one minus utilisation, so running hot is steep.

Worked example: peak 5,000 req/s, and one instance does 500 req/s flat out.

StepArithmeticResult
Plan for 60% busy500 x 0.6 = 300 req/s each5,000 / 300 = 17 instances
Lose one of 3 zones2 zones must carry the peak7 per zone, 21 total
Check the survivors5,000 / 14 = 357 req/s71% busy, still fine
In flight per instance300 req/s x 20 ms6 requests

Keep headroom instead of trusting for the first spike: a new instance needs a minute or two to boot and warm up. Use autoscaling for the slow growth, and headroom for the sudden jump.

health.js
// Liveness vs readiness, plus draining on deploy
let shuttingDown = false;
app.get('/livez', (_req, res) => res.sendStatus(200));                   // the process is alive
app.get('/readyz', async (_req, res) => {                                // should the LB send traffic here?
  if (shuttingDown) return res.sendStatus(503);
  try { await pool.query('SELECT 1'); res.sendStatus(200); }
  catch { res.sendStatus(503); }
});
process.on('SIGTERM', () => {
  shuttingDown = true;                                                   // readiness fails, so the LB stops routing here
  setTimeout(() => server.close(() => process.exit(0)), 10_000);         // finish in-flight requests, then exit
});

Press Play to watch it run · 4 steps

One slow server causes timeouts, the timeouts cause retries, and the retries make every server slower. A small fault becomes a fleet-wide outage.

The slow-server spiralOne slow server causes timeouts, the timeouts cause retries, and the retries make every server slower. A small fault becomes a fleet-wide outage.retryClientstimeout 1 sRound robinServer AServer BServer Cslow

Tap any box to see what it is and what it talks to.

  • Client
  • Load balancer
  • Service
Open in playground
Text description
  1. 13:00, a sale starts at 900 requests a second. C's database pool is exhausted: its calls go from 20 ms to 2 s.
  2. C still gets 300 requests a second. At 2 s each, about 600 are in flight on C against about 6 normally.
  3. Clients time out at 1 s and retry, so each failed call adds a new one. Load climbs well past 900.
  4. The retries land on A and B too, now running hotter than they can sustain.
Interactive

Retry storm

300 clients fail at once; the server is back at 10 s and answers 20 requests per 100 ms.

Retry policy
Peak after recovery
165 / 100 ms
full jitter: 12
Everyone served by
not by 30 s
280 failing · full jitter: 17.5 s
Requests sent
1,790
6.0 per client
Requests arriving at the server per 100 ms (line: recovery at 10 s)
  • Exponential
  • Full jitter
050100150200req07.51522.429.9Time (s)ExponentialFull jitter
Show data table
Requests arriving at the server per 100 ms (line: recovery at 10 s)
Time (s)Exponential (req)Full jitter (req)
0135143
0.1165184
0.2028
0.3029
0.4037
0.5033
0.6034
0.7048
0.8049
0.9044
113533
1.116528
1.2012
1.3017
1.4015
1.5018
1.6018
1.7018
1.8024
1.9015
2030
2.1025
2.2023
2.3019
2.4016
2.5017
2.6017
2.709
2.8013
2.9012
313512
3.11658
3.2022
3.303
3.4017
3.5010
3.604
3.709
3.8011
3.9012
406
4.1015
4.2010
4.3012
4.409
4.509
4.609
4.7015
4.8011
4.907
5014
5.109
5.2010
5.3011
5.406
5.505
5.6010
5.706
5.805
5.907
608
6.108
6.2012
6.308
6.404
6.505
6.604
6.708
6.806
6.904
71355
7.11652
7.2011
7.306
7.4011
7.504
7.609
7.706
7.808
7.9010
8013
8.105
8.207
8.3011
8.404
8.5010
8.6012
8.707
8.808
8.908
9010
9.109
9.203
9.304
9.407
9.505
9.609
9.707
9.8011
9.909
1004
10.106
10.207
10.3011
10.4012
10.509
10.607
10.708
10.806
10.906
11011
11.109
11.207
11.309
11.405
11.505
11.608
11.707
11.805
11.903
1208
12.105
12.203
12.305
12.406
12.505
12.606
12.705
12.803
12.907
1304
13.105
13.201
13.303
13.402
13.504
13.605
13.701
13.806
13.902
1402
14.103
14.202
14.302
14.404
14.503
14.604
14.703
14.802
14.903
151352
15.11658
15.203
15.300
15.404
15.503
15.603
15.700
15.800
15.901
1602
16.100
16.203
16.300
16.401
16.503
16.600
16.701
16.803
16.901
1702
17.100
17.200
17.300
17.400
17.501
17.600
17.700
17.800
17.900
1800
18.100
18.200
18.300
18.400
18.500
18.600
18.700
18.800
18.900
1900
19.100
19.200
19.300
19.400
19.500
19.600
19.700
19.800
19.900
2000
20.100
20.200
20.300
20.400
20.500
20.600
20.700
20.800
20.900
2100
21.100
21.200
21.300
21.400
21.500
21.600
21.700
21.800
21.900
2200
22.100
22.200
22.300
22.400
22.500
22.600
22.700
22.800
22.900
231300
23.11600
23.200
23.300
23.400
23.500
23.600
23.700
23.800
23.900
2400
24.100
24.200
24.300
24.400
24.500
24.600
24.700
24.800
24.900
2500
25.100
25.200
25.300
25.400
25.500
25.600
25.700
25.800
25.900
2600
26.100
26.200
26.300
26.400
26.500
26.600
26.700
26.800
26.900
2700
27.100
27.200
27.300
27.400
27.500
27.600
27.700
27.800
27.900
2800
28.100
28.200
28.300
28.400
28.500
28.600
28.700
28.800
28.900
2900
29.100
29.200
29.300
29.400
29.500
29.600
29.700
29.800
29.900

Exponential: the retries stay synchronised, so the recovering server's worst 100 ms saw 165 requests (it can answer 20) against 12 with full jitter. At 30 s, 280 clients were still failing; with jitter everyone was served by 17.5 s.

How the model works

Exponential delays are 1, 2, 4, 8 s, then 8 s (capped); full jitter waits a random time between 0 and that. Past its capacity the server wastes time on rejections, so it answers fewer. Each run uses a seeded random stream: reshuffle for another.

Average response time by utilisation
05101520x service time30%50%60%70%80%90%95%Utilisation
Show data table
Average response time by utilisation
UtilisationResponse time (x service time)
30%1.43
50%2
60%2.5
70%3.33
80%5
90%10
95%20

Quick checkRound robin keeps feeding one slow server. What do clients do?

REST API design and safe retries

Be predictable: plural nouns for resources, HTTP for verbs, one level of nesting.

Read more · 804 words

Clients guess your API correctly when it follows the rules they already know.

Resources and verbs. Model nouns as resources and use the HTTP method as the verb. GET /orders lists, GET /orders/123 reads one, POST creates, PUT replaces, PATCH changes some fields, and DELETE deletes. Other actions are sub-resources: POST /orders/123/cancel. Do not put verbs in paths like /getOrders. Keep nesting to one level (/users/7/orders); deeper trees force clients to know every parent id.

Status codes tell clients and proxies what happened, so pick them by asking the questions in order. Walk the tree, then use the table as a reference.

CodeUse it for
200 OKSuccess with a body: reads, updates returning the resource
201 CreatedCreated by a POST; add a Location header
202 AcceptedQueued, not done: return a job id or status URL
204 No ContentSuccess, no body: DELETE, or PUT with nothing to return
301 / 302Moved permanently / temporarily; browsers cache 301
304 Not ModifiedClient's copy still valid (conditional GET, If-None-Match)
400 Bad RequestMalformed: invalid JSON, a missing field
401 UnauthorizedNot authenticated: missing or expired token
403 ForbiddenAuthenticated, not allowed: Alice reading Bob's orders
404 Not FoundNo such resource (or hiding that one exists)
409 ConflictClashes with current state: duplicate, version mismatch, key in progress
422 Unprocessable ContentWell-formed but invalid: end date before start date
429 Too Many RequestsRate limited; always add Retry-After
500 Internal Server ErrorBug or unexpected failure, never a client mistake
503 Service UnavailableOverloaded, shedding load or failing readiness; add Retry-After

The worst mistake is returning 200 with an error in the body: clients, proxies and monitoring all count it as a success.

Long jobs: accept, then poll. A job over about 1 to 2 seconds (exports, video processing, bulk imports) should not hold a connection open. Return 202 Accepted with a status URL, run the work on a queue, and let the client poll. The cost is a second endpoint and a job record.

Versioning and errors. Put the major version in the path (/v1/orders). Adding an optional field needs no new version; removing or renaming one does. Scene: you rename total to totalCents on Friday. Mobile app 4.2, which users update slowly, reads undefined and shows a blank price until you roll back. Keep changes additive, and run /v1 and /v2 side by side until old clients fade.

Errors should be machine-readable. RFC 9457 "problem details" is the standard shape:

Pagination and safe retries. Page 50 of a feed, and the same POST sent twice: every busy API meets both. Offsets suit admin tables; feeds need cursors; any POST that moves money needs an .

Offset: ?page=3&limit=20Cursor: ?cursor=…&limit=20
Says"Skip N rows, then give me 20""Give me 20 after the last one I saw"; the cursor encodes that row
QueryLIMIT 20 OFFSET 40WHERE id < $1 ORDER BY id DESC LIMIT 21
Deep pagesSlow: reads and discards every skipped rowFast: seeks straight into the index, so every page costs the same
New items arriveRows shift: duplicates and skipsStable
Jump to page 50YesNo, only next (and previous)
Good forAdmin tables, numbered search results with a few hundred pages at mostFeeds, timelines, infinite scroll, sync APIs; the default for anything that grows

OFFSET 999980 reads about a million rows to return 20. The chart shows rows read as the page number grows; a cursor stays flat at 21.

The cursor's sort key must be unique and indexed, so sort on (created_at, id) when timestamps can tie.

Cursor on (created_at, id)? Send the timestamp as text (created_at::text), or rows get skipped: Postgres keeps microseconds, JavaScript only milliseconds.

Networks fail after the server did the work, so clients retry. A retried POST /payments must not charge twice. The client sends one UUID as an Idempotency-Key; the server claims it with SET NX, stores the response and replays it for repeats. Scene: Black Friday, 20:02. A shopper's phone loses signal just after sending a 49.00 payment. The charge succeeded, the response was lost, the app retries. Without a key the card is charged twice; with one, the retry replays the first response. Play Retry after a timeout.

A duplicate that arrives while the first is still running gets 409. Keep keys for about 24 hours (86,400 s in the snippet), and scope them to the user so one customer cannot replay another's.

GET, PUT and DELETE are safe to retry; PATCH isn't necessarily. Never auto-retry a POST without a key.

Which status code?yesyesyesnononoyesyesyesyesyesyesyesyesnonononoyesnonononono201 Created202 Accepted200 OK204 No ContentIs there a body toreturn?Is the work stillrunning?Did it create aresource?429 Too Many Requests401 Unauthorized403 Forbidden400 Bad Request409 Conflict422 UnprocessableContent503 Service Unavailable500 Internal ServerErrorOverloaded or sheddingload?Well-formed but breaks arule?Clashes with currentstate?Malformed (bad JSON,missing field)?404 Not FoundDoes the resource exist?Known caller, but notallowed?Caller unknown (no orexpired token)?Too many requests fromthis caller?Did the request succeed?

Did the request succeed?

{ "type": "https://example.com/problems/out-of-stock", "title": "Out of stock",
  "status": 409, "detail": "SKU a1 has 0 units left", "instance": "/orders/123" }
Rows read per request, 20 per page (log scale)
1101001K10K100K1Mrows20Offset, page 11KOffset,page 50100,000Offset,page 5,0001,000,000Offset,page 50,00021Cursor,any page
Show data table
Rows read per request, 20 per page
rows
Offset, page 120 rows
Offset, page 501,000 rows
Offset, page 5,000100,000
Offset, page 50,0001,000,000
Cursor, any page21 rows
cursor-pagination.js
// Stable pages even while new rows arrive
const encode = v => Buffer.from(JSON.stringify(v)).toString('base64url');
const decode = s => JSON.parse(Buffer.from(s, 'base64url').toString());

app.get('/api/posts', async (req, res) => {
  const limit = Math.min(Number(req.query.limit) || 20, 100);
  const cursor = req.query.cursor ? decode(req.query.cursor) : null;      // { id } of the last item seen
  const { rows } = await pool.query(
    `SELECT id, title, created_at FROM posts
     WHERE ($1::bigint IS NULL OR id < $1)
     ORDER BY id DESC LIMIT $2`,                                         // ids are time-ordered (bigserial / Snowflake)
    [cursor?.id ?? null, limit + 1]);                                    // one extra row tells us if there's a next page
  const page = rows.slice(0, limit);
  res.json({ data: page, nextCursor: rows.length > limit ? encode({ id: page.at(-1).id }) : null });
});

Press Play to watch it run · 6 steps

The client sends one UUID per intended action. The server claims it, performs the work once and replays the stored response for repeats.

Idempotency keys make a retry safeThe client sends one UUID per intended action. The server claims it, performs the work once and replays the stored response for repeats.alt [First try]POST /payments + Idempotency-Key k1SET idem:k1 pending NXOK, claimedcharge 49.00store 201 + body201 Created
  • Client
  • Service
  • Cache
  • Database
Text description
  1. Client → API: POST /payments + Idempotency-Key k1
  2. API → Redis: SET idem:k1 pending NX
  3. Redis replies to API: OK, claimed
  4. API → Payments DB: charge 49.00
  5. API → Redis: store 201 + body
  6. API replies to Client: 201 Created
idempotency.js
// Express middleware. Repeats of a POST with the same Idempotency-Key replay the stored response.
export function idempotency(redis, ttlSec = 86_400) {
  return async (req, res, next) => {
    const key = req.get('Idempotency-Key');
    if (!key) return next();
    const redisKey = `idem:${req.user?.id ?? 'anon'}:${key}`;
    const claimed = await redis.set(redisKey, JSON.stringify({ state: 'pending' }), 'EX', ttlSec, 'NX');
    if (!claimed) {
      const saved = JSON.parse((await redis.get(redisKey)) ?? '{}');
      if (saved.state === 'done') return res.status(saved.status).json(saved.body);
      return res.status(409).json({ error: 'A request with this key is still in progress' });
    }
    const json = res.json.bind(res);
    res.json = body => {
      const write = res.statusCode < 500
        ? redis.set(redisKey, JSON.stringify({ state: 'done', status: res.statusCode, body }), 'EX', ttlSec)
        : redis.del(redisKey);                               // let the client retry server errors
      write.catch(() => {});
      return json(body);
    };
    next();
  };
}
// If a handler crashes without responding, the key stays "pending" until the TTL expires: keep TTLs modest.

API styles, gateway and BFF

Pick the style by who is calling.

Read more · 580 words

REST is the default (every proxy, , browser and tool understands it, and responses by URL); each other style earns its place by solving one specific problem.

StyleBest fitStrengthsCosts
RESTPublic APIs, CRUD on resourcesSimple, HTTP caching, tooling everywhereOver- or under-fetching; several round trips per screen
GraphQLMany clients (web, iOS, Android, partners) wanting different shapes of the same dataClient picks fields in one request against a typed schema: about 80 ms of network instead of 240N+1 resolvers (batch with a loader); HTTP caching by URL is gone because everything is a POST; needs query depth and cost limits, or one query can ask for the whole database
gRPCInternal service-to-service calls with tight latency or streamingCompact binary Protobuf from a shared .proto over long-lived HTTP/2; generated clients, so a wrong field is a compile errorBrowsers need a gRPC-Web proxy; binary is harder to debug; long connections need L7 or client-side balancing
WebSocketChat, multiplayer, collaborative editing; only when both directions are frequentFull duplex, low latencyStateful servers: Alice and Bob on different servers need pub/sub between them. At about 100,000 sockets per machine (an assumption; measure yours), 1 million users need 10 machines plus headroom. A deploy drops every socket, so clients reconnect with jitter
SSENotifications, live scores, progress, streamed LLM tokensPlain HTTP, so far cheaper than WebSockets; the browser reconnects and resumes from the last event idServer to client only; text only

Answer these questions in order to choose. The polling, SSE and WebSockets deep dive compares the real-time options.

An is the single front door: it checks the token, enforces and routes each path to a service, so services stick to business logic and failing requests never reach them. Use it as soon as you have several services, or any public API. Play Rejected early: a caller sending 300 req/s against a limit of 100 is stopped at the door with a 429. The cost is an extra hop and a possible : run several gateways behind a balancer, and keep business logic out of it.

A backend for frontend (BFF) is a thin layer per client type that calls the services and shapes the response for its own screens. Use it when one generic API forces a client to make many calls or download fields it never shows, for example a phone on a slow network. Each client team owns its BFF. The cost is more services to run, and logic can drift between BFFs. is the alternative when you would rather let clients pick their own fields.

Which API style?yesnoyesyesyesyesnonononoWebSocketServer-sent eventsDoes the client streammessages too?REST over JSONgRPCGraphQLREST over JSONClients need differentfields?Internal,latency-critical orstreaming?Public or browser-facingAPI?Does the server pushupdates?

Does the server push updates?

Press Play to watch it run · 3 steps

The API gateway authenticates, rate limits and routes, so rejected requests never reach a service and services contain only business logic.

The gateway handles the shared choresThe API gateway authenticates, rate limits and routes, so rejected requests never reach a service and services contain only business logic.ClientAPI gatewayauth, limits, routingOrdersUsersSearch

Tap any box to see what it is and what it talks to.

  • Client
  • API gateway
  • Service
  • Search index
Open in playground
Text description
  1. GET /orders/123 with a bearer token.
  2. The gateway verifies the token, checks the caller's rate limit, and routes /orders to the orders service.
  3. The service returns data and never wrote any auth code.

API and load balancing in the interview

Bring this up the moment you draw more than one server, then say five decisions in order.

Read more · 543 words

The order shows you have done it before.

  1. Make the services stateless

    Sessions in or a signed token, files in , work on queues. Say it first: it is what makes the next steps possible.

  2. Put a balancer in front

    One managed L7 balancer by default, with an L4 tier in front only for raw or very large scale. Round robin to start.

  3. Pick the algorithm by the workload

    Round robin for uniform calls, least connections or P2C for mixed or slow ones, only when a key needs the same server.

  4. Say how it fails

    Active plus passive health checks, narrow readiness, draining on deploy, a retry budget, and enough headroom to lose a zone.

  5. Define the API contract

    REST with correct status codes, for feeds, and an on every POST that moves money.

If the interviewer saysYou raise
"Traffic is 10x on sale day"Stateless instances, headroom, autoscaling with slow start
"One server gets slow"Least connections or P2C, outlier ejection, a retry budget
"Users get logged out at random"Sticky or in-memory sessions; move to Redis or a signed token
"A client retries the payment"Idempotency key, 409 for in-flight duplicates
"Infinite scroll feed"Cursor pagination, never offset
"Web and mobile need different data"A BFF, or GraphQL if clients vary a lot

Quick checkWhat do you say first when scaling a service to many servers?

Interview questions

Why must services be stateless to scale horizontally?

So the balancer can send any request to any instance. A crash loses only in-flight requests.

L4 or L7?

L4 for fast forwarding; L7 for path routing and retries, usually behind an L4 balancer.

How does the LB know an instance is unhealthy?

Active /readyz checks (3 failures down, 2 up) plus passive ejection on error spikes.

One server gets slow under round robin. What happens?

Its queue grows, clients time out and retry, and the retries load the rest. Use least connections or P2C, eject outliers, and cap retries with a budget.

REST, GraphQL or gRPC?

REST for public resources; when clients need different shapes; for internal calls.

How do you make POST retries safe?

An Idempotency-Key the server claims with SET NX, replaying the stored response for repeats.

Check yourself

Question 1 of 5

Score 2 out of 3 or better to mark this page complete.

1. Which algorithm adapts when one server becomes slow?

Next: All those servers share one data layer, which becomes the next bottleneck.