What an API gateway and a BFF each do
The API gateway vs backend for frontend question is usually framed as a choice. That framing is wrong. They are different layers, owned by different teams, and they change at different speeds. Most systems that serve more than one kind of client end up needing both.
An API gateway is the single shared entry point for all external traffic. It does the following:
- Terminates TLS.
- Authenticates requests.
- Enforces rate limits.
- Emits consistent logs, metrics and traces.
- Routes each request to the right upstream by host, path or header.
It should be product-agnostic. The gateway does not know what a checkout screen needs. It only knows that /orders/* goes to the orders service and that the caller presented a valid token. Keeping it ignorant of product logic lets one platform team run it for every client without becoming a bottleneck.
A BFF (backend for frontend) is a separate backend for each client experience, such as web, iOS or a partner integration. It is usually owned by the team that builds that client. It calls several downstream services, merges the results and reshapes them into exactly what one screen needs. The BFF pattern exists because a mobile app on a slow network and a desktop dashboard want different payloads. One generic API forces either chatty clients or bloated responses.
| Aspect | API gateway | BFF |
|---|---|---|
| Core responsibility | Routing, TLS, authentication, rate limiting, observability | Aggregation, response shaping, client-specific orchestration |
| Owner | Platform or infrastructure team | Client or product team |
| Change frequency | Low; changes are cross-cutting and reviewed carefully | High; changes ship with UI features |
| Typical logic | Declarative route rules, token validation, quotas | Fan-out to services, field selection, view-model mapping |
| Number of instances | One shared entry point | One per client experience |
How gateway routing works and where it should stop
A gateway matches each incoming request against an ordered set of rules and forwards it to an upstream service. The common match keys are:
- Path:
/orders/*goes to the order service and/catalog/*goes to the catalog service. This is the default because it maps cleanly onto service boundaries. - Host:
api.example.comandpartners.example.comresolve to different route tables, which separates audiences without path prefixes. - Header: a version header or a canary flag selects between two deployments of the same service.
Some transformations are reasonable at the gateway because they are uniform and stateless. These include injecting a request ID, stripping inbound headers that clients should not set, forwarding verified identity claims as headers, and rewriting /orders/v2/* to the upstream's /*. None of them depend on what a particular screen needs.
Other transformations do not belong there. Payload reshaping, such as renaming fields, merging responses, or filtering arrays by user role, encodes knowledge about consumers. Business rules, such as "free-tier users cannot call this endpoint after 6pm," encode knowledge about the domain. Once either lands in the gateway, the shared entry point needs a change for every feature, and the platform team becomes a queue that product teams wait in.
The config shown below keeps each route to a match, a target, and a short list of header and path operations. A platform team can review and operate a file like that with confidence. Gateways that accumulate scripted transforms and multi-call aggregation become hard to test. That logic sits in configuration or plugins, usually without unit tests or local runs, and a bad change can break every client at once.
# Generic, vendor-neutral gateway routing config (illustrative schema).
# Rules are evaluated top to bottom; first match wins.
# Each route is only: match -> target -> header/path operations.
routes:
# Header match: canary flag selects a second deployment of the same service.
- match: { host: api.example.com, path_prefix: /orders/v2/, header: { x-canary: "true" } }
target:
rewrite: { strip_prefix: /orders/v2 } # /orders/v2/123 -> /123 upstream
# Path match: the default, maps onto service boundaries.
- match: { host: api.example.com, path_prefix: /orders/v2/ }
target:
rewrite: { strip_prefix: /orders/v2 }
- match: { host: api.example.com, path_prefix: /catalog/ }
target:
# Host match: partners get their own route table, no path prefix needed.
- match: { host: partners.example.com, path_prefix: / }
target:
# Uniform, stateless operations applied to every route.
defaults:
request_headers:
remove: [x-user-id, x-user-roles] # clients must not set identity headers
set:
x-request-id: "${request_id}" # generated per request by the gateway
x-user-id: "${jwt.sub}" # only from a verified token
# Deliberately absent: field renames, response merging, role-based
# array filtering, time-of-day rules. Those belong in a BFF or the service.
A generic declarative routing file that uses path, host and header matches with prefix rewrites and uniform header operations, and keeps payload reshaping and business rules out of the gateway.
Edge aggregation in a BFF: fan-out, timeouts and partial failure
Mobile clients pay for every round trip. A screen that needs a profile, an order list, recommendations, a cart count and feature flags often makes five requests. Each one crosses the radio link, and each dependent request waits for the one before it. A BFF moves that fan-out into the data center, where calls between services are short and can run in parallel.
Illustrative estimate (assumed numbers, not a benchmark). Assume 150 ms mobile RTT, five calls, 40 ms service time per call, and 10 ms of BFF overhead.
- Sequential from the client:
N × (RTT + service_time)= 5 × (150 + 40) = 950 ms - One aggregated call:
RTT + max(service_time_i) + bff_overhead= 150 + 40 + 10 = 200 ms
This ignores connection setup and payload transfer time. Use your own RTT and the latency percentiles you actually measure.
The handler in the example below starts all five calls at once, and each call has its own timeout. Profile and orders are critical, so if either fails the BFF returns an error. Recommendations and flags are optional, so on failure the BFF sets those fields to empty and marks them degraded so the client can hide those sections. The handler then maps the results into one payload shaped for that screen and drops fields the client does not use.
Two limits apply:
- Aggregation only helps when it replaces several client round trips. A BFF endpoint that wraps a single call adds a network hop and saves nothing.
- Parallel calls finish only when the slowest one does. Without a per-call timeout and a circuit breaker on each dependency, one slow service holds up the whole response.
// Mobile home-screen BFF handler: one client round trip, parallel fan-out inside the data center.
type Json = Record<string, any>;
const BASE = ""; // placeholder internal host
// Each dependency gets its own deadline so one slow service cannot stall the response.
// In production, wrap call() in a per-dependency circuit breaker as well.
async function call(path: string, timeoutMs: number): Promise<Json> {
const res = await fetch(BASE + path, { signal: AbortSignal.timeout(timeoutMs) });
if (!res.ok) throw new Error(`${path} -> ${res.status}`);
return res.json();
}
export async function homeScreen(userId: string) {
// Start all five calls at once. Total wait is about the slowest call, capped by its timeout.
const [profile, orders, recs, cart, flags] = await Promise.allSettled([
call(`/profile/${userId}`, 300),
call(`/orders?user=${userId}&limit=5`, 400),
call(`/recommendations/${userId}`, 250),
call(`/cart/${userId}/count`, 200),
call(`/flags?client=ios`, 150),
]);
// Critical: the screen cannot render without profile and orders, so fail the whole request.
if (profile.status === "rejected" || orders.status === "rejected") {
return { status: 502, body: { error: "home_unavailable" } };
}
// Optional: use an empty fallback and record the name so the client can hide that section.
const degraded: string[] = [];
const optional = <T>(name: string, r: PromiseSettledResult<Json>, pick: (v: Json) => T, empty: T): T =>
r.status === "fulfilled" ? pick(r.value) : (degraded.push(name), empty);
// Shape one payload for this screen and drop fields the client never renders.
const body = {
user: { name: profile.value.displayName, avatarUrl: profile.value.avatarUrl },
recentOrders: orders.value.items.map((o: Json) => ({ id: o.id, status: o.status, total: o.total })),
recommendations: optional("recommendations", recs, (v) => v.items.slice(0, 10), []),
cartCount: optional("cart", cart, (v) => v.count, 0),
flags: optional("flags", flags, (v) => v.enabled, {}),
degraded,
};
return { status: 200, body };
}
A TypeScript BFF handler that fans out to five services in parallel with per-call timeouts, fails the request only when a critical dependency fails, marks optional sections as degraded, and returns one payload shaped for the mobile home screen.
The layered topology and how identity flows through it
client (web / iOS / partner)
│ TLS
▼
API gateway TLS termination, token validation, rate limits, coarse routing
│
┌────┴─────┐
▼ ▼
web BFF mobile BFF aggregation, response shaping
│ │
└────┬─────┘
▼
domain services business rules, authorization decisions
Each hop owns the concerns it is best placed to apply once. The gateway sees every request, so it terminates TLS, rejects malformed or expired tokens, and enforces rate limits before traffic reaches anything expensive. Coarse routing by host or path prefix stops there.
Validating the token at the gateway means BFFs and services don't each need their own copy of signature and expiry checks. The gateway forwards verified user context, such as subject, tenant and scopes, as headers or as the original token. Services must accept those headers only from trusted internal callers. Otherwise anyone who can reach a service directly can claim to be any user.
Authorization belongs in the domain services, not the BFFs. "Can this user cancel this order?" depends on order state that only the order service holds. If each BFF re-implements that check, the web and mobile rules drift apart. A BFF may hide a button. The service still enforces the rule.
Generate a correlation ID at the gateway, or accept an incoming one, and propagate it on every downstream call. W3C Trace Context headers make this interoperable across languages. A single request can touch a gateway, a BFF and five services owned by four teams. Without a shared trace you can see that it was slow, but not which team's service caused it.
┌──────────────────────────────────────┐
│ client (web / iOS / partner) │
└──────────────────┬───────────────────┘
▼ HTTPS + bearer token
┌──────────────────────────────────────┐
│ API gateway │
│ TLS termination │
│ token validation: signature, expiry │
│ rate limits, coarse routing │
│ create or accept traceparent │
└─────────┬──────────────────┬─────────┘
│ ctx+traceparent │ ctx+traceparent
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ web BFF │ │ mobile BFF │
│ aggregation │ │ aggregation │
│ response shaping│ │ response shaping│
└─────────┬────────┘ └───────┬──────────┘
└────────┬─────────┘
▼ ctx + traceparent
┌──────────────────────────────────────┐
│ domain services │
│ business rules │
│ authorization decisions │
│ trusts headers from internal callers│
└──────────────────┬───────────────────┘
▼ spans
┌──────────────────────────────────────┐
│ one trace: gateway → BFF → services │
└──────────────────────────────────────┘
The gateway validates the token once and starts or accepts the trace context. BFFs aggregate and shape responses, then pass the verified user context and traceparent on to domain services. The services make authorization decisions and send spans to a shared trace.
Who should own each layer and who needs a BFF
Ownership settles most of the argument. A BFF exists so that a client team can change the shape of its API at the speed its UI changes. If a central team owns the BFF, every new screen field becomes a ticket in someone else's backlog, and the BFF turns into a second, more specialized gateway with the same queue. The team that ships the iOS app or the web checkout should own its BFF's code, deployments and on-call rotation.
Platform engineering should own the gateway. Its concerns are TLS termination, rate limiting, authentication handoff and route configuration, and they apply to every client equally. They change on infrastructure timelines, not product timelines.
Who actually benefits
The pattern pays off when two conditions hold together. You have client experiences that differ substantially, such as a mobile app that needs compact payloads and a partner integration that needs stable, versioned contracts. And separate teams build those clients. A single team shipping one web app gets little from a BFF except an extra hop and an extra deployment.
Splitting by device or by experience
Splitting by device (iOS, Android, web) is the obvious choice, but iOS and Android often render the same screens from the same data. Two BFFs for them drift into near-identical services that must be patched twice. Splitting by experience, such as a shopper app versus a merchant dashboard, usually follows real differences in data and workflow. Before creating a new BFF, check whether its responses would differ meaningfully from an existing one, and whether a different team would own it.
When to avoid a BFF, signs the boundary is wrong, and alternatives
A BFF earns its cost only when clients genuinely diverge. Skip it when:
- There is one client type. A single web app gains nothing from a layer that shapes responses for itself alone. Put that logic in the app or the services.
- The API is public with a stable contract. External consumers need versioned, documented resources, not views tailored to your own UI. A BFF would add a second contract to maintain.
- The team is small. One gateway in front of well-designed service APIs is cheaper than a deployable that the same few people must build, monitor and keep in sync.
A GraphQL or similar query layer is an alternative aggregation point. Clients choose fields, and resolvers fan out to services. This moves shaping work from server teams to client queries. In return, the platform has to handle query cost limits, resolver-level authorization and caching that plain HTTP caching handles less well.
Signs the boundary is wrong
- Gateway plugins or scripts contain business rules or per-client response shaping. That logic has no clear owner and is hard to test.
- BFFs duplicate domain rules such as pricing or eligibility, or call each other. The domain belongs in services, and BFF-to-BFF calls create a hidden dependency graph.
- One shared "BFF" serves every client. That makes it a second monolithic gateway with every team's release coupled to it.
| Option | Fit | Ownership | Operational load |
|---|---|---|---|
| Gateway only | One client, public APIs, small teams | Platform team | Low |
| Gateway + BFFs | Distinct clients with different workflows | Each client team owns its BFF | Medium to high, one service per client |
| Gateway + GraphQL layer | Many clients with overlapping data needs | Platform or API team owns the schema | Medium, concentrated in query governance |
Impact on development cost and engineering time
The cost of a BFF lies mostly in the work around its code. Each one is another deployable service with its own pipeline, dashboards, alerts, dependency upgrades, security review and on-call rotation. That overhead grows with the number of client types, not with traffic.
Illustrative estimate (assumed numbers, not a benchmark): ongoing overhead = BFF count × operational hours per BFF per month. Assume three clients (web, iOS, Android) and 10 hours a month per BFF for upgrades, alert tuning and incident follow-up. That gives 3 × 10 = 30 engineer-hours a month before any feature work. Substitute your own client count and hours.
Duplication adds more. Each BFF reimplements pagination, error mapping and auth-context extraction. Moving that code into a shared library replaces duplication with coupling. A breaking change to the library then forces coordinated releases across every client team.
The more expensive failure is drift. A discount rule or eligibility check lands in one BFF, and web and mobile start returning different answers for the same user.
The savings are concrete:
- Client teams ship UI-shaped changes without filing platform tickets.
- Screens load in one round trip instead of several.
- The gateway config stays small and stable enough for the platform team to change it safely.
Guardrails:
- BFFs shape and aggregate data but do not make business decisions. If a rule must behave the same on every client, it belongs in a domain service.
- The gateway handles only routing, authentication, rate limiting and TLS. Reject per-screen logic in gateway reviews.
- Keep shared BFF libraries thin, versioned and limited to plumbing.
- Merge or retire a BFF when its client's needs converge with another's.
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.