DSA Guide
System Design

APIs and Rate Limiting

Designing the interface first, paginating safely, making writes retryable, and protecting the system from too much traffic

The API is the contract. Getting it right early is worth more than getting the internals right, because internals can be replaced and a public contract cannot.

Design the Interface Before the Boxes

Writing down the operations forces the scope into the open. It is common to discover, while listing endpoints, that the design is doing two unrelated jobs.

POST   /urls              create a short URL
GET    /{code}            redirect to the original
GET    /urls/{code}/stats click statistics

Three lines, and the whole system is now scoped. If a fourth endpoint feels necessary, that is a scope conversation to have deliberately.

REST, GraphQL, gRPC

RESTGraphQLgRPC
ShapeResources and verbsOne endpoint, a query languageTyped remote calls
Over-fetchingCommonClient picks fieldsDefined per method
Round tripsOne per resourceOne for everythingOne per call
CachingEasy — plain HTTPHardHard
Best forPublic APIsVaried clients, mobileService-to-service

REST unless you can name why not

REST is cacheable at every layer, debuggable with a browser, and universally understood.

Choose GraphQL when many different clients need different slices of the same data and mobile round trips are the bottleneck. Choose gRPC for internal service-to-service traffic where you control both ends and want speed and type safety.

"We use GraphQL" is a weak answer. "Our mobile client was making seven calls to render one screen" is a strong one.

Pagination

Never return an unbounded list. The one time that table has ten million rows, the response is a heap dump.

Offset pagination

GET /posts?limit=20&offset=40

Simple, allows jumping to page 5000 — and wrong at scale for two reasons.

Why offset pagination breaks

It gets slower the deeper you go. OFFSET 1000000 makes the database count and discard a million rows before returning twenty.

It skips and repeats items. If someone inserts a row while a user is paging, everything shifts by one — so an item on the boundary is either seen twice or never seen at all.

Cursor pagination

GET /posts?limit=20&after=eyJpZCI6MTIzfQ

The cursor encodes where you stopped — usually the last id or timestamp. The query becomes WHERE id < last_id ORDER BY id DESC LIMIT 20, which uses the index and stays fast at any depth. Inserts do not shift it.

The cost: you cannot jump to an arbitrary page. For feeds and infinite scroll that is not a real loss.

OffsetCursor
Deep pagesSlowFast
Stable under insertsNoYes
Jump to page NYesNo
Use forAdmin tablesFeeds, timelines, exports

Idempotency for Writes

A network drops the response. The client retries. The order is placed twice.

POST /orders
Idempotency-Key: 9f2c-41ab-7e

first request  →  create the order, store the result under the key
retry          →  key seen before  →  return the stored result, create nothing
MethodNaturally idempotentNote
GETYesReads change nothing
PUTYesSets a value; repeating sets the same value
DELETEYesAlready gone stays gone
POSTNoNeeds an idempotency key
PATCHDependsset x = 5 yes; increment x no

Keys should expire, and should be scoped

Store idempotency keys for around 24 hours — long enough to cover any realistic retry, short enough to bound storage.

Scope the key to the user, so two clients cannot collide by generating the same value.

Rate Limiting

Rate limiting protects the system from a client that is abusive, buggy, or simply successful.

Where the limiter sits
requestcountallowedClientsRate Limiterat the edgeRedisshared countersAPI ServersDatabase
ClientEdgeCacheServiceDatabase
Reject as early as possible — work you never start is the cheapest kind.

The four algorithms and their trade-offs are worked through in Rate Limiter. The short version:

AlgorithmBurstsMemoryAccuracy
Fixed windowAllows 2× at the boundaryTinyPoor
Sliding logExactHeavyExact
Sliding window counterGoodTinyGood
Token bucketControlled, by designTinyGood

Token bucket is the usual choice: it permits a short burst, which real clients need, while capping the sustained rate.

Telling the client

HTTP 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1719000000
Retry-After: 30

Without Retry-After, clients retry immediately

A client that gets a 429 with no guidance will try again straight away, and again, making the overload worse.

Retry-After turns rejection into cooperation. It is one header and it materially changes system behaviour under load.

What to limit by

KeyCatchesWatch out
API key / user idPer-customer abuseRequires authentication
IP addressAnonymous trafficOffices and mobile networks share IPs
EndpointExpensive operationsNeeds per-route configuration
CombinationMost real abuseMore state to hold

Different endpoints deserve different limits. A search that scans an index should be limited far more tightly than a health check.

Status Codes That Mean Something

CodeUse for
200Fine
201Created
400The request is malformed
401Not authenticated
403Authenticated, not allowed
404Not found
409Conflict — e.g. a version mismatch
429Rate limited
500We broke
503Temporarily unavailable — try later

The 4xx / 5xx split is a signal to the client

4xx means the client should change something. Retrying identically will fail again.

5xx means the server had a problem. Retrying may well work.

Returning 500 for invalid input causes well-behaved clients to retry forever against a request that can never succeed — and it pollutes your error rate, so real outages hide inside the noise.

Versioning

Once a client depends on your response shape, you cannot change it freely.

ApproachExampleNote
URL path/v1/usersObvious, easy to route, most common
HeaderAccept: application/vnd.api.v2+jsonCleaner URLs, easier to miss
Query parameter/users?version=2Simple, easy to forget

Additive changes need no version

Adding a new optional field breaks nobody — clients ignore what they do not know.

Removing a field, renaming one, or changing a type does break clients. Only those require a new version.

Design responses as objects rather than bare arrays from the start: {"items": [...]} can gain a total field later, while [...] cannot.

On this page