What an API gateway and a BFF are responsible for

An API gateway and a backend for frontend (BFF) are easy to confuse because both sit between clients and services. The difference is who each one serves.

The gateway serves every caller the same way. It terminates TLS, validates tokens, enforces rate limits and request size limits, routes traffic to upstream services, and emits consistent logs, metrics and traces. None of these concerns depends on whether the caller is a browser, a mobile app or a partner's batch job. That is why a platform team usually owns the gateway and changes it carefully. A bad rule there affects all traffic at once.

A BFF serves one client experience. It calls several services, merges their responses, removes fields the client never renders, and translates internal models into the shape a screen or integration expects. When the iOS team redesigns a checkout flow, the BFF changes with it. The gateway does not. The BFF therefore belongs to the team that builds the client and moves at that team's pace.

Concern Layer Typical owner Change frequency
TLS termination, certificates Gateway Platform team Rare
Token validation, authentication Gateway Platform/security Rare
Rate limiting, request size limits Gateway Platform team Occasional
Routing, observability Gateway Platform team Occasional
Response shaping, field selection BFF Client team Frequent
Aggregation across services BFF Client team Frequent
Client-specific adaptation (web, iOS, partner) BFF Client team Frequent

The two layers have different owners and change at different rates, so most systems end up running both. The useful design question is not which layer to choose. It is where each responsibility should live.

How the combined topology works, hop by hop

A typical layout puts one shared gateway in front of several client-specific BFFs, each of which fans out to the same pool of domain services:

 Web app      iOS/Android      Partner API
    |              |                |
    +--------------+----------------+
                   |
           [ API gateway ]    TLS, authn, rate limits, routing
                   |
       +-----------+-----------+
       |                       |
  [ Web BFF ]            [ Mobile BFF ]   shaping, aggregation, client logic
       |                       |
   +---+-------+-------+-------+
   |           |       |
[Orders]  [Catalog]  [Users]              authz, business rules, data

Hop 1, client to gateway. The gateway terminates TLS, enforces quotas, and authenticates the caller by validating the token's signature, issuer, audience, and expiry. This check is the same for every request and needs no business data, so it belongs at the gateway. Rejecting bad credentials here keeps unauthenticated traffic away from everything behind it.

Hop 2, gateway to BFF. The gateway routes by path or host to the BFF for that client. The BFF reshapes payloads, merges calls, and trims fields for its screens. The team that owns the client also owns its BFF, so a mobile release does not wait on a platform ticket.

Hop 3, BFF to services. The BFF forwards the caller's token or a derived token. Each service validates it again and makes its own authorization decisions, such as "may this user cancel this order?" Only the service holding the order data can answer that question. Services should not trust a request because it came from inside the network. A misrouted request, a compromised pod, or a new internal caller would get through, and propagated identity is the only thing that keeps that boundary enforced.

  [Web app]        [iOS/Android]       [Partner API]
      |                  |                   |
      +------------------+-------------------+
                         |  HOP 1: bearer token over TLS
                         v
              +---------------------+
              |     API gateway     |  TLS termination, authn
              |      (shared)       |  (signature, iss, aud, exp),
              +---------------------+  rate limits, routing
                         |
                         |  HOP 2: route by path or host
            +------------+------------+
            v                         v
     +-------------+           +-------------+
     |   Web BFF   |           | Mobile BFF  |  shaping, aggregation,
     |  (web team) |           |(mobile team)|  client-specific logic
     +-------------+           +-------------+
            |                         |
            |                         |  HOP 3: forward caller token
            |                         |  or a derived token
            +------------+------------+
            v            v            v
      +----------+ +----------+ +----------+
      |  Orders  | | Catalog  | |  Users   |
      +----------+ +----------+ +----------+
       Each service: re-validates the token, makes authz decisions
       on its own data ('may this user cancel this order?'),
       applies business rules. Network position grants no trust.

A shared gateway authenticates every request and routes it to a BFF owned by the client team, and each downstream service re-validates the propagated token before making authorization decisions on its own data.

Gateway aggregation and where it stops being configuration

Gateway aggregation means a single inbound request fans out to several upstream services, and the gateway merges the responses before returning them. A product page might need catalog data, current price, and stock level. The gateway calls all three in parallel and returns one JSON document. When the merge is stable and mechanical, such as fixed upstreams, no branching, and fields copied into named keys, this belongs in gateway configuration. It saves the client round trips and needs no extra service.

Trouble starts when the merge needs decisions. Typical examples:

  • Skip the stock call for digital goods.
  • Hide the price when the user's region has no active contract.
  • Return a smaller payload for the watch app.

Most gateways express these through route conditions, templating, or scripted plugins. Each one works on its own. Together they amount to an application with no unit tests, no type checking, and no local debugger, deployed through the gateway's release process. Rollback is usually all-or-nothing across every route. A change for one screen goes through the platform team that owns the gateway, so feature work waits in their queue.

