What an API gateway and a BFF actually do

An API gateway is a shared edge component. Every request passes through it, so it handles concerns that apply to all traffic regardless of which client sent it. These include TLS termination, edge routing by host or path, token validation, rate limiting, request size limits and access logging. It knows little about any one screen, and that is the point: a single team can operate it, and its rules stay uniform.

A backend for frontend (BFF) is a service owned per client type, such as web, iOS, Android or a partner API. It calls domain services, combines their results and shapes the response to fit what a specific screen needs. Its logic is client-specific by design, so the team that ships the client usually owns it.

Framing the choice as "api gateway vs bff" is usually wrong. Most systems that need a BFF still need a gateway in front of it, because someone has to terminate TLS and enforce rate limits before traffic reaches any BFF. The real decision is where each kind of logic is allowed to live.

API gateway BFF
Responsibility Cross-cutting edge policy Response shaping, call orchestration
Owner Platform team Client team
Change cadence Slow, reviewed Fast, follows client releases
Typical failure mode Global outage or misrouting Slow fan-out, one client's screens break

Why the backend for frontend pattern exists

A single general-purpose API looks efficient on paper: one contract, one set of endpoints, one team maintaining it. The cost shows up once several clients depend on it at the same time.

Every client receives the same payloads. An order resource designed for a desktop admin console might carry line-item history, audit fields and nested customer records. A mobile app that only needs a status badge and a total still downloads, parses and discards all of it. On a constrained cellular connection that is wasted bandwidth and battery. The reverse problem is just as common. When a screen needs data from several resources, the client has to make sequential calls, and each round trip on a high-latency network adds delay the user can see.

The deeper problem is organizational. A shared API drifts toward a lowest common denominator: fields nobody can remove because some client might use them, and shapes nobody can optimize because another client depends on the current form. A mobile team that wants a leaner response has to negotiate with the web team, the partner integration team and the API owners. Changes then move at the pace of the slowest consumer.

A BFF breaks that coupling. Each client team gets a backend shaped to its own screens, and it can change that backend on its own release schedule.

The pattern pays off most for organizations with several client types whose data needs, network conditions or release cadences genuinely diverge. A single web app talking to a single API rarely needs one.

How the layered topology works: client, gateway, BFF, domain services

In the common layout, every request crosses the same edge, then splits by client type before reaching the domain services:

 [iOS app]   [Android app]   [Web SPA]   [Partner API]
      \            |             |             /
       +-----------+-------------+------------+
                         |
               +---------v---------+
               |    API gateway    |  edge routing, TLS,
               |                   |  token validation, rate limits
               +---------+---------+
           +-------------+-------------+
           |             |             |
     +-----v----+  +-----v----+  +-----v----+
     |Mobile BFF|  |  Web BFF |  |Partner BFF|
     +-----+----+  +-----+----+  +-----+----+
           +-------------+-------------+
                         |
        +----------------+----------------+
        |                |                |
   [Orders svc]    [Catalog svc]    [Payments svc]

Identity flow

The gateway validates the incoming token's signature, expiry and audience. It then forwards either the original token or a set of verified claims, such as subject, tenant and scopes, in a signed internal token or a header format the services agree on. The BFF passes that identity through on every downstream call. Each domain service still decides whether this caller may read this order. Authorization depends on domain data the gateway does not have.

There are two ways to get this wrong. If only the gateway enforces auth, anything that reaches a service directly is fully trusted. That includes a misrouted internal job, a compromised pod or a debugging port-forward. If every service re-validates on its own terms, with different libraries, claim names and clock-skew tolerances, the same request can be allowed by one service and denied by the next. The fix is one propagation scheme, defined once and enforced the same way everywhere.

Trace context

The gateway should start or continue the trace and forward the trace headers. The BFF has to carry that context onto every fan-out call it makes. If it doesn't, the trace breaks at the BFF, and you can no longer tell which downstream call made a screen slow.

 Client (iOS / Android / Web)
   |  Authorization: Bearer <user token>
   |  traceparent: 00-<trace-id>-<span A>-01
   v
+--------------------------------------------+
| API gateway                                |
|  - verify signature, expiry, audience      |
|  - rate limit per subject / client         |
|  - continue trace as child span B          |
+---------------------+----------------------+
                      |  signed internal token: sub, tenant, scopes
                      |  traceparent: 00-<trace-id>-<span B>-01
                      v
+--------------------------------------------+
| Mobile BFF                                 |
|  - shape responses for the client          |
|  - forward identity + trace on every call  |
+----------+-----------------------+---------+
           |                       |
           | same identity         | same identity
           | same trace, span C    | same trace, span D
           v                       v
+----------------------+  +----------------------+
| Orders svc           |  | Payments svc         |
|  verify internal tok |  |  verify internal tok |
|  authz: owns order?  |  |  authz: may charge?  |
+----------------------+  +----------------------+
 Direct call that skips the gateway (job, pod, port-forward)
   ----X---> rejected by the service: no valid internal token

