API gateways, BFFs and service meshes
What belongs in the layer between clients and services, and when do you need a gateway, a BFF, a mesh or GraphQL federation?
A gateway handles cross-cutting edge concerns for every client: routing, authentication, rate limits and request shaping. A BFF is application code owned by one client team that aggregates and reshapes services for one experience. A service mesh handles east-west traffic between services and never faces clients.
THE MENTAL MODEL
Sort the layers by who owns the logic. The gateway is infrastructure owned by a platform team. It is configured rather than programmed, it is identical for every client, and it should know nothing about your domain. A BFF is product code owned by the team that owns the screen. It knows exactly which three services the home screen needs and what to show when one of them is slow. The mesh sits below both and makes service-to-service calls uniform (mTLS, retries, telemetry) without touching payloads. Most trouble starts when a layer takes on another layer's job. A gateway that runs business logic turns into a god layer every team has to queue for. A BFF that reimplements pricing rules makes iOS and web disagree. A mesh that retries non-idempotent calls multiplies writes. Every hop also spends part of one shared latency budget, so deadlines have to shrink as a request moves inward.
HOW IT FITS TOGETHER
- Clients: web, mobile, partnersEach one has different payload needs, network quality and release cadence. Mobile versions stay in use for months after a new release ships.
- CDN / edgeTLS termination, WAF, bot rules and caching of public GETs. Edge functions can do cheap routing, auth checks or act as a thin BFF close to the user.
- API gatewayRouting by host and path, token validation, rate limits and quotas, request IDs, CORS, size limits and header normalisation. No domain logic.
- BFFs (one per experience)Owned by the client team. Aggregates services in parallel, shapes responses for one UI, sets deadlines and handles partial failure. Can expose REST or GraphQL.
- Service mesh (east-west)Sidecar or ambient proxies give mTLS identity, retries, timeouts, load balancing and traces between services. Clients never reach it.
- Domain services (gRPC inside)Each service owns its own data and rules. They use protobuf contracts and propagated deadlines and are not exposed to the internet.
- Data stores and queuesOnly reachable through their owning service. Async work goes through a queue rather than through the gateway.
| Primary job | Owned by | Traffic | Typical tools | Main risk | |
|---|---|---|---|---|---|
| API gateway | Edge policy: routing, authN, rate limits, quotas, transforms | Platform team, as configuration | North-south, every client | Kong, Envoy Gateway, AWS API Gateway, Apigee, Cloudflare | God layer full of business rules; single point of failure |
| BFF | Aggregate and shape data for one client experience | The client team, as code | North-south, one client type | Node/Go service, Next.js route handlers, GraphQL server | Logic duplicated across BFFs; fan-out latency |
| Service mesh | mTLS identity, retries, timeouts, telemetry between services | Platform team | East-west only | Istio (sidecar or ambient), Linkerd, Cilium | Hidden retries amplify load; operational complexity |
| GraphQL federation | One graph composed from subgraphs that teams own | Each subgraph team plus a graph owner | North-south, many clients | Apollo Router, GraphQL Hive, WunderGraph Cosmo | Query planner fan-out; schema governance overhead |
| Edge function BFF | Auth, routing and light shaping close to the user | Client or platform team | North-south, at the PoP | Cloudflare Workers, Vercel and Netlify edge functions | Far from data: chatty calls to an origin region are slow |
KEY TERMS
- Gateway offload
- The gateway runs the checks that would otherwise be copied into every service: TLS, token validation (signature, expiry, audience), rate limits and quotas per consumer, request size limits, CORS and request IDs. It passes identity downstream in trusted headers that it strips from inbound requests. Services must still authorise each action. The gateway answers who is calling, not whether they may touch this order.
- BFF per experience
- Sam Newman's rule is "one experience, one BFF". The mobile BFF returns small, pre-joined payloads that suit an old app version on a bad network. The web BFF can keep tokens server-side and set cookies. Because the BFF has one consumer, the team that owns that consumer can change or retire endpoints without versioning ceremony. For a web-only product, add a BFF only when there is real server-side aggregation to do.
- Aggregation and partial failure
- A chatty client makes 6 sequential calls across a 200 ms mobile RTT. A BFF makes those calls in parallel over a 1 ms datacenter network instead. Decide before you build it which upstreams are critical, so the request fails without them, and which are optional, so the response is returned with an empty value and a degraded flag. Clients must be able to render the degraded shape.
- Timeout budgets and deadline propagation
- Give each request one deadline at the edge, for example 3 s, and pass the remaining time downstream. The gateway timeout should be larger than the BFF's, and the BFF's larger than each upstream call's. If an inner timeout is longer than an outer one, the outer layer gives up while the inner work keeps running and holding resources. gRPC has no default deadline, so set one on every call. Java and Go propagate deadlines automatically as a remaining timeout.
- Protocol translation at the edge
- Outside, use REST or GraphQL over HTTPS, because browsers, CDNs and partners understand them. Inside, use gRPC with protobuf contracts, streaming and deadlines. The BFF or gateway translates between the two. gRPC status codes become HTTP statuses and RFC 9457 problem details, and internal field names stay internal. Transcoding at the gateway (gRPC-JSON transcoding in Envoy) works for simple 1:1 mappings. Anything that needs aggregation belongs in a BFF.
- GraphQL as a BFF
- A GraphQL server is a BFF whose response shape the client chooses, which helps when several screens need different slices of the same data. Resolvers call gRPC services, and DataLoader batches them per request. Federation splits the graph across teams, and a router plans queries across subgraphs. You still need persisted queries, depth and cost limits, and per-field auth.
- Edge functions as BFFs
- Workers and edge functions start fast and run near the user, which suits auth checks, A/B routing, header rewriting and caching. They are far from your database, though. A BFF that makes three sequential calls to us-east-1 from Sydney adds those long round trips again. Keep chatty aggregation close to the data, or use placement features that run the function near the origin.
IN YOUR STACK
YAML · Kong Gateway 3.x (decK) Server
_format_version: "3.0"
services:
- name: orders-bff
url: http://orders-bff.internal:8080
connect_timeout: 2000 # ms; keep below the client's own timeout
read_timeout: 5000
routes:
- name: orders-api
paths: ["/api/orders"]
protocols: [https]
plugins:
- name: key-auth # identifies the consumer for per-consumer limits
service: orders-bff
- name: rate-limiting
service: orders-bff
config:
minute: 600
hour: 20000
limit_by: consumer
policy: redis # shared counters across gateway nodes
redis:
host: redis.internal
port: 6379
fault_tolerant: true # if Redis is down, proxy instead of failing with 500- policy defaults to local, which keeps counters in memory on each node. Behind a round-robin load balancer the counts diverge as nodes are added, so a client can get up to N times the limit. Use redis, cluster (database-backed deployments) or a consistent-hashing load balancer when accuracy matters.
- limit_by defaults to consumer. If no auth plugin identifies a consumer, Kong limits by client IP, and behind NAT or a CDN that IP is shared by many users (check trusted_ips and real_ip_header). Rejections return 429 with "API rate limit exceeded" by default (error_code and error_message).
- fault_tolerant: true favours availability. Set it to false only when the limit protects something that must not be overrun, such as a paid third-party quota.
TypeScript · Cloudflare Workers Server
// wrangler.jsonc: "services": [{ "binding": "ORDERS", "service": "orders-bff" },
// { "binding": "CATALOG", "service": "catalog-api" }]
interface Env { ORDERS: Fetcher; CATALOG: Fetcher }
const routes: [prefix: string, binding: keyof Env][] = [["/api/orders", "ORDERS"], ["/api/catalog", "CATALOG"]];
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const { pathname } = new URL(request.url);
const route = routes.find(([p]) => pathname === p || pathname.startsWith(p + "/"));
if (!route) return new Response("Not found", { status: 404 });
const user = await verifySession(request); // your JWT or session check (jose, KV, ...)
if (!user) return new Response("Unauthorized", { status: 401 });
const headers = new Headers(request.headers);
headers.delete("cookie"); // services get identity, not the raw session
headers.set("x-user-id", user.id); // overwrites any client-supplied value
headers.set("x-request-id", request.headers.get("cf-ray") ?? crypto.randomUUID());
return env[route[1]].fetch(new Request(request, { headers }));
},
} satisfies ExportedHandler<Env>;- A service binding calls another Worker without a public URL. Cloudflare documents no added latency or cost for the call itself, but each call counts toward the subrequest limit, and one request can invoke at most 32 Workers.
- Always overwrite, never append, identity headers. If a client can send its own x-user-id and the gateway passes it on, auth is broken.
- Matching on the segment boundary (p + "/") stops /api/orders-admin from being routed as /api/orders.
TypeScript · Node.js 22 fetch Server
async function getJson<T>(url: string, signal: AbortSignal): Promise<T> {
const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`${url} -> ${res.status}`);
return res.json() as Promise<T>;
}
// GET /bff/home: one round trip for the mobile home screen instead of three
export async function homeScreen(userId: string, clientGone: AbortSignal) {
const id = encodeURIComponent(userId);
// One budget for the whole response; also cancelled if the client disconnects
const deadline = AbortSignal.any([clientGone, AbortSignal.timeout(800)]);
const [profile, orders, recs] = await Promise.allSettled([
getJson<Profile>(`http://users.internal/v1/users/${id}`, deadline),
getJson<Order[]>(`http://orders.internal/v1/orders?user=${id}&limit=5`, deadline),
// Optional data gets a tighter budget so it cannot hold the screen hostage
getJson<Rec[]>(`http://recs.internal/v1/users/${id}/recs`, AbortSignal.any([deadline, AbortSignal.timeout(300)])),
]);
if (profile.status === "rejected") throw profile.reason; // critical: fail the whole request
return {
profile: profile.value,
recentOrders: orders.status === "fulfilled" ? orders.value : null, // null = "couldn't load"
recommendations: recs.status === "fulfilled" ? recs.value : [],
degraded: orders.status === "rejected" || recs.status === "rejected",
};
}- The three calls run in parallel, so latency is that of the slowest one capped by the deadline, not the sum. Only sequence calls that really depend on each other.
- Distinguish "empty" from "failed" in the payload (null vs []) so the client can show a retry affordance instead of "no orders".
- Add a circuit breaker per upstream in production. Without one, a dead recommendations service still costs 300 ms on every request.
TypeScript · Next.js 15.5+ route handler Server
// app/api/orders/[id]/route.ts: the browser calls its own origin; access tokens stay on the server
import { cookies } from "next/headers";
import type { NextRequest } from "next/server";
export async function GET(_req: NextRequest, ctx: RouteContext<"/api/orders/[id]">) {
const { id } = await ctx.params;
const session = await getSession((await cookies()).get("sid")?.value); // your session store
if (!session) return Response.json({ error: "unauthenticated" }, { status: 401 });
let upstream: Response;
try {
upstream = await fetch(`${process.env.ORDERS_API}/v2/orders/${encodeURIComponent(id)}`, {
headers: { authorization: `Bearer ${session.accessToken}` },
signal: AbortSignal.timeout(2_000),
});
} catch {
return Response.json({ error: "orders unavailable" }, { status: 504 });
}
if (!upstream.ok) return Response.json({ error: "upstream" }, { status: upstream.status === 404 ? 404 : 502 });
const order = await upstream.json();
// Shaped for this UI only: internal fields never reach the browser
return Response.json({ id: order.id, status: order.status, total: order.totals.grand, items: order.lines.length });
}- This is the token-handler form of a BFF. The browser holds only an HttpOnly session cookie, and the OAuth access token never reaches JavaScript.
- params has been a Promise since Next.js 15. RouteContext (15.5+) is a global helper generated by next dev, next build or next typegen.
- Cookie-authenticated route handlers that change state need CSRF protection (SameSite plus an Origin check). GET handlers have not been cached by default since Next.js 15.
TypeScript · GraphQL resolvers + Connect Server
import { createClient, ConnectError, Code } from "@connectrpc/connect";
import { createGrpcTransport } from "@connectrpc/connect-node";
import { OrderService } from "./gen/orders/v1/orders_pb"; // generated by buf from .proto
const orders = createClient(OrderService, createGrpcTransport({ baseUrl: "http://orders.internal:8080" }));
// remainingMs: request deadline minus time spent; stockBySku: per-request DataLoader over a batch RPC
interface Ctx { userId: string; remainingMs(): number; loaders: { stockBySku: { load(sku: string): Promise<number> } } }
export const resolvers = {
Query: {
order: async (_: unknown, args: { id: string }, ctx: Ctx) => {
try {
const timeoutMs = Math.floor(ctx.remainingMs()); // integer ms; Connect treats <= 0 as "no timeout"
if (timeoutMs <= 0) throw new ConnectError("request budget spent", Code.DeadlineExceeded);
const res = await orders.getOrder({ id: args.id }, { timeoutMs, headers: { "x-user-id": ctx.userId } });
return res.order ?? null;
} catch (err) {
if (err instanceof ConnectError && err.code === Code.NotFound) return null; // nullable field
throw err; // surfaces in errors[], other fields still resolve
}
},
},
// Without batching, a 50-line order would make 50 inventory RPCs
OrderLine: { stock: (line: { sku: string }, _: unknown, ctx: Ctx) => ctx.loaders.stockBySku.load(line.sku) },
};- With the gRPC transport, timeoutMs is sent as the grpc-timeout header (connect-timeout-ms with the Connect protocol), so the service sees the remaining budget. A timeout raises ConnectError with Code.DeadlineExceeded.
- Map gRPC codes on purpose. NotFound becomes a null field, PermissionDenied becomes an error with an extension code, and Unavailable may be retried once if the RPC is idempotent.
- Create one DataLoader per request in the context factory. A shared loader caches across users and leaks data.
WHEN TO REACH FOR WHAT
API gateway only
Use when Several services or teams sit behind one public hostname and need consistent auth, rate limits, quotas and request IDs. Client payloads map cleanly onto service APIs.
Avoid when You find yourself writing per-screen aggregation or business rules in gateway plugins or scripting policies. That logic belongs in a BFF or service.
BFF per client type
Use when Web and mobile need very different payloads, mobile networks make chattiness expensive, or the web app should keep tokens server-side. Each client team can own and deploy its BFF.
Avoid when One web client already gets well-shaped responses from one backend. A BFF there is an extra hop and an extra deployable with nothing to do.
Single shared BFF / aggregator
Use when Two clients built by one team have nearly identical needs, for example an iOS and Android pair.
Avoid when Many teams have to change it for every feature. It turns into the general-purpose API bottleneck that BFFs exist to avoid.
GraphQL BFF or federation
Use when Many screens and clients need different slices of a connected domain, and a team can own schema governance, persisted queries and cost limits.
Avoid when Consumers are third parties who expect HTTP caching and simple curl access, or there are only a few fixed screens that a REST BFF would serve with less machinery.
Edge function as BFF
Use when The work is auth checks, routing, header shaping, personalisation of cached content or a single origin call.
Avoid when The handler makes several sequential calls to a database or services in one region. Each call pays the full user-to-origin distance.
Service mesh
Use when Many services in several languages need uniform mTLS identity, traffic policy and telemetry, and a platform team can operate it.
Avoid when There are only a handful of services. Client libraries plus a load balancer cost less to run and are easier to debug.
WHERE IT BITES
- Gateway as a god layer: business rules, response stitching and per-client branches pile up in gateway scripts that only one team can change and nobody tests. Keep the gateway declarative and move aggregation behind it.
- Nested timeouts set backwards. If the gateway times out at 30 s, the BFF at 60 s and the service has no deadline, abandoned work keeps running and piles up. Inner deadlines must be shorter than outer ones, and the remaining budget should be passed downstream.
- Retries stacked at every layer: client, gateway, BFF and mesh each retry 3 times, so one user click becomes 81 upstream calls during an incident. Retry at one layer, only for idempotent operations, with a retry budget.
- Duplicated domain logic across BFFs. If the web and mobile BFFs each compute order totals, they will disagree. BFFs shape and aggregate data; rules belong in a domain service.
- Trusting identity headers without stripping them at the edge, or letting services be reached without going through the gateway. Enforce it at the network level, for example with mesh mTLS or private networking.
- No correlation across hops. Without a request ID or traceparent created at the edge and passed through every layer, a slow BFF response cannot be traced to the upstream that caused it.
CLOSE THE AI. EXPLAIN THIS.
The mobile home screen calls a BFF that fans out to users, orders and recommendations behind a gateway with a 5 s timeout. Recommendations start taking 4 s. Walk through what the user sees with and without per-upstream deadlines, partial responses and a circuit breaker. Then say which layer should own each of those three fixes.WHEN IT BREAKS IN PRODUCTION
SOURCES
- Sam Newman: Backends For Frontends (opens in new tab)Checked
- Azure Architecture Center: Gateway Aggregation pattern (opens in new tab)Checked
- Kong: Rate Limiting plugin configuration reference (opens in new tab)Checked
- Cloudflare Workers: Service bindings (opens in new tab)Checked
- gRPC: Deadlines (opens in new tab)Checked
- Connect: Cancellation and timeouts (opens in new tab)Checked
Explainer reviewed