What is the backend for frontend pattern, and how is it different from an API gateway?
A backend for frontend (BFF) is a thin service built for one client type and owned by the team that builds that client. It turns many fine-grained service calls into the single response a screen needs. The edge API gateway is shared infrastructure. It sits in front of every BFF and protects the system as a whole. These are two separate layers because they answer to different owners and change at different rates. Gateway policy changes rarely and affects every caller. BFF response shapes change whenever a screen changes.
Request path
- Client (iOS/Android app, browser)
- → Edge gateway: TLS, authentication, rate limits, routing
- → Mobile BFF or Web BFF: composition and shaping
- → order, customer, inventory and shipping services
- → Mobile BFF or Web BFF: composition and shaping
- → Edge gateway: TLS, authentication, rate limits, routing
Concern-to-layer mapping
| Concern | Edge gateway | BFF |
|---|---|---|
| TLS termination | Yes | No |
| Authentication (token validation) | Yes | Consumes the verified identity |
| Rate limiting | Yes, per client and per key | No |
| Routing to the correct BFF | Yes | No |
| Response composition | No | Yes |
| Field shaping and trimming | No | Yes |
| Protocol adaptation (e.g., gRPC to JSON) | No | Yes |
Some concerns sit between the two layers, such as authorization checks, caching, and request logging. These drift unless you write down which layer owns them and enforce it. Otherwise each BFF team adds its own retry or caching logic on top of the gateway's. The result is duplicated behavior that nobody fully understands. Record ownership in a short platform contract, and check it in code review or with policy tests.
+----------------+ +----------------+
| iOS / Android | | browser |
| mobile app | | web app |
+-------+--------+ +-------+--------+
| |
v v
+--------------------------------------------------------+
| EDGE API GATEWAY (shared, platform-owned) |
| TLS termination, authentication, rate limits, routing |
+-----------+---------------------------+----------------+
| |
v v
+-----------------------+ +-----------------------+
| MOBILE BFF | | WEB BFF |
| (mobile team owns) | | (web team owns) |
| compose responses | | compose responses |
| shape/trim fields | | shape/trim fields |
| adapt protocol | | adapt protocol |
+-----------+-----------+ +-----------+-----------+
| |
+-------------+-------------+
|
+-----------+--------+----+------------+
| | | |
v v v v
+---------+ +----------+ +-----------+ +----------+
| Order | | Customer | | Inventory | | Shipping |
| service | | service | | service | | service |
+---------+ +----------+ +-----------+ +----------+
Every client request passes through the shared edge gateway for TLS, authentication, rate limiting and routing, then reaches a client-owned mobile or web BFF that composes and shapes responses from the order, customer, inventory and shipping services.
Why do mobile and web clients need separate BFFs?
A mobile client on a cellular network pays heavily for every round trip. A web client on broadband, served from the same region, pays much less. One shared aggregation endpoint has to pick a shape, and that shape suits at most one of them.
| Concern | Mobile client | Web client |
|---|---|---|
| Round-trip cost | High and variable | Lower, more stable |
| Payload tolerance | Small; bandwidth and battery matter | Larger responses acceptable |
| Data shape | Compact screens, fewer fields | Dense views, more fields per request |
| Release cadence | Gated by app store review | Deploy any time |
| Old versions in use | Builds stay installed for months | Users reload to the latest |
The last two rows matter most. A web BFF can change its contract alongside the frontend in one deploy. A mobile BFF must keep serving response shapes that old installed builds still expect. If both clients share one endpoint, the web team inherits the mobile team's compatibility constraints.
Illustrative estimate (assumed inputs): a screen needs four dependent calls, and each mobile round trip costs 150 ms. Called directly from the device, that is about 600 ms of network time. With a BFF, the device makes one 150 ms round trip. The BFF then makes in-datacenter calls at an assumed 5 ms per hop, about 20 ms if they run sequentially. Total network time is roughly 170 ms, a saving of about 430 ms, even though the BFF adds a server-side hop.
Aggregation makes latency worse in two cases:
- A slow required dependency: the whole response waits for the slowest call the BFF treats as mandatory.
- Poor fan-out design: the BFF chains calls that could run in parallel.
How does response composition work in a BFF?
Take an order-detail screen that shows the order summary, the customer's name and contact, stock status for each line item, and a delivery estimate. The BFF exposes one endpoint, such as GET /orders/{id}/detail, and builds the response in three steps.
1. Map the dependencies before writing any calls. The order service is keyed on the order ID, which the client already has, so that call can start immediately. The customer, inventory and shipping lookups usually need IDs that only appear in the order response: a customer ID, SKUs and a shipment ID. That makes the order call the only true sequential step. Once it returns, the other three calls run in parallel. The total latency is roughly the order call plus the slowest of the three, not the sum of all four. A common mistake is to await each call in turn out of habit. That turns a two-step dependency chain into a four-step one.
2. Merge into a screen-shaped payload. Map each downstream response into the fields the screen actually renders. If the screen shows a customer's display name, return only that, not the full customer record with addresses and preferences. Trimming cuts payload size on mobile networks. It also stops the client from coupling to fields it never asked for.
3. Account for load multiplication. Every screen load now produces four downstream requests. As an illustrative estimate, 500 order-detail views per second means about 2,000 requests per second spread across four services. Each of those services needs capacity, rate limits and caching planned with that multiplier in mind.
// GET /orders/:id/detail: one sequential step, then parallel fan-out
interface OrderDetail {
orderId: string;
status: string;
total: number;
customerName: string;
items: { sku: string; qty: number; inStock: boolean }[];
deliveryEstimate: string | null;
}
async function getOrderDetail(orderId: string): Promise<OrderDetail> {
// Step 1: the only true dependency. The other calls need IDs from this response.
const order = await orderService.get(orderId);
const skus = order.lines.map((l) => l.sku);
// Step 2: these three are independent of each other, so start them together.
// Latency is roughly order call + slowest of these, not the sum of all four.
const [customer, stock, shipment] = await Promise.all([
customerService.get(order.customerId),
inventoryService.getStock(skus),
shippingService.get(order.shipmentId),
]);
// Step 3: return only the fields the screen renders, not full upstream records.
return {
orderId: order.id,
status: order.status,
total: order.total,
customerName: customer.displayName,
items: order.lines.map((l) => ({
sku: l.sku,
qty: l.quantity,
inStock: (stock[l.sku] ?? 0) >= l.quantity,
})),
deliveryEstimate: shipment.estimatedDelivery ?? null,
};
}
// Note: each screen load makes four downstream requests. Size capacity accordingly.
A BFF handler that fetches the order first, runs the customer, inventory and shipping lookups in parallel with Promise.all using IDs from the order, then trims the merged result to the fields the screen renders.
How should a BFF handle partial failures and timeouts?
Every one of those downstream calls can be slow or fail, so the BFF needs a plan for when they do.
Budget time from the client's deadline down
Start from how long the client will wait, subtract network and serialization overhead, and give each downstream call a timeout that fits inside what remains. Run independent calls in parallel under one shared deadline. If the BFF's own timeouts exceed the client's, it keeps working on requests nobody is waiting for.
Classify calls as required or optional
An order-detail screen cannot render without the order itself, so that call is required: if it fails, the request fails. Recommendations or loyalty points are optional. When an optional call times out, return the rest of the response and mark that section explicitly, for example "recommendations": null alongside "degraded": ["recommendations"]. A marker lets the client tell "unavailable" apart from "empty". The cost is real: every client must now handle each degraded section, and those paths need testing.
Retry at one layer only
Retries multiply across layers.
Illustrative estimate (assumed numbers, not a benchmark): total downstream attempts = (retries per layer + 1)^layers. With the client, gateway and BFF each retrying twice, that is (2 + 1)^3 = 27 attempts for one user action, and they arrive exactly when the service is already struggling.
Choose one layer to retry, usually the BFF because it knows which calls are idempotent. Cap retries with a budget, such as a fixed fraction of recent request volume, and disable them everywhere else.
// Client waits ~1000 ms; reserve ~150 ms for network and serialization.
const CLIENT_DEADLINE_MS = 1000;
const OVERHEAD_MS = 150;
async function fetchJson(url: string, timeoutMs: number): Promise<unknown> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs); // stop work nobody waits for
try {
const res = await fetch(url, { signal: ctrl.signal });
if (!res.ok) throw new Error(`${url} -> HTTP ${res.status}`);
return await res.json();
} finally {
clearTimeout(timer);
}
}
export async function orderDetail(orderId: string) {
const deadline = Date.now() + CLIENT_DEADLINE_MS - OVERHEAD_MS;
// Each downstream timeout is capped by what is left of the shared deadline.
const budget = (ms: number) => Math.max(0, Math.min(ms, deadline - Date.now()));
// Independent calls run in parallel under one deadline.
const [order, recs, loyalty] = await Promise.allSettled([
fetchJson(` budget(800)), // required
fetchJson(` budget(300)), // optional
fetchJson(` budget(300)), // optional
]);
// Required call failed: the screen cannot render, so fail the whole request.
if (order.status === "rejected") throw order.reason;
const degraded: string[] = [];
const optional = (name: string, r: PromiseSettledResult<unknown>) => {
if (r.status === "fulfilled") return r.value;
degraded.push(name); // null plus marker means "unavailable", not "empty"
return null;
};
// e.g. { order: {...}, recommendations: null, loyalty: {...}, degraded: ["recommendations"] }
return { order: order.value, recommendations: optional("recommendations", recs),
loyalty: optional("loyalty", loyalty), degraded };
}
A BFF handler that fans out to three services under one deadline derived from the client's timeout, fails only when the required order call fails, and returns null plus a degraded marker for optional sections that time out or error.
Who should own a BFF, and how do you keep business logic out of it?
The ownership rule is simple: one BFF per client experience, owned by the team that builds that client. The iOS and Android teams may share a mobile BFF if their screens match. The web team owns its own. The BFF is part of the client, deployed on the server side, and nothing more.
That framing tells you what stays out. Domain logic and writes of record belong in downstream services. If the mobile BFF calculates a discount and the web BFF calculates it slightly differently, users see two prices for the same cart. Worse, the downstream service stops being the source of truth, because the real rule now lives in two places that nobody reconciles.
Teams often try to fix duplication by sharing a library between BFFs. This trades drift for coupling: every library change forces coordinated releases across client teams. Duplicating presentation code (formatting, field mapping) is usually the cheaper choice. If you find yourself wanting to share business code, move it into a downstream service instead.
Checklist for leaked logic
- Pricing, discount or eligibility rules computed in the BFF
- Order, account or workflow state transitions decided in the BFF
- Direct reads or writes against a service's database
- Authorization checks that duplicate a downstream service's checks rather than passing identity through to it
- Caches shared across requests that clients or other services treat as authoritative
Clear ownership pays off in delivery speed. When a screen needs a new field combination, the client team changes its own BFF and ships. There is no cross-team API negotiation and no waiting on another team's backlog.
Implementing a BFF in production: auth, versioning and observability
Authentication and identity propagation
Validate the client's token once, at the edge or in the BFF, then convert the result into a single internal identity representation. That can be a signed internal token or a fixed set of headers such as user ID and tenant. Downstream services should never see three different identity formats depending on which BFF called them. When they do, every service grows parsing code, and a mismatch becomes a security bug. Strip any identity headers the client sends before setting your own, or a caller can spoof them.
Leave authorization in the downstream services. If the BFF also decides "can this user see this order," the two checks will drift apart. The BFF can hide UI elements, but the service that owns the data owns the decision.
Versioning for mobile
Web clients update on deploy. Mobile builds stay installed for months. Prefer additive changes, require clients to ignore unknown fields, and keep deprecated response shapes until usage from old builds falls below a threshold you have agreed on in advance. Each retained shape multiplies your contract tests, so track usage by app version and delete old shapes on purpose.
Observability
Forward trace context to every downstream call so a single request shows the full fan-out. Record latency and error rate per downstream dependency, plus response payload size. Alert on p99 of the composed response. It is bounded by the slowest call, so averages will hide the problem.
import { context, propagation } from '@opentelemetry/api';
// Result of validating the client's token once, earlier in BFF middleware.
interface VerifiedIdentity {
userId: string;
tenantId: string;
}
type IncomingHeaders = Record<string, string | string[] | undefined>;
// Build headers for downstream calls from scratch rather than copying the
// client's headers. Client-sent x-user-id / x-tenant-id are never forwarded,
// so a caller cannot spoof identity.
export function downstreamHeaders(incoming: IncomingHeaders, identity: VerifiedIdentity): Record<string, string> {
const headers: Record<string, string> = {};
// Explicit allowlist of client headers that are safe to pass through.
for (const name of ['accept-language']) {
const value = incoming[name];
if (typeof value === 'string') headers[name] = value;
}
// One internal identity format for every downstream service.
headers['x-user-id'] = identity.userId;
headers['x-tenant-id'] = identity.tenantId;
// Add W3C traceparent/tracestate for the active span so each downstream
// call appears in the same trace as the incoming request.
propagation.inject(context.active(), headers);
return headers;
}
// Fan-out: every call gets the same identity and trace context.
export async function getHomeScreen(incoming: IncomingHeaders, identity: VerifiedIdentity) {
const headers = downstreamHeaders(incoming, identity);
const [profile, orders] = await Promise.all([
fetch('', { headers }),
fetch('', { headers }),
]);
return { profile: await profile.json(), orders: await orders.json() };
}
A BFF helper that replaces any client-supplied identity headers with values from the verified token, injects W3C trace context through the OpenTelemetry API, and reuses those headers for every call in the fan-out.
When should you avoid a BFF, and what are the alternatives?
A BFF earns its keep only when client teams are blocked by each other. Without that pressure, it is overhead. Skip it when you ship a single client type, when the client talks to a handful of services, or when every client needs roughly the same aggregated shapes. In those cases one composition layer serves everyone, and splitting it per client duplicates code without decoupling anyone.
The cost is easy to underestimate. Each BFF is another service to deploy, secure, patch and monitor. That cost multiplies with every client type: three clients means three on-call surfaces, three dependency trees to keep current and three places where auth can drift. Set that against the engineering time saved when a mobile team can change its payloads without negotiating with the web team.
| Option | Development cost | Operational cost | Fits when |
|---|---|---|---|
| Direct client calls | Low upfront; client owns orchestration | None added server-side; more round trips on the client | One client, few services, low-latency network |
| Edge gateway aggregation | Low to moderate; limited by gateway config | Shared with existing gateway | Simple fan-out with no client-specific shaping |
| Shared composition service | Moderate; one codebase | One extra service | Clients need similar aggregated views |
| Query layer (e.g. GraphQL) | Higher; schema design and resolver work | One service, plus query cost controls | Clients need different field sets from the same graph |
| BFF per client | Highest; one service per client | Scales with client count | Client teams ship independently and diverge |
Start with the cheapest option that removes current friction. Split into BFFs once coordination between client teams becomes the bottleneck.
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 backend for frontend pattern, 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
.envby 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.