The gateway validates the user token once and forwards verified claims and trace context through the BFF. Each domain service still verifies the internal token and makes its own authorization decision, so a call that bypasses the gateway is rejected rather than trusted.

API aggregation in practice: one order detail screen, three approaches

Take an order detail screen that needs order, customer, shipment and recommendation data. There are three places to compose it.

The client makes four calls. Fan-out, timeouts and partial-failure handling all run on the device. The client downloads full service payloads and discards most of the fields. Every platform (iOS, Android, web) reimplements the same logic, and each one can get it wrong in a different way.

The gateway composes. Fan-out moves server-side, but a shared edge layer now encodes one screen's needs: which fields to keep and which failures are tolerable. Every client team's changes then compete for the same deploy pipeline.

A BFF composes. The handler shown separately fans out to all four services in parallel, each with its own timeout. Order, customer and shipment are required. Recommendations get a short timeout. On failure they return an empty list with a degraded: true marker. The response is then trimmed to the fields the screen renders.

Illustrative estimate. Assume a 150 ms mobile round trip and a 5 ms data-center hop, and ignore service processing time. Four sequential client calls cost about 600 ms of network time. One BFF call costs about 150 ms plus the slowest internal call, because the internal calls overlap.

Partial failure needs an explicit rule for each field. A missing order fails the request. Missing recommendations only degrade it. Clients must render the degraded shape, and their tests should cover it.

Concern Client calls Gateway composes BFF composes
Fan-out Device, over mobile network Gateway BFF, in data center
Partial failure Each client app Shared gateway config BFF, defined per field
Timeouts Client Gateway BFF, per call
Response trimming Client discards fields Gateway BFF
Network cost (illustrative) ~600 ms ~150 ms + slowest call ~150 ms + slowest call
// BFF handler for the order detail screen: parallel fan-out, per-call timeouts,
// required vs optional data, and trimming to the fields the screen renders.
const BASE = ""; // placeholder internal service host

async function getJson(path: string, timeoutMs: number): Promise<any> {
  // AbortSignal.timeout aborts the request if the service exceeds its budget.
  const res = await fetch(`${BASE}${path}`, { signal: AbortSignal.timeout(timeoutMs) });
  if (!res.ok) throw new Error(`${path} -> ${res.status}`);
  return res.json();
}

// customerId comes from the authenticated session, so all four calls can start at once.
export async function orderDetail(orderId: string, customerId: string) {
  // allSettled waits for every call; total wait is roughly the slowest call.
  const [order, customer, shipment, recs] = await Promise.allSettled([
    getJson(`/orders/${orderId}`, 800),
    getJson(`/customers/${customerId}`, 800),
    getJson(`/shipments?orderId=${orderId}`, 800),
    getJson(`/recommendations?orderId=${orderId}`, 300), // short budget: optional data
  ]);

  // Required data: any failure fails the whole request (map to 502/504 at the route).
  if (order.status === "rejected" || customer.status === "rejected" || shipment.status === "rejected") {
    throw new Error("order detail unavailable");
  }

  // Optional data: degrade to an empty list and say so explicitly.
  const recsOk = recs.status === "fulfilled";

  // Trim: return only the fields the screen renders, not full service payloads.
  return {
    orderId: order.value.id,
    placedAt: order.value.placedAt,
    total: order.value.total,
    customerName: customer.value.displayName,
    shipment: { status: shipment.value.status, eta: shipment.value.eta },
    recommendations: recsOk ? recs.value.items.slice(0, 4).map((r: any) => ({ sku: r.sku, name: r.name })) : [],
    degraded: !recsOk, // clients must render and test this shape
  };
}

A TypeScript BFF handler that calls four services in parallel with per-call timeouts, fails the request when required order, customer or shipment data is missing, returns empty recommendations marked degraded when that call fails, and trims the response to the fields the screen renders.

Drawing the line: what belongs in the gateway and what does not

The rule is simple to state: if logic is stateless, generic, and expressible as policy, it belongs in the gateway. TLS termination, token validation, coarse rate limits, request size caps, and routing by path or host all qualify. Business rules, per-screen composition, and transformations that require knowledge of a domain do not. Every team deploys through the gateway, so any domain logic placed there ties each team's release to a shared component that nobody fully owns.

How gateways become the monolith

The drift is gradual. A team adds a header rewrite, then a field rename for one legacy client, then an if client == "ios" branch, then a scripted plugin that merges two responses. Each change is small and urgent. A year later the gateway config holds hundreds of client-specific conditionals, every change needs a platform review, and a bad plugin can take down traffic for every service at once. At that point it is a monolith at the edge, without tests or clear ownership.

How BFFs drift

BFFs fail in the opposite direction. The web BFF computes a discount, and the mobile BFF reimplements it slightly differently. Permission checks get the same treatment. Two clients then show two prices. Pricing, entitlement, and validation rules must live in domain services. A BFF should call those rules, not copy them.

Caching

