What an API gateway and a BFF each own
An API gateway is the single, shared entry point that every client request crosses. Its job is policy, not meaning. It matches requests by path, host, or header and forwards them to the right upstream. It terminates TLS and validates tokens before anything reaches a service. It enforces rate limits and request size limits, and it emits uniform logs, metrics, and traces. It should know nothing about what an "order" is. Keeping it free of business logic is what lets a platform team run one gateway for dozens of services without becoming a bottleneck for every product change.
A backend for frontend (BFF) is a backend dedicated to one client experience, such as the mobile app or the web checkout, and owned by the team that builds that frontend. It calls several microservices and merges their responses. It drops fields the screen never renders and maps domain models into view models shaped for that UI. Because one team owns both the BFF and its client, they can change both together without negotiating with every other consumer.
| Concern | API gateway | BFF |
|---|---|---|
| Owner | Platform team | Frontend team |
| Scope | All clients | One client experience |
| Logic | Policy only | Composition and shaping |
| Changes when | Security or traffic rules change | A screen changes |
The two sit at different layers, so choosing between them is the wrong framing. What you need to decide is where edge concerns end and client composition begins. Teams that skip the BFF usually build one anyway. It ends up inside the gateway as response transforms and aggregation scripts, owned by nobody who understands the screens they serve.
How requests flow from client to gateway to BFF to services
[ iOS / Android ] [ Web SPA ]
\ /
v v
+------------------------------+
| API Gateway |
| TLS, token check, limits |
+------------------------------+
| |
v v
+------------+ +------------+
| Mobile BFF | | Web BFF |
+------------+ +------------+
| | \ / |
v v v v v
[orders] [catalog] [payments] [shipping]
The BFFs are not exposed directly to clients. They sit behind the gateway so that TLS termination, token validation, rate limiting, and request size limits are configured once and applied the same way to every client. If each BFF faced the internet, each team would reimplement that edge code, and the implementations would drift. A web BFF might enforce a stricter limit than the mobile one, or miss a header-stripping rule. With a single gateway, security reviewers have one policy surface to audit. BFF teams can then spend their effort on composition, not perimeter defense.
Identity across layers
The gateway validates the client's token (signature, expiry, audience) and rejects bad requests before they use BFF capacity. The BFF then needs to tell downstream services who the caller is. It does this in one of two ways. It can forward the validated token, or it can exchange it for a narrower, service-scoped token, which limits the damage if a single hop is compromised.
Services must still enforce authorization themselves. A request that reaches the orders service has passed the gateway, but that does not mean this user may read order 4182. Network position is not proof of permission. Internal traffic can come from a misrouted call, a compromised pod, or another BFF with broader access. Each service checks ownership and scope against the user context it receives.
[ iOS / Android ] [ Web SPA ]
| user access token | user access token
v v
+-------------------------------------------------------------+
| API Gateway |
| TLS termination | verify signature, expiry, audience |
| rate limits | request size limits | reject bad tokens here |
+-------------------------------------------------------------+
| validated request | validated request
v v
+----------------------+ +----------------------+
| Mobile BFF | | Web BFF |
| forward user token | | forward user token |
| OR exchange it for | | OR exchange it for |
| a service-scoped | | a service-scoped |
| token per call | | token per call |
+----------------------+ +----------------------+
| user context + token | user context + token
v v
+----------------------+ +----------------------+
| orders service | | payments service |
| verify token again | | verify token again |
| authz: may this | | authz: may this |
| user read order | | user charge this |
| 4182? | | account? |
+----------------------+ +----------------------+
Network position is not proof of permission.
Identity flows through every layer: the gateway validates the client token once at the edge, each BFF forwards it or exchanges it for a narrower service-scoped token, and every service still makes its own authorization decision.
API gateway routing mechanics you actually configure
Most gateway routes reduce to four decisions, and the route config example shows them side by side.
Matching. Host matching (api.example.com vs partners.example.com) separates audiences. Prefix matching (/orders/) picks the upstream. Keep prefixes non-overlapping, or define precedence explicitly. When /orders and /orders/export resolve to different backends by accident, the failures look intermittent and are hard to trace.
Rewriting. Strip the public prefix so /mobile/orders/42 reaches the BFF as /orders/42. The backend's internal paths can then change without touching any client.
Header-based splits. A header such as x-canary: true or x-api-version: 2 can send a subset of traffic to a new deployment. This makes the canary deterministic. You choose who gets the new path, instead of leaving it to a random percentage.
Timeouts and retries. Set these per route, because a report export and a cart lookup have very different latency budgets.
Retries are only safe on idempotent methods such as GET, PUT, and DELETE, or on a POST that carries an idempotency key the service deduplicates. Without that guarantee, a retried payment can charge twice.
Layered retries also multiply load. If the client, gateway, BFF, and mesh each retry three times, one failing call can become 81 attempts against a service that is already struggling. Give one layer the retry budget, usually the one closest to the failing dependency, and turn retries off everywhere else.
Keep the gateway thin. Every client passes through it, so a bad rewrite or an overly broad rate limit breaks every client at once. Put composition logic in the BFF, where a failure only affects one client.
# Vendor-neutral route config (illustrative schema, not a specific product)
routes:
# Canary first: header match takes precedence over the plain prefix route below
- name: mobile-orders-canary
match:
host: api.example.com
prefix: /mobile/orders/
headers:
x-canary: "true" # deterministic split: you choose who sends this
rewrite:
prefix: /orders/ # /mobile/orders/42 -> /orders/42
upstream: mobile-bff-v2
timeout: 2s
retries: 0 # retry budget is owned by the BFF, not here
- name: mobile-orders
match:
host: api.example.com
prefix: /mobile/orders/
rewrite:
prefix: /orders/
upstream: mobile-bff-v1
timeout: 2s # cart/order lookups: tight latency budget
retries: 0
- name: partner-reports-export
match:
host: partners.example.com # host separates audiences
prefix: /reports/export/ # non-overlapping with other partner prefixes
upstream: reporting-service
timeout: 60s # exports are slow by design
retries:
attempts: 2 # this layer owns the budget for this route
on: [connect-failure, 503]
methods: [GET] # idempotent only; POST would need an idempotency key
A vendor-neutral route config showing host and prefix matching, prefix rewriting, a header-based canary split, and per-route timeouts with retries enabled on only one idempotent route so a single layer owns the retry budget.
Microservice aggregation for an order details page: client vs gateway vs BFF
Consider an order details screen that needs four things: the order itself, the customer's name and address, stock status for each line item, and the shipment's tracking state. Each lives in a different service, and there are three places to stitch them together.
| Concern | Client aggregation | Gateway plugins or scripts | BFF |
|---|---|---|---|
| Client round trips | Four | One | One |
| Failure handling lives in | Every client app | Shared gateway config | BFF code for this screen |
| Owner | Each frontend team | Platform team | The frontend team that owns the screen |
| Blast radius of a bug | One app version | All traffic through the gateway | One client type |
The BFF handler in the code example below starts all four downstream calls concurrently, and each call has its own timeout. Order and customer data are critical, so if either fails the handler returns an error. Shipment is not critical. If it times out, the handler still returns the order with shipment: null and a degraded flag, and the UI shows "tracking unavailable" in place of a blank page.
Illustrative estimate (assumed inputs, not measured): With a 60 ms mobile RTT, four sequential client calls cost about 4 × 60 = 240 ms of network time. With one call to a BFF that fans out in parallel over a data-center link at about 10 ms per call, the cost is about 60 + 10 = 70 ms. Processing time is ignored in both cases.
Parallel fan-out has a cost. The response is only as fast as the slowest of the four calls, so the endpoint's tail latency tracks the worst downstream tail. Per-call timeouts put a hard ceiling on that wait.
// BFF handler for the order details screen (Node 18+, global fetch).
async function getJson(url: string, timeoutMs: number): Promise<unknown> {
// Each downstream call gets its own deadline; AbortSignal.timeout aborts the fetch.
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 getOrderDetails(orderId: string) {
// Start all four calls at once. Total wait is capped by the longest timeout.
const [order, customer, inventory, shipment] = await Promise.allSettled([
getJson(` 300),
getJson(` 300),
getJson(` 200),
getJson(` 150),
]);
// Critical data: without the order and customer there is nothing useful to render.
if (order.status === "rejected" || customer.status === "rejected") {
return { status: 502, body: { error: "order details unavailable" } };
}
// Noncritical data: fall back to null so the UI can show "tracking unavailable".
const orNull = (r: PromiseSettledResult<unknown>) =>
r.status === "fulfilled" ? r.value : null;
return {
status: 200,
body: {
order: order.value,
customer: customer.value,
inventory: orNull(inventory),
shipment: orNull(shipment),
// Tell the client that it received a partial response.
degraded: inventory.status === "rejected" || shipment.status === "rejected",
},
};
}
A BFF handler starts four downstream calls in parallel, gives each one its own timeout, fails only when order or customer data is missing, and returns a partial response flagged as degraded when inventory or shipment fails.
When you need a BFF and when a gateway alone is enough
The aggregation question becomes a staffing question once the endpoint exists: someone has to own it. A BFF is a deployable service with its own on-call, release pipeline, and failure modes. Only adopt one when the coordination cost it removes is larger than the operational cost it adds.
| Signal | Points toward | Why |
|---|---|---|
| Web, mobile, and TV clients need different fields and shapes from the same data | BFF | Each client gets a tailored response, so no client carries another client's fields |
| Mobile clients on metered or high-latency networks | BFF | Fewer round trips and smaller payloads reduce radio wake-ups, which drain battery |
| UI redesigns change response shapes every sprint | BFF | The churn stays in a layer the frontend team controls, not in shared domain services |
| Frontend tickets sit blocked waiting for backend teams to add or reshape fields | BFF | Moves the change into the team that needs it |
| One client type | Gateway alone | A BFF for a single consumer is just an extra hop with an extra owner |
| Services already expose resources that match what screens need | Gateway alone | There is nothing to compose, so routing, auth, and rate limiting cover it |
| Public API for third-party developers | Gateway alone | External consumers need a stable, generic, versioned contract, and you cannot tailor it to clients you don't know |
Most of these signals only matter in combination. A single signal, like one awkward endpoint, is usually better fixed in the service itself. Several signals that persist across releases mean the BFF is solving a recurring problem rather than a one-off.
Ownership and boundary rules that keep BFFs healthy
Once you decide a BFF is earning its place, the boundary rules matter more than the framework you build it in.
One BFF per client experience. The iOS app, the web storefront and the partner portal each get one, because each has its own screen shapes, payload budgets and release cadence. Splitting BFFs by backend team or by downstream service recreates the service topology at the edge, and the client ends up composing again.
The frontend team owns and operates it. The team that changes the screen should be able to change the endpoint behind it in the same release, and should also carry the on-call load for it. If a separate team operates the BFF, it turns into a ticket queue between the UI and the data it needs.
No domain logic. Discount eligibility, stock reservation and order state transitions belong in services. Once a business rule lands in a BFF, that BFF becomes a hidden domain service that other clients cannot reuse, and every frontend release now has to be checked for domain correctness.
Move shared concerns out of the BFFs. Token validation, rate limiting and TLS belong in the gateway. Shared computations belong in services. Do not copy them into each BFF.
Multiple BFFs tend to drift apart. One returns cursor pagination for orders while another uses offsets, or the two map the same upstream failure to different error bodies. Agree on a few conventions, such as the error envelope, pagination style and correlation headers, and ship them in a thin shared library. Keep that library limited to conventions. If it starts accumulating business logic, you are rebuilding a monolith with extra network hops.
Alternatives and the impact on cost and engineering time
A BFF is not the only way to avoid pushing composition into clients. Three alternatives come up most often, and each moves the complexity somewhere else rather than removing it.
| Approach | Where composition lives | What it costs you |
|---|---|---|
| Gateway-only, richer services | Services expose client-ready endpoints | Services couple to UI needs; every screen change becomes a backend ticket |
| Gateway plugins or scripting | Shared gateway config or scripts | One deployable for all clients; a bad script affects every route |
| GraphQL or another generic aggregation layer | A shared schema plus resolvers | Schema governance, query cost limits, and harder HTTP caching |
GraphQL is the strongest of these when you have many clients with overlapping needs, because one schema serves all of them. The work then shifts into deciding who owns each type, rejecting expensive queries before they reach services, and caching at the resolver or field level, since one URL no longer maps to one cacheable response.
A BFF also has real costs. It adds a network hop, another deployable, and another on-call rotation. For a single, simple client that mostly passes requests through, those costs can be larger than the benefit. In that case a gateway and well-shaped service endpoints are enough.
The time savings show up as clients multiply. Frontend teams can change response shapes in code they own, without filing tickets with other teams. Gateway changes stay rare and low-risk. Aggregation, timeouts, and partial-failure handling live in one place per client, instead of being duplicated across apps or tangled into shared gateway scripts that nobody wants to touch.
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.