Compare the route config with the equivalent BFF handler shown alongside. The config states upstreams and a merge template, and its conditional rules sit in expressions that nothing compiles. The handler is ordinary code. The conditions are if statements, upstream clients can be mocked in tests, and the logic goes through normal code review. The frontend team that owns the screen can ship it independently.

// BFF handler for the product page. Upstream clients are injected so tests can stub them.
type Product = { id: string; name: string; kind: "physical" | "digital" };
interface Deps {
  catalog: { get(id: string): Promise<Product> };
  stock: { get(id: string): Promise<{ available: number }> };
  pricing: { get(id: string, region: string): Promise<{ amount: number; currency: string }> };
  contracts: { isActive(region: string): Promise<boolean> };
}

export async function productPage(deps: Deps, id: string, region: string, client: "web" | "watch") {
  const product = await deps.catalog.get(id);

  // Business rule: digital goods have no stock, so skip that upstream call entirely.
  const stockCall = product.kind === "physical" ? deps.stock.get(id) : Promise.resolve(null);
  const [stock, contractActive] = await Promise.all([stockCall, deps.contracts.isActive(region)]);

  // Business rule: hide the price when the region has no active contract.
  const price = contractActive ? await deps.pricing.get(id, region) : null;

  // Per-screen shaping: the watch app gets a minimal payload.
  if (client === "watch") {
    return { id, name: product.name, inStock: stock === null || stock.available > 0 };
  }
  return { id, name: product.name, kind: product.kind, price, stock: stock?.available ?? null };
}

// Ordinary unit test with stubbed upstreams; runs locally and in CI like any other code.
import { test } from "node:test";
import assert from "node:assert/strict";

test("digital goods skip stock and hide price without a contract", async () => {
  const deps: Deps = {
    catalog: { get: async (id) => ({ id, name: "E-book", kind: "digital" }) },
    stock: { get: async () => { throw new Error("stock must not be called"); } },
    pricing: { get: async () => ({ amount: 9.99, currency: "EUR" }) },
    contracts: { isActive: async () => false },
  };
  const body = await productPage(deps, "sku-123", "region-x", "web");
  assert.equal(body.price, null);
});

A BFF handler where the conditional fan-out, price-hiding rule and watch-specific payload are plain if statements with injected upstream clients, so the logic can be unit-tested, type-checked and reviewed instead of living in uncompiled gateway expressions.

Latency, timeouts and failure behavior in aggregation layers

A server-side composition layer adds latency by default. Whether it saves latency depends on how the fan-out is structured and how it behaves when one dependency is slow.

Illustrative estimate (assumed numbers, not a benchmark). These are the assumed inputs:

  • three downstream calls at 80 ms, 120 ms and 60 ms
  • 10 ms overhead for the extra BFF hop
  • 150 ms mobile round-trip time (RTT)
  • data-center hops folded into the call latencies
Composition Formula Result
Client, sequential 3 × RTT + sum(calls) 710 ms
Client, parallel RTT + max(calls) 270 ms
BFF, sequential RTT + overhead + sum(calls) 420 ms
BFF, parallel RTT + overhead + max(calls) 280 ms

The aggregated call wins clearly when the calls depend on each other or the client cannot run them in parallel. Against well-parallelized client calls, a parallel BFF costs one hop's overhead. Its benefits are fewer radio wake-ups and a smaller payload.

Set each downstream deadline below the caller's remaining budget. Without that, the BFF is still waiting when the client has already given up. Decide per field whether a failure is fatal:

  • Required data (for example, account state): fail the whole request.
  • Optional data (for example, recommendations): return a partial response with an explicit marker, so the client renders a placeholder instead of guessing.

Retry at one layer only. Three attempts at each of the gateway, the BFF and the service can produce 3 × 3 × 3 = 27 requests against a struggling dependency. Retry only idempotent calls, and only within the remaining deadline.

Emit a span per downstream call and propagate trace context across every hop. Otherwise a single composed latency number hides which dependency was slow.

// BFF handler: parallel fan-out where each call's deadline fits inside the caller's remaining budget.
// No retries here. Retries belong to one layer only (see text).
const SAFETY_MARGIN_MS = 50; // time reserved to build and send the response

async function getJson(url: string, timeoutMs: number): Promise<unknown> {
  // AbortSignal.timeout cancels this call once its own deadline passes.
  // Use instrumented fetch or OpenTelemetry so each call gets its own span.
  const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
  if (!res.ok) throw new Error(`${url} returned ${res.status}`);
  return res.json();
}