A gateway can safely cache generic, public responses keyed by URL. BFF responses are usually personalized. If they are cached at the gateway with a URL-only key, one user's data can be served to another, or mobile-shaped payloads can reach web clients. Cache keys for these responses must include user or tenant identity and client type. The alternative is to mark them uncacheable at the edge.

Running each layer: timeouts, fan-out limits, rate limits and ownership

Every hop adds latency and another place to fail, so each layer needs an explicit timeout that fits inside the timeout of the layer above it. If a BFF waits longer than the gateway does, it keeps doing work after the client has already received an error.

Illustrative estimate (assumed numbers, not a benchmark):

  • Client timeout: 2,000 ms
  • Gateway to BFF: 1,800 ms (leaves 200 ms for gateway overhead and network)
  • BFF to each domain service, called in parallel: 600 ms, with at most 1 retry
  • BFF composition time: 100 ms
  • Check: per-call timeout × (1 + retries) + composition ≤ gateway timeout, so 600 × 2 + 100 = 1,300 ≤ 1,800 ms

For aggregated calls, cap fan-out per request and give each downstream dependency its own bulkhead, meaning a separate connection pool or concurrency limit. A slow inventory service should not use up the threads that pricing needs. If an optional call fails, return a degraded response with the field marked unavailable instead of failing the whole page.

Retries need a budget, for example retries limited to 10% of request volume. Without one, retries multiply across layers: two layers that each try three times can send nine calls to a service that is already struggling.

Rate limiting does two different jobs. At the gateway, limits block abuse per key or IP. At the BFF and the services, concurrency limits protect downstream capacity.

Ownership: the frontend team should own the BFF, or work closely with whoever does, so payload changes ship at the client's pace. A platform team owns the gateway and offers reviewed, self-service configuration. Do not hand BFFs to frontend teams that lack backend on-call experience, service templates, or default dashboards and tracing. That moves the reliability problem to a different team without solving it.

# Illustrative, vendor-neutral config. Field names are not tied to any product.
# Rule: each layer's timeout must fit inside the timeout of the layer above it.
client:
  timeout_ms: 2000

gateway:
  upstream_timeout_ms: 1800      # leaves 200 ms for gateway overhead and network
  rate_limit:                    # edge abuse protection, keyed per caller
    key: api_key                 # fall back to client IP for anonymous traffic
    requests_per_minute: 600
  retries: 0                     # the BFF owns retries, so they do not multiply across layers

bff:
  composition_budget_ms: 100
  max_fanout_per_request: 4      # cap parallel downstream calls per incoming request
  retry_budget:
    ratio: 0.10                  # retries may not exceed 10% of request volume
  # Worst case: 600 ms x (1 + 1 retry) + 100 ms = 1300 ms, which is within 1800 ms
  dependencies:
    pricing:
      required: true             # a failure here fails the response
      timeout_ms: 600
      max_retries: 1
      bulkhead:
        max_concurrent: 50       # own pool, so a slow neighbour cannot starve it
    inventory:
      required: false            # optional: degrade instead of failing the page
      timeout_ms: 600
      max_retries: 1
      bulkhead:
        max_concurrent: 30
      on_failure:
        degrade: true
        field_state: unavailable # client renders the field as unavailable
    recommendations:
      required: false
      timeout_ms: 400            # lower value, so a non-critical call gives up early
      max_retries: 0
      bulkhead:
        max_concurrent: 20
      on_failure:
        degrade: true
        field_state: unavailable

An illustrative, vendor-neutral config that nests timeouts from the client down to each dependency and combines edge rate limits at the gateway with per-dependency bulkheads, a retry budget and degraded responses in the BFF.

When to use a gateway alone, add BFFs, or choose an alternative

Start from how much your clients actually diverge, not from the topology diagram.

Situation Choose Why
Clients need roughly the same data, or the API is a thin public surface Gateway alone One contract and one place for auth and rate limits. Nothing client-specific needs a home.
Web, mobile and partners differ in payload shape, network constraints or release cadence Gateway plus BFFs Each client team owns its aggregation and ships without negotiating payloads with other teams.
Divergence is mostly which fields each client selects Shared GraphQL-style aggregation layer Clients pick fields from one schema. You avoid running near-duplicate services.
Experiences such as checkout and account differ more than devices do BFFs split by product area Ownership follows the teams that change the logic.
One client, few services, simple reads Direct calls through the gateway Extra layers add hops without removing any work.

Skip BFFs when web and mobile share most of their needs, or when the team cannot operate more services. Every BFF adds its own deployment pipeline, on-call rotation, dependency patching and dashboards. That cost recurs for as long as the service exists. The payoff is engineering time: client teams stop waiting on cross-team payload reviews and release on their own schedule. A clean gateway boundary pays off in a similar way. Platform engineers no longer have to coordinate every product change, because product logic never reaches their config.

Choose the split axis carefully. If you split by client type and later find that product area was the real boundary, the fix is to move aggregation logic, endpoints and ownership between services. That is a migration across several teams, not a refactor.

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 bff, 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.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

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.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • 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.