APIs, Load Balancing & Rate Limiting
The front door: REST vs. gRPC, how a load balancer spreads traffic, and rate limiters that protect you from spikes.
The API is the front door of the system. It defines what clients can ask for, how failures are retried, how traffic spreads across instances, and how abuse is contained. A clean backend with a sloppy API still becomes an unreliable product.
Good API design is mostly about contracts: stable resource names, predictable pagination, safe retries, explicit limits, and load balancers that only send work to healthy capacity.
The mental model
Think of the request path as three contracts in a row:
client contract -> traffic contract -> fairness contract
API shape -> load balancer -> rate limiter
resources/errors -> health/routing -> quotas/backpressure
The API tells clients how to behave. The load balancer decides where each request runs. The rate limiter decides whether the request is allowed to run at all.
REST vs. gRPC vs. GraphQL
| Style | Best fit | Strengths | Trade-offs |
|---|---|---|---|
| REST | Public resource APIs, CRUD, broad clients | Simple, cacheable, debuggable, works everywhere | Can over/under-fetch; versioning needs discipline |
| gRPC | Internal service-to-service calls | Typed contracts, streaming, efficient binary protocol | Harder for browsers and ad hoc debugging |
| GraphQL | Product APIs with many client-shaped views | Clients ask for exactly the fields they need | Query cost control, caching, and auth get more complex |
A common architecture uses REST at the public edge, gRPC internally, and GraphQL only where client flexibility is worth the operational cost.
Idempotency and pagination
Retries are inevitable. Networks fail after the server succeeds but before the client receives the response. Without idempotency, a retry can create two orders, two payments, or two jobs.
Make unsafe operations retry-safe with an idempotency key:
POST /orders
Idempotency-Key: user-123:create-cart-456
If the same key arrives again, return the original result instead of creating a
second order.
Pagination has the same contract problem. Offset pagination is easy but unstable under inserts: page 2 can skip or duplicate items when new rows arrive. Cursor pagination uses an opaque token tied to the last item seen.
GET /events?limit=50&cursor=eyJsYXN0X2lkIjoi...
The cursor should encode a stable sort key such as (created_at, id), not a page number.
Load balancing
A load balancer spreads traffic across healthy instances.
| Layer | Routes by | Good at | Example use |
|---|---|---|---|
| L4 | IP and TCP/UDP connection | Very fast, protocol-agnostic | Raw TCP, high-throughput edge |
| L7 | HTTP/gRPC request details | Path/header routing, retries, auth hooks | API gateways and service meshes |
Common algorithms:
- Round robin — simple rotation; good when requests cost about the same.
- Least connections / least requests — better when request durations vary.
- Weighted routing — send more traffic to larger instances or canaries.
- Consistent hashing — keep the same key on the same backend, useful for sticky caches or long-lived sessions.
Health checks matter as much as the algorithm. A load balancer that routes to a stuck process is an outage amplifier.
Rate limiting
Rate limiting protects shared capacity. The main algorithms:
| Algorithm | How it behaves | Fit |
|---|---|---|
| Fixed window | Count requests per minute/hour | Simple but bursty at boundaries |
| Sliding window | Count requests over the last N seconds | More accurate, more state |
| Token bucket | Tokens refill over time; bursts spend saved tokens | Best general-purpose API limiter |
| Leaky bucket | Queue drains at a fixed rate | Smooths traffic, can add latency |
Token bucket is the interview default because it allows small bursts without letting a client exceed its long-term rate.
Pattern recognition
Variations
Worked problems
In API scenarios, define the client-visible behavior before jumping into servers.
Design an idempotent paginated events API
Design an API for ingesting user events and listing them later. Clients may retry writes after timeouts. The list endpoint must paginate through new events without skipping or duplicating items.
Approach. Use an idempotency key for ingestion and cursor pagination for listing. Sort by a stable pair, not by page number.
Show solution
One possible contract:
POST /v1/events
Idempotency-Key: tenant-42:device-9:event-abc
Body: { "occurred_at": "2026-07-10T12:00:00Z", "type": "click", ... }
201 Created on first insert
200 OK with the same event id on retry with the same key
409 Conflict if the same key is reused with a different bodyList endpoint:
GET /v1/events?tenant_id=42&limit=100&cursor=<opaque>
Response: { "items": [...], "next_cursor": "..." }The cursor encodes the last (occurred_at, event_id) returned. The query asks for items after that pair in ascending order. event_id breaks ties so two events at the same timestamp do not duplicate or disappear across pages.
Storage needs a uniqueness constraint on (tenant_id, idempotency_key) and an index on (tenant_id, occurred_at, event_id). The API contract and database index are part of the same design.
Pick a load balancing strategy
A collaboration app serves normal REST requests, gRPC calls between internal services, and WebSocket sessions for live documents. Instances vary in size and WebSocket sessions can last for hours. What load balancing strategy do you use?
Approach. Separate request/response traffic from long-lived connections. Use L7 where request metadata matters and sticky or consistent routing where session locality matters.
Show solution
A reasonable split:
| Traffic | Strategy | Why |
|---|---|---|
| Public REST | L7 load balancer with weighted least requests | Routes by path, respects canaries, handles varied request cost |
| Internal gRPC | L7/service-mesh balancing with health checks | Understands HTTP/2/gRPC and service identity |
| WebSockets | Consistent hash or sticky routing by document/session id | Keeps a live document’s connection state together |
Weighted routing lets larger instances receive more traffic. Least requests is better than round robin when some endpoints are slow. For WebSockets, do not move a connection per request; route the initial upgrade to a healthy instance and keep it there.
Health checks should test dependency readiness, not just “process is alive.” If an instance cannot reach Redis or its document shard, the load balancer should stop sending it affected traffic before users discover the failure.
Implement token-bucket rate limiting
An API allows each user 100 requests/minute with bursts up to 20 requests. Sketch a token-bucket limiter for one user. What state do you store, and what happens when a request is rejected?
Approach. Store the current token count and last refill time. Refill based on elapsed time, cap at burst size, and spend one token per accepted request.
Show solution
import time
class TokenBucket:
def __init__(self, rate_per_sec: float, burst: int):
self.rate = rate_per_sec
self.capacity = burst
self.tokens = burst
self.updated_at = time.monotonic()
def allow(self) -> tuple[bool, float]:
now = time.monotonic()
elapsed = now - self.updated_at
self.updated_at = now
self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
if self.tokens >= 1:
self.tokens -= 1
return True, 0.0
wait_seconds = (1 - self.tokens) / self.rate
return False, wait_seconds
bucket = TokenBucket(rate_per_sec=100 / 60, burst=20)For production, this state usually lives in a gateway or Redis keyed by user_id/API key. A rejected request should return 429 Too Many Requests with a Retry-After hint. You may also have separate buckets per endpoint so one expensive export API cannot starve cheap reads.
The burst size is 20, so a user can make 20 requests instantly after being idle. The refill rate is about 1.67 tokens/sec, which enforces the long-term 100/minute contract.