export async function getHome(userId: string, deadlineAt: number) {
  const remaining = deadlineAt - Date.now() - SAFETY_MARGIN_MS;
  if (remaining <= 0) throw new Error("caller deadline already exceeded");
  // A per-call timeout never exceeds what the caller has left.
  const budget = (ms: number) => Math.min(ms, remaining);
  const id = encodeURIComponent(userId);

  // allSettled waits for every call, so one failure does not discard the others.
  const [account, orders, recs] = await Promise.allSettled([
    getJson(` budget(300)),
    getJson(` budget(300)),
    getJson(` budget(150)),
  ]);

  // Required data: any failure fails the whole request.
  if (account.status === "rejected") throw account.reason;
  if (orders.status === "rejected") throw orders.reason;

  // Optional data: an explicit marker lets the client render a placeholder.
  return {
    account: account.value,
    orders: orders.value,
    recommendations:
      recs.status === "fulfilled"
        ? { status: "ok", items: recs.value }
        : { status: "unavailable" },
  };
}

A TypeScript BFF handler runs three downstream calls in parallel, gives each a timeout capped by the caller's remaining deadline, fails the request when required data is missing, and returns an explicit 'unavailable' marker for optional data.

When to use a gateway, a BFF, or both, and when to avoid each

The choice depends less on the technology than on how many clients you serve, how far apart their needs are, and who will own the extra service.

Criterion Gateway only BFF (behind a gateway)
Distinct client types One, or several with near-identical payloads Two or more with divergent screens and data shapes
Data-need divergence Same resources, different auth or rate limits Different fields, aggregation and pagination per client
Team boundaries One team owns the API and its consumers Each frontend team owns its own backend contract
Release cadence Clients and services ship together Mobile releases lag services, so contracts must stay stable
Ownership A platform team runs the gateway A platform team runs the gateway, frontend teams run the BFFs

Avoid a BFF when there is only one client, because it becomes another hop without a second consumer to justify it. Avoid it when it only proxies calls, which adds latency and deployment overhead and removes nothing. Avoid it when the owning frontend team cannot run a production service: monitoring, autoscaling, dependency patching and on-call are part of the job. If no one can own those, the BFF degrades until it becomes an outage.

Keep business logic out of the gateway for a different reason. Every client shares it, so it is a single failure domain. One slow aggregation route can exhaust shared connection pools and threads, creating a noisy-neighbor effect for unrelated traffic. A bad route or transform deploy breaks every consumer at once. The gateway should handle cross-cutting concerns: TLS, authentication, rate limiting and routing. Composition logic should live where its blast radius covers one client.

BFF granularity, duplicated logic and the GraphQL alternative

The default split is one BFF per client type: web, iOS, Android, perhaps a partner API. It maps cleanly to teams that ship a single client, but in large organizations it can produce a "mobile BFF" that every product squad edits and nobody owns. Splitting per product area (checkout, search, account) inside each client gives each BFF a clear owner, at the cost of more deployables.

Granularity matters less than a specific warning sign: the same domain rule implemented in several BFFs. Typical examples are a discount calculation, or a check on whether a user can edit a shared document. When web and mobile drift, users see different prices or permissions depending on the device. Adding a shared BFF or library doesn't fix this, because it recreates the coupling the BFFs were meant to remove. The rule belongs in a downstream service that both BFFs call.

Alternatives to a dedicated BFF

Option Who shapes responses Main risk
Direct calls behind a gateway Client Chatty mobile traffic; clients coupled to service contracts
Gateway-only aggregation Gateway config Composition logic grows in a shared, high-blast-radius layer
GraphQL as a single BFF Client query Expensive queries, harder HTTP caching, unclear schema ownership

GraphQL moves response shaping into the client's query, which removes much of the per-client endpoint work. It also adds new jobs:

  • Cost control. You need depth or complexity limits to stop expensive queries.
  • Caching. Responses no longer fit simple URL-keyed HTTP caching.
  • Ownership. Someone has to govern the schema.

Without an owner, the graph drifts back into the one-size-fits-all API that BFFs were introduced to replace.

Impact on development cost and engineering time

Every layer you add has a fixed cost that does not shrink with traffic. A BFF brings another network hop, another deployment pipeline, another service to patch and scale, and another on-call rotation. It is also one more place where timeouts and retries must be configured, and one more place where they can disagree with the layers around it. Count these per client: three BFFs means three of each, not one.

The time savings come from removing coordination, not from writing less code. When a client team owns its BFF, it can ship a screen change and the matching composition change in the same release. It does not need to negotiate a new endpoint with each backend team or file a ticket against a central platform team. The gateway's change queue gets shorter because client-specific shaping no longer lands there. Edge concerns such as authentication, rate limiting, and TLS termination are built once at the gateway, so they do not have to be reimplemented in every service.

Adopting the split incrementally

  • Start with the gateway only. Use it for cross-cutting concerns that apply to all traffic.
  • Add a BFF when composition diverges. The trigger is a client that needs different aggregation, payload shapes, or call sequences that the gateway can only express through growing configuration. A new client type alone is not enough.
  • Assign ownership explicitly. The team that consumes a BFF should run it, including on-call. A BFF owned by "everyone" turns back into a shared gateway.
  • Push shared behavior downstream deliberately. When two BFFs implement the same rule, move it into the owning backend service rather than copying it a third time.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to api gateway vs backend for frontend, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.