Backend-for-Frontend and Client-Specific API Design Questions
Shaping API responses to the needs of specific client types: the Backend-for-Frontend (BFF) pattern, one BFF per client versus a shared gateway, client-side composition or a generic API, what belongs in a BFF (including normalizing inconsistent downstream pagination and error formats for one client), and aggregating several backend calls into one client-shaped response. Covers payload shaping (sparse fieldsets and field selection against relational and document stores, partial responses, optional expansion of related collections, delta responses with deletions, compact serialization including binary wire formats), round-trip reduction and composite views for feeds and dashboards, adapting to mobile versus web bandwidth, battery and device constraints (device-appropriate image variants, prefetching and refresh frequency tuned to connection quality), and choosing GraphQL versus REST to fix over- and under-fetching, including incremental migration from REST to GraphQL. Authentication, general REST resource design, versioning, rate limiting, offline sync and real-time transport are covered elsewhere.
A product has web, iOS and Android clients that each need aggregated views combining several backend services. Evaluate an API gateway, client-side composition and a Backend-for-Frontend per client, covering latency, caching, developer productivity, code duplication, schema evolution and operational complexity. Which would you recommend for a platform with several internal product teams, and why?
Sample Answer
Recommendation
Put a Backend-for-Frontend (BFF) in front of the shared services for each kind of client, keep a thin API gateway in front of the BFFs for edge concerns only, and leave client-side composition for screens that need a single service. A BFF is a small server owned by the team that owns the client; it exists to turn "what the services expose" into "what this screen needs". An API gateway is the single front door that terminates TLS (TLS is the encryption of the connection, the "S" in HTTPS; terminating it means the gateway decrypts incoming traffic so the services behind it do not each have to), checks the caller's token, rate-limits (refuses callers who send too many requests) and routes each request to the right service. Client-side composition means the app itself calls several services and stitches the answers together.
For a platform with several internal product teams, the deciding factor is who has to change what to ship a screen. With a BFF per client type, the web team and the mobile team can reshape a screen without queueing behind a central gateway team or waiting for three app-store releases.
One screen, three ways
Take an order-history screen that needs the user's name (identity service), their last 10 orders (orders service), and a product title for each order (catalog service). Fan-out means one incoming request causing several outgoing calls to other services.
- API gateway with aggregation routes. The gateway team adds
GET /screens/order-history, which fans out to the three services. Any product team that wants a different screen files a change against that same shared gateway codebase. - Client-side composition. The app calls identity and orders at once, waits for the order ids, then calls catalog with the product ids. The same stitching code is written in Swift, Kotlin and TypeScript.
- BFF per client type. The mobile team owns a mobile BFF with
GET /mobile/order-history, which does the same fan-out and returns only the fields the phone renders. The web team has its own web BFF with a different response shape. Neither waits for the other or for a central team.
The three options against the six criteria
Where a cell mentions RTT or dependency depth, the worked numbers are in the latency section after the table. A private cache is one stored per user (a browser or app cache, or a per-user entry), while a shared cache, such as a CDN or reverse proxy, stores one copy and serves it to many users, which is only safe for responses identical for everyone.
Two terms used below. Dependency depth is the number of calls that must finish one after another because a later call needs an earlier answer (a call that returns ids, then calls that fetch those ids, is depth 2). Round trip time (RTT) is the time for a request to reach the server and the first byte of the answer to come back.
| Criterion | API gateway with aggregation routes | Client-side composition | BFF per client type |
|---|---|---|---|
| Latency | One call from the client. Adds one hop; upstream calls run in parallel in the data centre. | Pays RTT once per level of dependency depth, over the slowest link (cellular). Independent calls can run in parallel and are then about as fast as a BFF. | Same as the gateway: one client call, parallel fan-out inside the data centre. |
| Caching | Good for public, shared responses. Per-user screens are cacheable only privately. | Best granularity: each upstream resource keeps its own HTTP cache headers and CDN entry. | Composed screens are per-user, so private; cache upstream reads and fragments inside the BFF instead. |
| Developer productivity | Every product team's screen change goes through one shared codebase and its owners. | Fast to start; slow to change, because each of three clients re-implements the join and mobile changes wait for store review. | Client team ships its own server change with its own client change. Needs a template so a new BFF costs days, not weeks. |
| Code duplication | Low. | Highest: the same join and error handling written in Swift, Kotlin and TypeScript, and they drift. | Moderate: web BFF and mobile BFF repeat some orchestration. Share upstream client libraries, not business logic. |
| Schema evolution | One place to adapt, but one place that every team can break. | Worst: a backend change reaches installed apps that cannot be forced to update. | The BFF absorbs upstream changes and keeps the client contract stable; old app versions keep working because the BFF serves them. |
| Operational complexity | One more critical service, and it becomes a shared bottleneck and on-call burden (on-call means someone must be reachable to fix it when it breaks at 3 a.m.). | Nothing new to run; complexity moves to clients (retries, partial failure, three implementations). | N small services to deploy, monitor and put on call; the cost is real and is reduced by one runtime template and per-BFF service-level objectives (SLOs: written targets such as "99.9% of requests succeed and 95% finish in under 500 ms", used to decide when a team must stop shipping and fix reliability). |
What the latency difference looks like
The model charges round trips the client makes in sequence x RTT + server time. The inputs are assumptions chosen for illustration: 60 ms per upstream call, 20 ms of extra aggregation work in the BFF, RTT of 40 ms on Wi-Fi and 400 ms on weak cellular. The order-history screen above has dependency depth 2: identity and orders are fetched together, then catalog needs the order ids. A BFF serving that screen still makes the same two dependent calls one after the other inside the data centre, so it keeps their server time; what it removes is the extra trip over the slow link. Run in a node:22 container with node latency.mjs:
// Latency floor of a screen = (round trips the client makes in sequence) x RTT + server time, once a connection is open.
// Assumptions: 60 ms per upstream call; the BFF adds 20 ms of aggregation work.
// The BFF still has to make dependent upstream calls one after another; it only moves those waits off the slow link.
const screens = [
// [label, client round trips in sequence, server ms]
['order-history, client composes (identity+orders, then catalog)', 2, 60 + 60],
['order-history, one BFF call (same upstream chain inside)', 1, 60 + 60 + 20],
['3 independent calls, client runs them in parallel', 1, 60],
['3 independent calls, one BFF call', 1, 60 + 20],
];
const rtts = { 'Wi-Fi, RTT 40 ms': 40, 'weak cellular, RTT 400 ms': 400 };
for (const [name, rtt] of Object.entries(rtts)) {
console.log(name);
for (const [s, trips, serverMs] of screens) console.log(' ', s.padEnd(62), String(trips * rtt + serverMs).padStart(5), 'ms');
}
It prints:
Wi-Fi, RTT 40 ms
order-history, client composes (identity+orders, then catalog) 200 ms
order-history, one BFF call (same upstream chain inside) 180 ms
3 independent calls, client runs them in parallel 100 ms
3 independent calls, one BFF call 120 ms
weak cellular, RTT 400 ms
order-history, client composes (identity+orders, then catalog) 920 ms
order-history, one BFF call (same upstream chain inside) 540 ms
3 independent calls, client runs them in parallel 460 ms
3 independent calls, one BFF call 480 ms
Two conclusions follow from the numbers. First, three independent calls made in parallel from the client (460 ms on cellular) are not slower than one BFF call (480 ms): the BFF adds a hop and aggregation work, so it does not win on latency by itself. Second, when the client must chain calls, the BFF removes a round trip over the slowest link: 920 ms becomes 540 ms for order-history on weak cellular, and the saving grows by roughly one RTT for every extra level of dependency depth. On Wi-Fi the same change saves only 20 ms (200 ms against 180 ms), because a 40 ms RTT is cheap. The BFF is justified by composition depth, plus the ownership and evolution rows of the table, not by a blanket "fewer calls is faster" claim.
Decision for the platform
- Gateway for edge concerns only: token validation, rate limiting, request ids, TLS, routing to the right BFF. No screen logic.
- One BFF per client type, not per platform by reflex. Web gets its own. iOS and Android usually share one "mobile BFF" because they render the same screens from the same data; split them when their screens or release cadence diverge.
- Owned by the client team, with a platform-provided template (auth middleware, upstream client libraries, timeouts, tracing, error shape). The domain services keep every business rule.
- Client composition stays for screens that need one service, or for calls that are independent and already cacheable at a CDN.
- Consumer-driven contract tests between each BFF and each upstream service so a service team learns it broke a screen before deploy. In such a test the consumer (the BFF) writes down exactly which fields and shapes it relies on, for example "
GET /orders/{id}returnsstatusas a string"; the service team's build runs those expectations against its code, so renamingstatusfails their build instead of silently breaking the screen in production.
What would change the answer
- One client type and two aggregated screens: a gateway aggregation route is enough, and a BFF is overhead.
- Web and mobile render identical screens: one BFF, not two.
- No team available to own and be on call for each BFF: a shared gateway with strict route ownership by team beats unowned BFFs.
Pitfalls
- The BFF turns into a second business tier. If it decides who may refund an order or computes prices, every client and every other consumer must now reimplement or bypass it. Keep rules in the domain services; the BFF translates, joins and trims.
- One "universal BFF" for all clients reproduces the generic gateway problem with a new name.
- A BFF with its own database of record becomes a service that other teams start depending on.
- No deadlines. Without a per-upstream timeout, one slow service holds every screen that touches it.
You need one API strategy that serves mobile clients on poor connectivity and web clients that need rich interactivity. Would you serve both from the same API, a shared gateway, or separate client-specific backends? How would the protocol, payload shape and caching differ per client class?
Sample Answer
Direct answer
Run one set of shared domain services behind a thin gateway, with a separate BFF for each client class (a mobile BFF and a web BFF). Serving both from one generic API forces a compromise: either mobile pays for the web's richness in bytes and round trips, or web pays for mobile's frugality in chattiness. A shared gateway alone does not fix this, because it has no knowledge of any one client's screens. The BFFs differ in protocol tuning, payload shape and caching, while the business rules stay in the shared services.
Why the link, not the device, drives the split
A first-order model of one request on an open connection: time = RTT + payload bits / bandwidth, where RTT (round trip time) is how long a request and the first byte of its answer take. The link figures are assumptions (weak cellular 400 ms and 1 Mbit/s; office Wi-Fi 30 ms and 50 Mbit/s), and the model ignores slow start (a new connection begins by sending only a little data per round trip and speeds up as the network proves it can cope), packet loss (data lost in transit that must be sent again) and server time. Run in a node:22 container (Node 22.23.3) with node transfer.mjs:
// Time for one request/response on an already-open connection:
// rtt (request out, first byte back) + payload bits / bandwidth.
// Ignores TCP/QUIC slow start, packet loss and server time: a first-order model with ASSUMED link figures.
const links = {
'weak cellular (RTT 400 ms, 1 Mbit/s)': { rttMs: 400, mbps: 1 },
'office Wi-Fi (RTT 30 ms, 50 Mbit/s)': { rttMs: 30, mbps: 50 },
};
const ms = (link, bytes) => link.rttMs + (bytes * 8) / (link.mbps * 1e6) * 1000;
const plans = [
{ name: 'one composite response, 120 kB', calls: [120_000] },
{ name: 'one lean response, 30 kB', calls: [30_000] },
{ name: 'six sequential calls, 20 kB each (120 kB)', calls: Array(6).fill(20_000) },
{ name: 'lean 30 kB, then lazy 90 kB for below the fold', calls: [30_000, 90_000], firstOnly: true },
];
for (const [ln, link] of Object.entries(links)) {
console.log(ln);
for (const p of plans) {
const total = p.calls.reduce((t, b) => t + ms(link, b), 0);
const first = ms(link, p.calls[0]);
console.log(' ', p.name.padEnd(50), p.firstOnly ? `first content ${Math.round(first)} ms, all ${Math.round(total)} ms` : `all ${Math.round(total)} ms`);
}
}
It prints:
weak cellular (RTT 400 ms, 1 Mbit/s)
one composite response, 120 kB all 1360 ms
one lean response, 30 kB all 640 ms
six sequential calls, 20 kB each (120 kB) all 3360 ms
lean 30 kB, then lazy 90 kB for below the fold first content 640 ms, all 1760 ms
office Wi-Fi (RTT 30 ms, 50 Mbit/s)
one composite response, 120 kB all 49 ms
one lean response, 30 kB all 35 ms
six sequential calls, 20 kB each (120 kB) all 199 ms
lean 30 kB, then lazy 90 kB for below the fold first content 35 ms, all 79 ms
What this shows:
- On weak cellular, both terms hurt: six small sequential calls cost 3,360 ms because each pays 400 ms of RTT, and one 120 kB response costs 1,360 ms because 120 kB at 1 Mbit/s is 960 ms of transfer. The lean 30 kB response needs 640 ms.
- On Wi-Fi, bytes are nearly free (120 kB is 19 ms of transfer) and round trips dominate (six calls take 199 ms against 49 ms for one).
- Splitting a screen into a lean first response plus a lazy second one costs 1,760 ms in total on cellular, 400 ms more than a single response, but the user sees first content at 640 ms instead of 1,360 ms. That is the right trade for perceived speed on the mobile client.
So the mobile BFF optimizes for fewer bytes and fewer, resumable requests, and the web BFF optimizes for fewer round trips and interactivity; neither is a copy of the other.
What differs per client class
| Dimension | Mobile BFF (poor connectivity) | Web BFF (rich interactivity) |
|---|---|---|
| Protocol | HTTP/3 where available (the newest HTTP version, which runs over QUIC, a transport protocol built on UDP instead of TCP). In plain terms: stream multiplexing means many requests share one connection at the same time, and the point is that on QUIC one lost packet delays only the stream it belongs to. RFC 9114 maps HTTP onto QUIC, whose features include stream multiplexing and low-latency connection establishment, while the same RFC notes that over HTTP/2 on TCP a lost or reordered packet stalls all active transactions, which is the failure mode that hurts most on lossy cellular links (lossy meaning packets are often dropped). The practical rule is: send few requests, keep connections open, and let the platform negotiate the best protocol. | HTTP/2 or HTTP/3 through the CDN, many parallel requests are cheap (one connection carries them all); add streaming or push channels only where the product needs them (choosing that transport is its own design decision). |
| Payload shape | One composite response per screen, sparse fields, short lists with small first pages, image URLs chosen for the device, compact enums instead of repeated labels. | Resource-oriented or query-style endpoints that let the page fetch exactly what an interaction needs, richer objects (previews, permissions, related items) to avoid follow-up calls. |
| Request pattern | Idempotent GETs (reads, which are safe to repeat) with retry and backoff (wait longer after each failed attempt, for example 1 s, then 2 s, then 4 s, so a struggling server is not hammered); a cursor that survives app restart; first content separated from below-the-fold content (the part of the screen the user has to scroll to reach). | Prefetch on hover or route intent (start loading the next page's data when the pointer hovers a link or the router predicts the visit); optimistic updates (show the result of an action immediately and undo it if the server refuses); fine-grained revalidation (re-check only the one resource that may have changed). |
| Caching | ETag (a version tag for a response) and If-None-Match (the client sends the tag back) so a refresh can return 304 with no body when nothing changed; stale content shown immediately (stale-while-revalidate, which lets a cache reuse a stale response while it revalidates); private for per-user screens. | CDN for shared resources with s-maxage (a Cache-Control value that sets how long shared caches such as a CDN may reuse a response), browser HTTP cache for assets, and a client-side server-state cache (a library layer in the page that keeps fetched API data by key, serves it instantly and refetches it in the background) for API reads. |
| Compression | Compress text bodies; avoid sending fields at all rather than relying on compression, which cannot save the round trips. | Compress text bodies; field selection matters less than avoiding extra calls. |
| Versioning of the contract | Must support old app versions for months, so keep the contract additive. | Deployed with the page; the contract can change in step with the frontend release. |
Example of the same entity, a product card, as each BFF returns it:
{ "mobile": { "id": "pr_1", "t": "Trail shoe", "pc": 8900, "img": "https://img.example/pr_1/w640.jpg", "rt": 4.6 } }
{ "web": { "id": "pr_1", "title": "Trail shoe", "priceCents": 8900, "currency": "USD",
"images": [{ "url": "https://img.example/pr_1/w1290.jpg", "w": 1290, "h": 968 }],
"rating": { "average": 4.6, "count": 812 },
"variants": [{ "sku": "pr_1_42", "size": 42, "inStock": true }],
"permissions": { "canReview": true } } }
The mobile card uses short keys for a small, stable object. Short keys save bytes only when the response is large and compression is off; with compression the saving shrinks because repeated key names compress well, so use them where a profile of the real payload shows it matters, and never at the cost of a contract nobody can read.
Same API, shared gateway or separate backends
| Option | Fits when | Breaks when |
|---|---|---|
| Same API for both | One small team, similar screens, early product | Mobile starts needing a different shape and every change is negotiated with web |
| Shared gateway only | Edge concerns (auth, limits) are the main problem | Screen-specific shaping lands in a team-shared codebase |
| Gateway plus a BFF per client class | Different link quality, release cadence and screen needs, as here | Only one client exists, or no team can own a second backend |
The recommendation is the third row. What would flip it: if usage analytics show that mobile and web request nearly identical shapes, collapse to a single BFF.
Pitfalls
- Treating "mobile" as one thing: a phone on office Wi-Fi does not need the cellular profile. Let the client send a coarse profile name (such as lean or balanced) and keep the server choice overridable.
- Letting the two BFFs fork business logic. The shared services decide; the BFFs only shape.
- Optimizing the byte count and ignoring depth: the cellular numbers above show round trips costing as much as the payload.
- Caching a per-user screen publicly. Mark it
privateand cache upstream fragments instead.
That is every published Backend-for-Frontend and Client-Specific API Design question for Solutions Architect so far. Browse the other topics in this category, or practice this one interactively.