Design an API gateway service
Open in the interactive app: practice, quiz, mock interview →In one minute
- Split the data plane from the control plane. Gateway nodes forward traffic using an immutable, in-memory config snapshot. A separate control plane validates, versions and pushes that snapshot. A control plane outage must never stop traffic; nodes keep serving the last known good config.
- Compile routes, don't scan them. Thousands of routes are compiled into a segment trie with explicit precedence (literal beats parameter beats wildcard, longer prefix beats shorter). Matching costs microseconds and the result is deterministic, so two nodes never route the same request differently.
- Run a fixed, per-route plugin chain with a declared failure policy. Authentication runs before rate limiting so quotas are charged to a known consumer, and each plugin says whether it fails open or closed when its dependency is down.
- Keep the gateway stateless and cheap per request. Verify JWTs locally against cached keys, keep rate-limit counters in a sharded in-memory store with a local fallback, and reuse pooled upstream connections. Every network hop added on the hot path shows up in every API's p99.
- Protect the backends from the clients and from the gateway itself. Timeouts, retry budgets, circuit breakers and load shedding live here, because this is the only place that sees all traffic.
Requirements
Functional
- Route requests by host, method and path pattern (with path parameters and prefix wildcards) to upstream service clusters, with weighted splits for canaries.
- Authenticate callers with API keys or JWTs and attach the consumer identity to the upstream request.
- Enforce rate limits and quotas per consumer, per route and per plan.
- Apply per-route plugins: header rewrites, request size limits, CORS, response caching for a few GET routes.
- Emit access logs, metrics and trace spans for every request.
- Change routes and plugins through an admin API with no deploy and no dropped connections.
Non-functional
- Gateway overhead under 1 ms at p50 and under 5 ms at p99, excluding the upstream.
- 99.99% availability for the data plane, which is about 52 minutes of downtime a year.
- 500k requests per second at global peak across three regions, with room for 2x growth.
- A config change reaches every node within 10 seconds, and each request sees exactly one config version.
Out of scope
- East-west service-to-service traffic (a service mesh), the developer portal and billing, a full web application firewall, and GraphQL federation.
Back-of-envelope
- Traffic. Assume 100M daily active users and about 50 API calls each per day: 5B requests per day. 5 × 10^9 / 86,400 ≈ 58k RPS on average. Mobile traffic peaks around 3x the daily mean and launches add more, so plan for 200k RPS normal peak and 500k RPS as the design point.
- Nodes. A modern proxy with TLS termination, JWT verification and logging handles very roughly 3k to 6k RPS per core; I'll assume 4k. A 16-core node then does about 60k RPS at 90% CPU, but we run at 50% to absorb spikes, so about 30k RPS per node. 500k / 30k ≈ 17 nodes, so roughly 8 per region across three regions, where any two regions can carry the full load. Round up to 10 per region.
- Bandwidth. Average request 2 KB and response 10 KB: 12 KB × 500k = 6 GB/s, about 48 Gbit/s at peak, spread over 30 nodes is 1.6 Gbit/s each. NICs are not the bottleneck; CPU for TLS and parsing is.
- Connections. 20M idle keep-alive clients over 30 nodes is about 670k connections per node; at roughly 10 KB each that is 7 GB of memory to budget.
- Logs. 5B × 500 bytes = 2.5 TB per day, or about 4.5 PB over 5 years. Keep 30 days raw (75 TB) and only aggregates after that.
- Config and state. 10k routes × 2 KB = 20 MB of config. 10M API keys × about 100 bytes of hot state is 1 GB of counters, which fits in a small sharded in-memory store.
API
The data plane accepts any HTTP request. The control plane is a small admin API.
PUT /admin/v1/routes/{route_id}
{ "hosts": ["api.example.com"], "method": "GET",
"path": "/v2/orders/:order_id", "upstream": "orders-svc",
"plugins": [ {"name": "jwt_auth", "on_error": "closed"},
{"name": "rate_limit", "limit": 100, "window_s": 1, "on_error": "open"} ],
"timeout_ms": 2000, "retry": {"on": ["connect-failure"], "max": 1} }
-> 200 { "route_id": "...", "config_version": 41872 }
GET /admin/v1/config/versions/{n} -> 200 { "version": n, "sha256": "...", "created_by": "...", "diff": [...] }
POST /admin/v1/config/rollback {"to": n} -> 202 { "config_version": 41873 }
Data plane responses added by the gateway:
429 { "error": "rate_limited" } + Retry-After, RateLimit, RateLimit-Policy
401 { "error": "unauthenticated" } 503 { "error": "upstream_unavailable" }The rate-limit headers follow the current IETF draft, which replaced the older RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset trio with two structured fields, for example RateLimit: "default";r=50;t=30. It is still an Internet-Draft, not an RFC, so clients must tolerate changes (IETF draft-11).
Nodes receive config over a streaming RPC similar to Envoy's xDS: the node sends the version it has, the control plane streams the next snapshot, and the node ACKs or NACKs it (Envoy xDS protocol).
Data model
- routes (Postgres):
route_idPK,host,method,path_pattern,upstream_cluster,pluginsJSONB,timeout_ms,updated_at. Unique index on (host,method,path_pattern) so two teams can't register the same shape. The dataset is tiny and changes need transactions and an audit trail, so a single relational primary with replicas is the right store. No partitioning needed. - clusters:
cluster_idPK,lb_policy,health_check,circuit_breakerthresholds. Endpoints come from service discovery and are not stored here. - consumers:
consumer_idPK,plan,status; api_keys:key_hashPK (SHA-256 of the key, never the key),consumer_id,created_at,revoked_at. Lookups are by hash only, so the primary key is the index. - config_versions:
version(monotonic bigint) PK,snapshot_sha256,author,created_at. The compiled snapshot is a blob in object storage keyed by its hash. - Rate-limit counters (sharded Redis-like store): key
rl:{consumer_id}:{route_id}:{window_start}with a TTL of two windows. The partition key isconsumer_id, so all of one consumer's counters share a shard and a multi-counter check is one round trip.
High-level design
flowchart LR C["Clients"] --> DNS["GeoDNS / Anycast"] DNS --> L4["L4 load balancer"] L4 --> GW["Gateway node: TLS, router, plugin chain"] GW --> RL["Rate-limit store (sharded)"] GW --> JW["JWKS cache"] GW --> UP["Upstream service clusters"] GW --> TEL["Log and metrics pipeline"] ADM["Admin API"] --> PG["Postgres config"] PG --> COMP["Config compiler and validator"] COMP --> SNAP["Snapshot store"] SNAP --> XDS["Config distribution (streaming)"] XDS --> GW
Request path. DNS or anycast sends the client to the nearest region. An L4 load balancer spreads TCP connections across gateway nodes. The node terminates TLS, parses HTTP, and looks up the route in its current snapshot. It then runs the route's plugin chain: authenticate (verify the JWT signature with a cached public key, or hash the API key and look it up in a local cache backed by the consumer store), then rate limit (one round trip to the counter shard, or a local token bucket for hot keys), then transforms. If every plugin passes, the node picks a healthy upstream endpoint, reuses a pooled connection, and forwards the request with the consumer id and trace context in headers. The response streams back through response-phase plugins.
Config path. An engineer changes a route through the admin API. The change is written to Postgres with a new version number. The compiler loads the full config, validates it (no pattern conflicts, all clusters exist, plugin configs parse), compiles it into the snapshot format, stores it by hash, and notifies the distribution service, which streams it to every connected node. Each node builds the new route trie off the hot path and swaps a single pointer. In-flight requests finish on the old snapshot.
Deep dives
1. Route matching and precedence
Options: a linear scan of regexes in priority order, a hash map on exact path, or a segment trie. A regex scan costs O(routes) per request and makes precedence depend on list order. A hash map can't do parameters. I'd compile a trie per (host, method), where each node has three kinds of children: literal segments in a hash map, a single parameter child, and a single wildcard terminal. Lookup tries literal first, then parameter, then wildcard, backtracking if a deeper branch fails. That yields the rule "the most specific route wins, compared segment by segment": /users/me beats /users/:id, /static/css/ beats /static/, and /orgs/:org/repos/:repo beats /orgs/*. Lookup is O(path segments), which is a few hundred nanoseconds. The compiler rejects ambiguous shapes such as /a/:x and /a/:y on the same method, because nothing in the URL can disambiguate them. Regex routes, if allowed at all, run after the trie fails and use a linear-time engine such as RE2.
2. The plugin chain, auth and rate limiting
Order matters and is fixed by the platform, not by each team: request-size limit, authentication, authorization, rate limiting, transforms, then the upstream call. Authentication comes before rate limiting so the quota is charged to a known consumer; unauthenticated traffic gets a separate, much smaller per-IP bucket so a credential-stuffing burst can't spend a real customer's quota. JWTs are verified locally against a cached JWKS in tens of microseconds, so the identity provider is off the hot path; the cost is that revocation waits for token expiry. High-risk routes can instead ask the identity provider on each call through token introspection (RFC 7662), paying a round trip for instant revocation.
Two security rules come with forwarding identity in headers. The gateway must strip any consumer-id or identity headers the client sent before adding its own, and backends must accept traffic only from the gateway (mutual TLS or a network policy), or a caller can bypass auth and quotas by going around it. Azure's Gateway Offloading pattern lists both: prevent gateway bypass, and never treat unverified forwarded headers as proof of identity (Azure Architecture Center).
For rate limiting I considered three options. Purely local buckets need no network but each node sees only 1/N of a consumer's traffic, so a limit of 100 RPS becomes anywhere from 100 to 100 × N. A central counter store is accurate but adds a round trip (around 200 to 500 µs in-region) and a dependency. Hybrid: a central sliding-window counter for normal keys, plus local token leases for the few very hot keys, where a node fetches a batch of 50 tokens and spends them locally. I'd choose the hybrid. Every plugin declares on_error: auth fails closed, rate limiting fails open to a conservative local limit, because a counter outage should not become an API outage. Envoy's global rate limit filter makes the same choice explicit: it allows traffic when the rate limit service doesn't answer unless failure_mode_deny is set, and its call times out after 20 ms by default (Envoy docs).
3. Config distribution and safe change
A bad route can take down every API at once, so config is treated like code. Changes are validated by the compiler, diffed, and rolled out in waves: first to a canary node group carrying about 1% of traffic, watched for error rate and latency for a few minutes, then to everyone. Nodes NACK a snapshot they can't load and keep the old one. Snapshots are immutable and content addressed, so every node can report exactly which hash it's serving and rollback is a pointer move. Below a few tens of MB, push full snapshots rather than deltas, because a full snapshot is idempotent and can't drift. If the control plane is down, nodes keep serving and new nodes boot from the latest snapshot in object storage.
Down the stack
Layer 1The contract
A request entering a healthy node is matched against exactly one config version and either forwarded to one upstream endpoint or rejected with a gateway-generated status (401, 404, 413, 429, 503, 504). Non-idempotent methods are retried only when the gateway can prove the upstream never received the request, such as a connect failure or a refused stream, which is the rule HTTP itself sets for automatic retries (RFC 9110, section 9.2.2). Gateway overhead is under 1 ms p50 and 5 ms p99. Rate limits are enforced within about 10% of the configured value inside a region and are best effort across regions. Managed gateways promise no more: AWS API Gateway documents its token-bucket throttles as targets applied on a best-effort basis, not guaranteed ceilings (AWS docs). A config change becomes visible on all nodes within 10 seconds, and a node never mixes routes from two versions within one request.
Layer 2The mechanism
In an Envoy-style proxy, a listener accepts connections and runs a network filter chain. The HTTP connection manager decodes HTTP/1.1 or HTTP/2 frames into a stream and runs HTTP filters in order (decodeHeaders, decodeData, then encodeHeaders on the way back). Any filter can stop iteration and send a local reply, which is how auth and rate limiting short-circuit. The route table is a list of virtual hosts, each with routes. The config arrives over xDS as DiscoveryResponse messages carrying version_info, a nonce and typed resources (listeners, routes, clusters, endpoints). The node answers with a DiscoveryRequest that echoes the nonce and either the new version (ACK) or the old version plus error_detail (NACK). Each worker thread holds a thread-local pointer to the active snapshot, and the main thread publishes a new one by posting it to every worker's event loop, a read-copy-update pattern with no locks on the request path (Envoy threading model). Upstream clusters hold per-worker connection pools, outlier detection (eject an endpoint after N consecutive 5xx) and circuit breaker counters (max pending requests, max retries in flight).
Layer 3The machine
Each worker runs a single-threaded event loop around epoll_wait. With SO_REUSEPORT, the kernel hashes incoming SYNs across one listen socket per worker, so no lock is taken on accept. A full TLS 1.3 handshake with an ECDHE key exchange and an ECDSA certificate costs very roughly 0.3 to 1 ms of CPU on the server; session resumption cuts most of that, which is why resumption rates are a capacity metric. Reads land in kernel socket buffers (default sizes are tens of KB, and autotuning grows them up to a few MB), and the proxy copies them into its own buffer slices. Per-connection buffer limits ("watermarks") matter: 600k connections each buffering 64 KB would need 38 GB. Upstream connections are kept alive so a request skips the TCP handshake (one in-region RTT, around 0.2 to 0.5 ms) and the TLS handshake. Stats counters are per worker and merged periodically, because a shared atomic counter incremented by 16 cores bounces one 64-byte cache line between them and can cost 50 to 100 ns per increment under contention. Access logs go to an in-memory ring buffer and are flushed with batched writev calls rather than one write per request.
Why it's built this way
- Every API pays the gateway's latency on every call, so the hot path must do no blocking I/O except what's unavoidable (the upstream call and at most one counter lookup).
- Blocking I/O is avoided by event loops, and event loops can't share mutable state cheaply, so config is immutable and swapped by pointer per worker.
- Immutable snapshots need a producer, so a control plane compiles and validates them; because it is off the hot path, it can be slow, strongly consistent and sometimes unavailable.
- A shared gateway is a shared blast radius, so config changes are staged, versioned and reversible, and each plugin has a declared failure mode.
- CPU, mostly TLS and parsing, is the scarce resource, so connection reuse and session resumption decide the node count more than bandwidth does.
Failure modes
- 10x load. A launch or a retry storm multiplies traffic. Mitigation: per-node concurrency limits that shed with 503 before CPU saturates, priority classes so health checks and paid tiers are shed last, retry budgets (retries at most 10% of requests, the per-client figure Google uses (Google SRE book, ch. 21)) so the gateway doesn't amplify an upstream brownout, and autoscaling on CPU with pre-scaling for known events.
- Region outage. DNS or anycast withdraws the region and traffic moves to the other two, which must already have the headroom (the reason for 50% target utilization). Rate-limit counters are regional, so a consumer can briefly get up to its limit in each region; that is accepted and documented.
- Hot key. One customer sends 50k RPS on one route, so one counter shard melts. Mitigation: detect keys above a threshold and switch them to local token leases, and give that consumer its own shard if it persists.
- Counter store down. Rate limiting fails open to a local per-node bucket set to limit ÷ node count, which bounds the overshoot.
- Bad config push. Validation catches most errors; the canary wave catches the rest; nodes NACK unloadable snapshots and keep the last good one; rollback is one API call.
- Slow upstream. Per-route timeouts, outlier ejection and circuit breakers stop one slow service from starving every other route. Set the breaker limits per cluster on purpose: Envoy's are on by default with modest values such as 1,024 connections per cluster (Envoy circuit breaking), because a shared proxy left on defaults can start rejecting unrelated traffic once timeouts and retries pile up.
In the wild
- Canva, 2024: a stalled CDN asset releases a herd on the API gateway. After a deploy, one JavaScript chunk stalled on a lossy network path, and CDN request coalescing held about 270,000 users on the same fetch. When it finally loaded, every waiting client hit the API at once, about 1.5M requests per second or 3x peak. Gateway tasks, already slowed by lock contention in a telemetry library, ran out of memory faster than autoscaling could replace them, and canva.com was down for about 52 minutes. Canva blocked traffic at the CDN, let regions back in under rate limits, and added load shedding to the gateway (Canva).
- Google Cloud, 2025: the API policy checker crash-loops worldwide. Service Control checks authorization, policy and quota on every Google Cloud API request, the job of a gateway plugin chain. A policy record with blank fields replicated globally within seconds and hit new quota code that had no error handling and no feature flag, so the binary crashed in every region. The kill switch was rolled out within about 40 minutes, but restarting tasks without randomized exponential backoff overloaded a Spanner table, so us-central1 took up to about 2 hours 40 minutes to recover. Google committed to making Service Control fail open (Google Cloud incident report).
- Cloudflare, 2025: a fail-closed auth plugin blocks every login. Cloudflare Access reads identity and policy data from Workers KV. When the storage provider behind KV failed, 100% of identity-based Access logins failed for about 2.5 hours, because Access fails closed by design. It is the plugin failure policy in this lesson playing out: closed was the safe choice for auth, but it made a storage outage an access outage (Cloudflare).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Central gateway fleet | One place for auth, quotas and telemetry; easy to audit | Shared blast radius; extra hop | Public APIs, many teams, compliance needs |
| Per-service gateway (backend for frontend) | Teams own their edge; isolation | Duplicated policy; drift between teams | Few large clients with very different needs |
| Local rate limits only | No network hop; never fails | Inaccurate by up to N times | Coarse abuse protection |
| Central counter store | Accurate limits | Extra round trip; a dependency | Paid quotas and contractual limits |
| JWT verified locally | No IdP call per request | Revocation waits for expiry | Short-lived tokens (5 to 15 minutes) |
| Opaque token introspection | Instant revocation | IdP on the hot path | High-risk admin APIs |
| Full snapshot push | Simple; no drift | Large payloads at scale | Config under tens of MB |
Leadership lens
- Rollout. Start by putting the gateway in front of one low-risk API in pass-through mode with only logging, then add auth, then rate limits in shadow mode (log the decision, don't enforce) for two weeks before enforcing.
- Ownership. A platform team owns the data plane, the plugin catalog and the control plane. Product teams own their route definitions as code in their own repos, reviewed by CI checks the platform team writes. Custom plugins need a sandbox (Wasm with CPU limits) or they become the platform team's outage.
- Cost drivers. CPU for TLS and JWT verification, log volume (often more than the nodes themselves), and cross-zone data transfer between gateway and upstreams. Sample success logs and keep all error logs.
- Build first. The route compiler with conflict detection and the versioned snapshot pipeline. Plugins are easy to add later; a safe config pipeline is hard to retrofit.
- Design review questions. What does each plugin do when its dependency is down? How long does an emergency rollback take, end to end?
Check yourself
The rate-limit counter store goes down. What should the gateway do, and why is that plugin configured differently from authentication?
Show answer
Rate limiting fails open to a local per-node bucket set to the limit divided by the node count, which bounds the overshoot, because a counter outage should not become an API outage. Authentication fails closed, since letting unauthenticated calls through is not an acceptable degradation.
Why choose a hybrid of a central sliding-window counter plus local token leases over purely local buckets or a purely central store?
Show answer
Purely local buckets need no network hop, but each node sees only 1/N of a consumer's traffic, so a limit can be exceeded up to N times. A central store is accurate but adds a round trip and a dependency for every key; the hybrid keeps central accuracy for normal keys and gives the few very hot keys batches of local tokens so they do not melt one shard.
Walk through the node-count estimate. Why run nodes at 50% CPU instead of 90%?
Show answer
At about 4k RPS per core, a 16-core node does about 60k RPS at 90% CPU, or 30k at 50%, so 500k RPS needs about 17 nodes, rounded up to 10 per region across three regions. The 50% target leaves headroom for spikes and lets two regions carry the full load if a third is withdrawn.
Why are routes compiled into a segment trie instead of a priority-ordered list of regexes?
Show answer
A regex scan costs O(routes) per request and makes precedence depend on list order, while the trie matches in O(path segments) with a fixed rule: literal beats parameter beats wildcard. That makes matching deterministic across nodes, and the compiler can reject ambiguous shapes like
/a/:xand/a/:yup front.
Sources and further study
Primary
- IETF draft-ietf-httpapi-ratelimit-headers-11: the
RateLimit-PolicyandRateLimitfields that replace the older per-value headers. - RFC 9110, section 9.2.2: Idempotent methods: which methods a proxy may retry automatically, and when it may not.
- RFC 7662: OAuth 2.0 Token Introspection: the online alternative to local JWT verification when revocation must be instant.
Systems docs
- Envoy xDS protocol: versions, nonces, ACK and NACK in the config stream this design copies.
- Envoy global rate limit filter: descriptors, the 20 ms default timeout and
failure_mode_deny. - AWS API Gateway request throttling: token-bucket limits layered per client, per method, per account and per Region.
Books
- Building Microservices (2nd edition), Sam Newman, chapter 5 "Implementing Microservice Communication", section "Service Meshes and API Gateways": where API gateways sit relative to service meshes and service discovery in microservice communication.
- Site Reliability Engineering, Beyer et al. (Google), chapter 21 "Handling Overload" and chapter 22 "Addressing Cascading Failures": retry budgets, criticality and load shedding at the front door.
Other curricula
- Azure Architecture Center: Gateway Offloading pattern: frames the gateway as a place for cross-cutting concerns only, with explicit warnings about bypass, forwarded headers and business logic.
- Azure Architecture Center: Backends for Frontends pattern: the per-client gateway alternative from the trade-offs table.
Watch
- What is API Gateway? (ByteByteGo): a short overview of what a gateway does on each request.
- API Gateways in System Design Interviews w/ Ex-Meta Staff Engineer (Hello Interview): how to present a gateway in an interview and when it matters.
- Envoy Proxy Crash Course (Hussein Nasser): listeners, clusters, L4 and L7 proxying and TLS in a real proxy.
Free resources
- Envoy HTTP routing: how a production proxy structures virtual hosts, routes, retries and timeouts.
- Envoy threading model: why workers are single-threaded event loops with thread-local config.
- Envoy HTTP filters: the filter chain model behind gateway plugins.
- API gateway pattern (microservices.io): the pattern, its variants and its costs.
- How we built Pingora (Cloudflare): connection reuse and CPU savings at very large proxy scale.
- Using load shedding to avoid overload (AWS Builders' Library): how to shed load before a fleet tips over.