← Back to the library
ArchitectureHow it works · Applied · 14 min

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

From client to service: who does whatTop is closest to the user. Each layer adds a network hop, so each needs a reason to exist and a share of the timeout budget.
  1. 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.
  2. 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.
  3. API gatewayRouting by host and path, token validation, rate limits and quotas, request IDs, CORS, size limits and header normalisation. No domain logic.
  4. 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.
  5. Service mesh (east-west)Sidecar or ambient proxies give mTLS identity, retries, timeouts, load balancing and traces between services. Clients never reach it.
  6. 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.
  7. Data stores and queuesOnly reachable through their owning service. Async work goes through a queue rather than through the gateway.
Gateway, BFF, mesh, federation and edge BFFThese are complementary layers, not alternatives. Large systems often run a gateway, BFFs and a mesh together.
Primary jobOwned byTrafficTypical toolsMain risk
API gatewayEdge policy: routing, authN, rate limits, quotas, transformsPlatform team, as configurationNorth-south, every clientKong, Envoy Gateway, AWS API Gateway, Apigee, CloudflareGod layer full of business rules; single point of failure
BFFAggregate and shape data for one client experienceThe client team, as codeNorth-south, one client typeNode/Go service, Next.js route handlers, GraphQL serverLogic duplicated across BFFs; fan-out latency
Service meshmTLS identity, retries, timeouts, telemetry between servicesPlatform teamEast-west onlyIstio (sidecar or ambient), Linkerd, CiliumHidden retries amplify load; operational complexity
GraphQL federationOne graph composed from subgraphs that teams ownEach subgraph team plus a graph ownerNorth-south, many clientsApollo Router, GraphQL Hive, WunderGraph CosmoQuery planner fan-out; schema governance overhead
Edge function BFFAuth, routing and light shaping close to the userClient or platform teamNorth-south, at the PoPCloudflare Workers, Vercel and Netlify edge functionsFar 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.
Kong Gateway 3.x (decK) documentation (opens in new tab)

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.
Cloudflare Workers documentation (opens in new tab)

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.
Node.js 22 fetch documentation (opens in new tab)

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.
Next.js 15.5+ route handler documentation (opens in new tab)

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.
GraphQL resolvers + Connect documentation (opens in new tab)

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.

SOURCES

Explainer reviewed