cf.completefrontend
My progress
FUNDAMENTALS · MODULE 3

Backend for frontend (BFF)

A small server, usually owned by the frontend team, that gathers data from many backend services and returns exactly what one screen of one kind of client needs.

The problem: one screen, six services

Picture an online shop with a product page. The page shows the product name and photos, the price, whether it is in stock near you, the star rating with three reviews, a row of "you might also like" items, and the number of items in your cart. It looks like one thing to the user.

Behind it, the backend is split into microservices: small services, each owned by one team, each with its own database and its own API. There is a catalog service, a pricing service, an inventory service, a reviews service, a recommendations service, and a cart service. Six teams, six APIs, six release schedules. That split is good for the backend teams, because each one can ship without waiting for the others.

It is hard on the frontend. To draw one page, the browser now has to talk to six services, each designed for its own team's needs, not for this page. And there are other clients too: an iOS app, an Android app, maybe a TV app.

What goes wrong when the browser calls everything

Let the browser call all six services directly and four problems show up, one after the other.

  1. Too many round trips. Some calls depend on others: you need the product's category before you can ask for recommendations, and its SKU (stock keeping unit, the id of one sellable variant) before you can ask for stock. On a phone with a 150 ms round trip, two rounds of dependent calls cost 300 ms of pure waiting, even if every service answers instantly.
  2. Too many bytes. Each service returns its full resource. The catalog sends 40 KB of product data (every locale, every attribute, every image size) when the page needs about 3 KB.
  3. Too much knowledge in the client. The page has to know six base URLs, six error formats, and the order of the calls. That logic is copied into the web, iOS, and Android apps, and the copies drift apart.
  4. Secrets in the wrong place. Each service wants an access token, so the browser holds tokens where an XSS (cross-site scripting) bug can read them and send them to an attacker.
A waterfall of the product page calling six services straight from a phone with a 150 millisecond round trip. Round one: catalog, from 0 to 180 milliseconds. Round two starts at 180 milliseconds, once the SKU and category are known: pricing ends at 350, inventory at 355, reviews at 390, recommendations at 410, and cart at 345. The page has all its data at about 410 milliseconds, and most of each bar is time on the phone network.round 1round 2, in parallelcatalogpricinginventoryreviewsrecscart0 ms100 ms200 ms300 ms400 msphone networkservice work
Most of every bar is the phone network, not the services. Round two can't start until the catalog answers, so the 150 ms round trip is paid twice before the page is complete.

The fix: one server that speaks for this screen

A backend for frontend, or BFF, is a small server that sits between one kind of client and all those services. The browser makes one call, GET /pages/product/42. The BFF fans out to the six services over the fast data-center network, picks the 3 KB the page needs, shapes it the way the page wants it, and sends it back in one response.

The services stay general and the client stays simple. The screen-specific glue lives in one place, owned by the people who build the screen. That ownership is what makes a BFF different from a plain proxy.

The pattern grew up at SoundCloud in the early 2010s. Their apps were built on one general-purpose public API, and every new mobile feature meant negotiating changes to it with another team. They moved to one small backend per client experience, owned by the client team. Sam Newman wrote the pattern up in 2015 as "Backends For Frontends", still the standard reference.

Check your understanding · Exercise 1

A product page calls six microservices straight from the browser. Which problems does a BFF in front of them directly address? Select all that apply.

The mental model: a waiter for each dining room

Think of a restaurant kitchen with several stations: a grill, a salad bar, a pastry corner. Each is good at one thing. That is your set of microservices. Guests don't visit each station themselves: a waiter takes one order, collects the plates, and brings them out together, arranged the way that table expects.

A BFF is that waiter. The drive-through window gets its own staff, because its customers want paper bags and short waits, not plates. In the same way, each kind of client (web, mobile, TV) gets its own BFF that knows what it needs. The stations don't change when a new kind of guest arrives; only a new waiter is hired.

Three layers, three jobs

Keep this three-layer picture in your head for the rest of the lesson. Almost every question about BFFs is a question about which layer a piece of logic belongs to.

  • Clients draw pixels and handle input. They know about screens, not services. They call one host: their own BFF.
  • BFFs know about screens and services. They call services, combine and trim the results, translate errors, handle the user's session, and return one view-shaped response. They hold no data of their own beyond caches and sessions.
  • Domain services know about the business: what a price is, how stock is counted, who may see a review. They don't know any screen exists, and they serve every BFF the same way.
Three layers. Top, clients that draw pixels: the web app, the iOS app, and the TV app. Middle, one BFF per client that shapes views and holds sessions: the web BFF owned by the web team, the mobile BFF owned by the mobile team, and the TV BFF owned by the TV team. Bottom, domain services that own the rules and the data: catalog, pricing, inventory, and reviews. Each client calls only its own BFF; each BFF calls the services its screens need.Clients: draw pixelsBFFs: shape viewsDomain services: own the rules and dataWeb appiOS appTV appWeb BFFweb teamMobile BFFmobile teamTV BFFTV teamCatalogPricingInventoryReviews
Arrows only go down. Clients know screens, BFFs know screens and services, services know only the business. Adding a new client adds a BFF, not a change to the services.

What "view-shaped" means

A domain service returns a resource-shaped response: a product with all of its fields, the way the catalog team models it. A BFF returns a view-shaped response: the exact data one screen draws, already joined, already formatted, in the order the screen uses it. A good test: could a component render the response with no extra fetching and no business logic? If yes, it is view-shaped.

What a BFF should not do

Most BFFs return JSON, though a Next.js or Remix server that fetches from services and renders HTML plays the same role. Whatever the format, the waiter does not cook. A BFF that calculates prices or decides discounts has taken a domain service's job. The mobile BFF then needs the same logic, and you have two copies of a business rule that will disagree one day.

Think first

The product page should show "Only 3 left" in red when stock is below 5. Where does each part of that belong: the threshold, the count, the text, the colour?

How a request flows, step by step

Let's follow one request for the product page through a BFF, with real numbers. The user is on a phone with a 150 ms round trip to the nearest region. The BFF runs in that region, next to the services, and a call between two servers there takes about 2 ms of network time.

  1. The browser sends GET /api/pages/product/42 to the web BFF, with its session cookie. One request, to one host it already has a connection to.
  2. The BFF reads the cookie, looks up the session, and finds the user's access token (more on that in the auth chapter). The browser never sees the token.
  3. Round 1. The BFF calls the catalog service for product 42 (30 ms). It needs the result first, because it carries the SKU and the category id.
  4. Round 2. With those ids, the BFF calls pricing (20 ms), inventory (25 ms), reviews (60 ms), recommendations (80 ms), and cart (15 ms) at the same time. The round takes as long as its slowest call: 80 ms.
  5. The BFF picks the fields the page draws, formats the price for the user's locale, and builds one JSON object of about 3 KB.
  6. It sends the response with a short Cache-Control header (or private, no-store if it holds personal data), and the browser renders the page.
A sequence diagram with five columns: browser, web BFF, catalog, pricing, and reviews. The browser sends GET product 42 to the BFF. The BFF asks the catalog for product 42, and the catalog answers with the SKU and category after 30 milliseconds. Then the BFF calls pricing and reviews at the same time; pricing answers in 20 milliseconds and reviews in 60. The BFF answers the browser with one 3 kilobyte view. Stock, recommendations, and cart are called in the same parallel round but not drawn.BrowserWeb BFFCatalogPricingReviewsGET product/42get 42sku, categoryprice(sku)reviews(42)20 ms60 ms200 view, 3 KB
One arrow crosses the phone network each way; everything else happens next to the services. The second round starts only when the catalog answers, and lasts as long as its slowest call.

What the handler looks like

The core of a BFF route is short. It awaits the calls that others depend on, runs the rest in parallel, and maps the results into the view. Here is the product page route as a Node handler, with the service clients left out.

ts
export async function productPage(id: string, session: Session) {
  // Round 1: everything else needs the SKU and category.
  const product = await catalog.get(id, { timeoutMs: 50 });

  // Round 2: independent calls run at the same time.
  const [price, stock, reviews, recs, cart] = await Promise.allSettled([
    pricing.get(product.sku, session.token, { timeoutMs: 90 }),
    inventory.get(product.sku, session.storeId, { timeoutMs: 90 }),
    reviewsApi.top(id, 3, { timeoutMs: 90 }),
    recommendations.forCategory(product.categoryId, { timeoutMs: 90 }),
    cartApi.count(session.token, { timeoutMs: 90 }),
  ]);

  return {
    title: product.name,
    image: product.images.medium,
    price: valueOr(price, null, (p) => formatMoney(p, session.locale)),
    stock: valueOr(stock, null, toStockBadge),
    reviews: valueOr(reviews, [], (r) => r.map(toReviewCard)),
    recommendations: valueOr(recs, [], (r) => r.slice(0, 8).map(toTile)),
    cartCount: valueOr(cart, null, (c) => c.count),
  };
}
One BFF route: a dependent call, then a parallel fan-out, then a view-shaped result.

Two choices matter later. Promise.allSettled, not Promise.all, means one failed service doesn't throw away the other four answers. And each call has its own timeout, so one slow service can't hold the page hostage.

Latency budget math

The total time is the phone's round trip plus the BFF's critical path: the longest chain of calls that depend on each other. Calls in the same round overlap, so only the slowest one in each round counts.

Same services, same speeds. The BFF wins because the phone pays its 150 ms round trip once instead of once per round.
StepBrowser calls services directlyBrowser calls the BFF
Round 1 (catalog)150 ms network + 30 ms = 180 ms2 ms network + 30 ms = 32 ms
Round 2 (five calls in parallel)150 ms network + 80 ms = 230 ms2 ms network + 80 ms = 82 ms
Phone to BFF and backnone150 ms
BFF work (parse, map, serialize)noneabout 5 ms
Totalabout 410 msabout 270 ms

The direct column is also optimistic. If the services live on different hosts, the browser needs a DNS lookup, a TCP connection, and a TLS handshake for each new host. Through a BFF, it reuses one connection to one host.

Flip it around to set a latency budget. Say the API response for this page should arrive within 300 ms. The phone's round trip takes 150 ms of that, and we keep 10 ms for the BFF's own work (5 ms typical, plus a margin). That leaves about 140 ms for the BFF's critical path.

With two rounds, give the catalog about 50 ms (it usually takes 32) and round two about 90 ms (its slowest call, recommendations, usually takes 82). Those numbers become the timeouts in the handler above. If a call's usual time sits close to its timeout, the budget is too tight: remove a round or cache that call.

Phone round trip
Rounds of dependent calls
Slowest call after round 1
Two bars on a time axis. With 2 rounds of dependent calls, a 150 millisecond phone round trip, a 30 millisecond catalog call in round 1, and a slowest call of 80 milliseconds in each later round, calling services directly takes 410 milliseconds and going through a BFF takes 269 milliseconds. The budget line is at 300 milliseconds.direct410 msvia BFF269 ms0 ms300 ms600 ms900 msphone networkservices and BFF
Hatched is time on the phone network. Direct calls pay it once per round; the BFF pays it once in total. Round 1 is always the catalog (30 ms). The dashed line is a 300 ms budget for the API response.
Within a 300 ms budgetValue
Phone round trips, direct against BFF2 against 1
Left for the BFF's critical path, after the phone trip and 10 ms for its own work140 ms
Catalog timeout (it usually takes 32 ms)about 50 ms
Timeout for each later round (its slowest call usually takes 82 ms)about 90 ms

Through the BFF the data arrives in 269 ms, inside the budget. Direct calls take 410 ms, over the budget.

Check your understanding · Exercise 2

A BFF calls service A (40 ms). With A's result it calls B (30 ms) and C (90 ms) in parallel. With B's result it calls D (20 ms). Server-to-server network is 2 ms per call and the phone round trip is 120 ms. Which statements are right? Select all that apply.

Shaping the response: aggregate, trim, translate

Calling services in parallel is the easy part. The real value of a BFF is in what it does with the answers. There are four jobs, and each has a common mistake.

Aggregate: join data from several services

The reviews service returns reviews with an authorId. The page wants the author's display name and avatar, which live in the users service. The BFF joins them.

The trap is the N+1 fan-out: one call for the list, then one call per item. Three reviews means four calls; a page of 50 comments means 51.

Fix it by batching: collect the unique ids from the list, then make one call such as GET /users?ids=7,19,23. If the users service has no batch endpoint, ask for one; that is a reasonable request from a BFF team. Libraries like DataLoader do this collecting and batching for you within one request, and also stop the same id being fetched twice.

Trim: send only what the screen draws

The catalog's product resource is 40 KB because it serves many consumers: search indexing, the warehouse app, every locale. The product page needs the name in one language, one image size, the SKU, and a short description. The BFF keeps those and drops the rest.

Trimming also protects you: fields you don't send can't leak. A supplier's cost price or an internal moderation flag is harmless inside the data center and harmful in a browser's network tab. Build the response from an explicit list of fields (an allowlist). Spreading the whole object and deleting a few fields will one day ship a new field nobody reviewed.

Before and after. Left, the catalog service's product resource, about 40 kilobytes: the name in 12 locales and images in 9 sizes, of which one each is kept; 84 attributes, SEO data, and warehouse bins, which are dropped; and supplier cost and moderation flags, which must never reach a browser. Right, the view the page receives from the BFF, about 3 kilobytes: title, one 600 pixel image, display price, stock label, rating, three reviews, and eight recommendations.catalog resource: 40 KBview from the BFF: 3 KBname (12 locales)images (9 sizes)attributes (84)seo, sitemap datawarehouseBinssupplierCostmoderationFlagstitleimage (600 px)price.displaystock.labelratingreviews (3)recommendations (8)built for every consumerbuilt for one screen
The BFF builds the view from a list of wanted fields. Faded fields are dropped to save bytes; red ones are dropped because a browser must never see them.

Translate: one vocabulary for the client

Each service has its own conventions: one returns prices in cents, another as decimal strings; one uses snake_case, another camelCase; one signals "not found" with 404, another with 200 and { "error": "NOT_FOUND" }. The BFF translates all of that into one vocabulary and one error format for its client.

Be careful with what you format on the server. Turning 1999 cents into "$19.99" is fine if the BFF knows the user's locale and currency. Turning a timestamp into "3 minutes ago" is usually not: the text is wrong a minute later, and it breaks HTTP caching. Send the ISO timestamp and let the client format relative times with Intl.RelativeTimeFormat.

Shield: absorb backend change

When the pricing team renames a field or splits their service in two, only the BFF changes. This matters most for mobile: once version 5.2 of the iOS app is on a million phones, you can't change what it expects. The mobile BFF keeps serving 5.2's shape while newer versions get new fields, often by reading the app version from a header such as X-App-Version.

json
{
  "title": "Trail Runner 3",
  "image": "https://img.example.com/p/42/600.webp",
  "price": { "amount": 8900, "currency": "EUR", "display": "89,00 €" },
  "stock": { "count": 3, "lowStock": true, "label": "Only 3 left" },
  "rating": { "average": 4.6, "count": 1284 },
  "reviews": [
    { "author": "Mia K.", "avatar": "https://img.example.com/u/7/48.webp",
      "stars": 5, "text": "Light and grippy on wet rock.",
      "createdAt": "2026-09-30T08:12:00Z" }
  ],
  "recommendations": [{ "id": "51", "title": "Trail Sock", "price": "12,00 €" }],
  "cartCount": 2,
  "partial": []
}
The view the page receives: about 3 KB, every field drawn on screen, one error convention.

The partial array lists sections that failed to load, so the client can show a fallback for them. We'll use it in the partial failures chapter.

Check your understanding · Exercise 3

A BFF builds a comments section of 40 comments, each with an authorId. Which are good choices? Select all that apply.

One BFF per client type

The "F" in BFF is singular on purpose. Sam Newman's version of the pattern is one backend per user experience: one for the web app, one for the mobile apps, one for the TV app. Each is built by, and for, the team that owns that client. That is the main difference from a general-purpose API that tries to serve everyone.

Why clients want different things

It is tempting to think web and mobile show "the same data". They rarely do. The same product page differs in almost every way that shapes an API.

A matrix of what three clients want from the same product page. Recommendations shown: web 8 tiles, mobile 4, TV 6. Reviews: web 3, mobile 1, TV none. Image: web 600 or 1200 pixels depending on the screen, mobile 600, TV a 1920 pixel hero. Network: web anything from a desk connection to a phone, mobile slow and patchy, TV fast and steady. Releases: web many a day, mobile through the store every few weeks, TV a few a year. Auth: web a session cookie, mobile a token in the operating system's secure storage, TV a device code sign-in.WebMobileTVrecs shown8 tiles4 tiles6 tilesreviews3 reviews1 reviewnoneimage600/1200 px600 px1920 px heronetworkdesk or phoneslow, patchyfast, steadyreleasesmany a daystore, weeksa few a yearauthcookiesecure storedevice code
Every row is a reason for a different response shape, and the last three are reasons for a different server: networks, release cycles, and sign-in all differ by client.
  • Screen. The desktop web page shows 8 recommendations and 3 reviews; the mobile app shows 4 and 1; a TV shows a large hero image and no reviews.
  • Network. A phone on a train needs the smallest payload and the fewest round trips.
  • Release cycle. The web app deploys many times a day and every user gets the new version on the next load. A mobile app ships through store review, and old versions stay in use for months, so the mobile BFF must serve several app versions at once.
  • Auth. The browser should hold a cookie, not tokens (next chapter). A native app can keep tokens in the operating system's secure storage (Keychain on iOS, Keystore on Android).
  • Extra features. Mobile needs push registration and maybe offline sync; web needs server rendering and SEO metadata; TV needs device pairing.

One shared API that serves all of these grows a parameter for every difference (?client=tv&reviews=0), and every client team negotiates changes with the team that owns it: exactly the bottleneck SoundCloud was escaping.

How many BFFs?

Decision How should you split BFFs across your clients?

One general API for all clients

Every client calls the same endpoints, maybe with parameters to pick fields; one backend team owns it.

Pros

  • One codebase and deploy; fine when clients are few and similar.

Cons

  • Fits no client well.
  • Client teams wait on the API team, and old mobile versions pin its shape.

One BFF per client type

A web BFF and a mobile BFF (shared by iOS and Android if their screens match), each owned by its client team.

Pros

  • Each BFF fits its client exactly.
  • Client teams ship end to end without waiting.

Cons

  • Some glue code is written twice.
  • More services to deploy, monitor, and secure.

One BFF per experience or team

A checkout BFF, a search BFF, an account BFF, behind one domain and routed by path.

Pros

  • Each team owns its slice, end to end; fits large organizations.

Cons

  • Many small services.
  • The session and shared header data need a common library.

What we pick: Start with one BFF per client type, owned by that client's team. Let iOS and Android share one while their screens and release cycles stay close. Split by team only when several teams are waiting on each other inside one BFF. A general API is still right for third-party developers, beside the BFFs rather than in place of them.

What about the duplicated code?

Two BFFs will both call the catalog service and both format prices. Some duplication is the price of independence, and it is usually small. When it grows, push shared business logic down into a service, and share plumbing (service clients, auth helpers, logging) as a library.

Check your understanding · Exercise 4

Your web and mobile teams share one BFF. Which signs say it's time to split it? Select all that apply.

Auth at the BFF: keep tokens off the browser

Most modern backends use OAuth 2.0 and OpenID Connect: the user signs in at an authorization server (Auth0, Okta, Entra ID, Keycloak, or your own), and the app receives an access token (a short-lived credential, often valid for 5 to 60 minutes, sent as Authorization: Bearer ...) and a refresh token (a long-lived one used to get new access tokens without asking the user to sign in again).

A single-page app that does this flow by itself keeps both tokens in the browser, where JavaScript on the page can reach them. One XSS bug or compromised third-party script can send the refresh token to an attacker, who can then call your APIs as that user from their own machine for days.

The token handler pattern

A BFF removes tokens from the browser entirely. The BFF becomes the OAuth client: it runs the sign-in flow and keeps the tokens on the server, and the browser gets only an opaque session cookie that JavaScript can't read. Identity vendors such as Curity call this the token handler pattern.

The IETF standard for this is RFC 10017, "OAuth 2.0 for Browser-Based Applications", published in August 2026 as a Best Current Practice after years as a draft. It describes three architectures in decreasing order of security, and the BFF comes first. It strongly recommends the BFF for business applications, sensitive applications, and apps that handle personal data.

  1. The user clicks "Sign in". The browser navigates (a full page load, not fetch) to the BFF's /auth/login.
  2. The BFF stores a random state and a PKCE code verifier (PKCE, proof key for code exchange, ties the final code to this login attempt) and redirects to the authorization server.
  3. The user signs in there and is redirected back to the BFF's /auth/callback?code=...&state=....
  4. The BFF checks state, then exchanges the code for tokens in a server-to-server call, authenticating with its own client secret. The tokens never pass through the browser.
  5. The BFF stores the tokens in a server-side session (for example in Redis) and sets a cookie holding only the random session id.
  6. From then on the browser calls /api/... with the cookie. The BFF looks up the session, refreshes the access token when needed, and attaches Authorization: Bearer ... to each service call.
A sequence diagram of sign-in with the token handler pattern, between the browser, the BFF, the authorization server, and the session store. The browser opens GET /auth/login on the BFF, which redirects it to the authorization server. The user signs in, and the authorization server redirects the browser back to the BFF's callback with a code. The BFF sends the code, its PKCE verifier, and its client secret to the authorization server, server to server, and gets back the access and refresh tokens. It saves them in the session store and answers the browser with a Set-Cookie for an HttpOnly session id.BrowserBFFAuth serverSessionsGET /auth/login302 to sign-inuser signs in302 with codeGET /callbackcode+PKCE+secretaccess + refreshsave tokensSet-Cookie: sid
The tokens travel only between the BFF and the authorization server, and rest in the session store. The browser ends up with one HttpOnly cookie that holds a random id.
http
HTTP/1.1 302 Found
Location: /
Set-Cookie: __Host-sid=k7Qm2xV9pL0aR4tN8bWc; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=28800
The only credential the browser holds: an opaque id it can't read or send anywhere else.
  • HttpOnly: document.cookie and every other script API can't see it, so XSS can't copy it out.
  • Secure: only sent over HTTPS.
  • SameSite=Strict: only sent on requests that start from your own site, never on a fetch, form POST, or link from another site. That blocks most CSRF (cross-site request forgery). RFC 10017 recommends Strict, which suits a single-page app whose API calls come from its own pages. Use Lax if the server renders personal HTML on the first visit from an outside link: it also sends the cookie on top-level GET navigations.
  • __Host- prefix: the browser only accepts the cookie if it is Secure, has Path=/, and has no Domain attribute, so a sibling subdomain can't overwrite it. RFC 10017 suggests the newer __Host-Http- prefix, which also requires HttpOnly, but browser support is still partial.
  • Max-Age=28800: the session lasts 8 hours at most. The refresh token on the server may last longer; the BFF decides.

What this fixes, and what it doesn't

Moving to cookies brings back CSRF, where another site makes the user's browser send a request with their cookie attached. SameSite blocks requests from other sites, but every subdomain of example.com counts as the same site, so a hijacked blog.example.com could still forge requests. That is why RFC 10017 says the BFF must have a CSRF defense of its own.

The usual one: reject API requests that lack a custom header such as X-CSRF: 1. A form on another origin can't add a header, and a cross-origin fetch that tries triggers a CORS preflight the BFF refuses. Checking Origin on writes is a cheap extra layer.

Be honest about XSS. An injected script can still call /api/... while the page is open, with the cookie attached. What it can no longer do is steal a token and use it elsewhere after the tab closes. That limits the damage; you still need output encoding and a Content Security Policy.

Two more jobs fit naturally at the BFF. It can swap the user's token for narrower ones per downstream service (OAuth token exchange, RFC 8693). And logout becomes complete: the BFF deletes the session, revokes the refresh token, and clears the cookie.

Check your understanding · Exercise 5

A web app moves from keeping OAuth tokens in localStorage to a BFF with an HttpOnly session cookie. Which statements are true? Select all that apply.

Partial failures and timeouts

A BFF that calls six services will often find one of them slow or down. If any one failure breaks the whole page, you have made the page as fragile as the weakest of six services. The goal is the opposite: the page degrades one section at a time.

Fan-out makes slow calls common

Say each service answers within 100 ms 99% of the time; the other 1% is its slow tail (its p99, the 99th percentile). One call is slow 1 time in 100.

A page that waits for six such calls is slow whenever any of them is: 1 - 0.99^6, about 5.9% of page loads. With 20 calls, it's about 18%. The page's p95 is now set by the services' p99.

So in a fan-out, rare problems become everyday ones. That's why every call needs a timeout, and why the page must have something to show when a call misses it.

Sort every section: required or optional

For each service call, decide in advance what happens if it fails. There are only a few answers.

Decide this per section with product and design, before launch, not during the first outage.
SectionIf it fails or times outWhat the client shows
Product (catalog)Required: the page can't exist without it. Return 503 (or 404 if the product is gone).An error page with a retry button.
PriceRequired for buying. Return the page, mark price failed.The page, with "Price unavailable" and the buy button disabled.
StockOptional. Serve the last cached value if under 5 minutes old, else omit.No stock badge.
ReviewsOptional. Omit.The rating hidden, a quiet "Reviews couldn't load".
RecommendationsOptional. Fall back to a cached "bestsellers" list.Bestsellers instead of matching items.
Cart countOptional. Omit; the client fetches it later.The cart icon without a number for a moment.

Report degraded sections in the response, for example "partial": ["reviews"], and still answer 200, so the client shows a fallback for those sections only. Count every degraded section on the server too, or reviews can be missing for a week before anyone notices.

BFF code
Catalog
Price
Stock
Reviews
Recs
Cart
The product page after 269 milliseconds, status 200. Price: 89,00 €. Stock: Only 3 left. Reviews: 4.6 stars, 3 reviews. Recommendations: You might also like. Header: Cart (2).shop.example/p/42Cart (2)Trail Runner 389,00 €Only 3 left4.6 stars, 3 reviewsYou might also like
Red sections are fallbacks. With allSettled and timeouts, only the catalog can take the whole page down; without them, one failed call loses everything and one slow call holds the page for seconds.

200 after about 269 ms.

Timeouts, retries, and circuit breakers

  • Timeouts from the budget. Give each call a timeout that fits the latency budget, plus an overall deadline. When it passes, cancel the calls still running (with an AbortSignal in Node).
  • At most one retry. Retry only idempotent calls (safe to repeat), only on errors that may be temporary, and only if the budget allows. Three retries on every call can send up to four times the load to a struggling service. See the lesson on retries and backoff.
  • Circuit breakers. If half the calls to reviews failed in the last 10 seconds, stop calling it for 30 seconds and serve the fallback at once. The page gets faster and the service gets room to recover.
  • No single point of failure. Run several BFF instances behind a load balancer and keep them stateless: sessions live in a shared store such as Redis.

Check your understanding · Exercise 6

A BFF fans out to 10 services, each slow 1% of the time, and the reviews service starts timing out on every call. Which are good responses? Select all that apply.

Caching in and around the BFF

A BFF sits at a good spot for caching: it sees every request from its client, and it knows which parts of a response are the same for everyone and which are personal. The skill is to cache the shared parts hard and the personal parts carefully, at the right layer.

The web BFF's service-call cache as a table of keys, lifetimes, and who an entry is shared by. catalog:42, 60 seconds, all users. reviews:42:top3, 2 minutes, all users. recs:trail-shoes, 5 minutes, all users. stock:42:store-9, 10 seconds, shared within one store. price:42:gold, 30 seconds, shared within one price tier. cart:user-7 is never cached. The last row, view:42 cached for 60 seconds for all users, is marked as a bug, because that view contains a member price and a cart count.keylifetimeshared bycatalog:4260 sall usersreviews:42:top32 minall usersrecs:trail-shoes5 minall usersstock:42:store-910 sone storeprice:42:gold30 sone tiercart:user-7neverone userview:4260 sall userslast row: holds a member price and a cart count, cached for everyone
Each input gets its own lifetime and its own audience, and the key says who an entry is for. The red row is the classic leak: a whole view keyed only by product id, though it holds one user's price and cart.

Split shared data from personal data

Most of the product page is the same for every user: name, photos, description, rating, top reviews. Only the cart count, and maybe the price (if members get their own), are per user. If one response holds all of it, that response is personal, and no CDN may cache it.

Two common fixes. First, cache inside the BFF per service call: the catalog result for product 42 is cached for 60 seconds and reused across users, while the cart call is never cached. Second, split the screen into a public route (GET /api/pages/product/42, cacheable at the CDN with Cache-Control: public, s-maxage=60, stale-while-revalidate=300) and a small personal one (GET /api/me/product/42, with Cache-Control: private, no-store).

Lifetimes and layers

  • Lifetimes per source. The client's data library (TanStack Query, SWR, Apollo) dedupes within a session; a CDN caches public routes; the BFF caches service results with their own lifetimes: catalog 60 s, stock 10 s, recommendations 5 minutes, cart never.
  • Whole-view caching for a few seconds helps on hot pages, but only if the key includes every input that changes the view: locale, store, app version.
  • Request-scoped dedupe. Within one request, the same service call is made once even if two parts of the view need it. DataLoader-style batching gives you this.

Two traps: the stampede and the leaked key

When a popular cache entry expires, hundreds of concurrent requests may miss at once and all call the service: a cache stampede. Avoid it by letting one request refresh the entry while the others get the stale copy (the same idea as stale-while-revalidate), or by sharing the in-flight promise for that key.

The worse trap is a cache key that misses a personal input. Cache a view that includes a member price by product id alone, and the first member to load the page sets the price everyone sees for 60 seconds. Every entry needs a clear answer to "who is this for?".

Check your understanding · Exercise 7

Which caching choices are safe for a product page BFF whose view includes the user's cart count? Select all that apply.

BFF, API gateway, or GraphQL federation, and who owns it

Three things sit between clients and services in most large systems, and interviewers like to ask how they differ. They solve different problems, are owned by different teams, and often appear together.

  • An API gateway is generic edge plumbing: TLS termination, routing /orders/* to the orders service, checking that a token is valid, rate limiting, request logs. It knows nothing about screens. One gateway serves every client, and a platform team owns it. Examples: AWS API Gateway, Kong, Envoy, Apigee.
  • A BFF is screen glue for one client: it knows the product page needs six things, calls them, and shapes the result. The client team owns it. It often sits behind the gateway.
  • GraphQL federation builds one shared graph from many parts. Each service team publishes a subgraph (its types and fields), and a router (Apollo Router, for example) splits each client query into calls to the right subgraphs and joins the answers. Clients ask for exactly the fields they need.
Three small architectures side by side. API gateway: web and app clients call one gateway that routes and checks tokens, in front of three services; a platform team owns it. BFF per client: the web client calls a web BFF and the app calls an app BFF, both in front of the same three services; client teams own them. Federation: clients send queries to a router that plans each query across three subgraphs; service teams own the subgraphs.API gatewayWeb, AppGatewayroutes, checksABCservicesplatform teamBFF per clientWeb, AppWeb BFFApp BFFABCservicesclient teamsFederationWeb, AppRouterplans queriesABCsubgraphsservice teams
Same clients, same services, different middle. The gateway routes and knows no screens, a BFF is screen glue owned by one client team, and a federation router joins subgraphs from a shared schema.
They are layers, not rivals: a common setup is gateway, then a web BFF, then a federated graph or REST services.
QuestionAPI gatewayBFFGraphQL federation
Knows about screens?NoYes, one client'sNo; clients describe screens in queries
Who writes the aggregation?Nobody: it routesThe client team, by handThe router, from the schema
Who owns it?Platform teamClient teamEach service team owns its subgraph; a platform team owns the router
Number of themUsually oneOne per client typeOne graph for all clients
Holds the user's session?Rarely; it checks tokensOften (token handler)Rarely; usually behind a BFF or gateway that does
HTTP cachingPasses throughEasy for GET routesHarder: queries often use POST; needs persisted queries or field-level cache hints

Which should you use? A gateway alone is fine for a few services and one client. Gateway plus a BFF per client is the default for most teams, and the easiest to explain in an interview. Federation pays off when many clients and teams keep asking for new combinations of the same data and the organization can invest in schema governance; see the GraphQL lesson for that side.

Ownership: the frontend team runs a server

The pattern only works if the BFF is owned by the team that owns the client. If a separate backend team owns the web BFF, every screen change is again a ticket in another team's queue, and you've rebuilt the shared-API bottleneck with an extra hop. So the web team writes it, deploys it, and is on call for it.

That's a real cost. The team now needs server skills: logging, tracing across service calls, capacity planning, security reviews, and on-call. Many teams write the BFF in TypeScript on Node so the same engineers work on both sides and share types. Next.js route handlers and server components, Remix loaders, and SvelteKit server load functions are all a BFF built into the web app's own deploy.

When it turns into a distributed monolith

A distributed monolith is a system split into services that can't change or deploy independently: all the network costs of microservices, none of the freedom. BFFs drift into it in a few recognizable ways.

  • Business logic creeps in. Discount rules or stock math live in the BFF, the mobile BFF copies them, and the copies disagree.
  • BFFs call each other. The mobile BFF calls the web BFF's /product route to reuse its work, so a web change can break mobile.
  • Lockstep deploys. Every feature needs the BFF and three services released together, in order. The boundaries are wrong.
  • Services shaped by one screen. A service grows endpoints like /getDataForProductPageV2: it now knows about a screen.
  • One BFF for everyone. A single "BFF" serves web, iOS, Android, and partners: a general-purpose API with a misleading name.
Two dependency graphs. Left, healthy: the web BFF calls catalog and price, and the app BFF calls price and stock; every edge points down from a BFF to a service. Right, tangled: the same nodes, but the app BFF also calls the web BFF to reuse its product route, and the stock service calls back up into the app BFF. Those two edges are marked as problems.healthy: edges go downtangled: sideways and upwebappcatpricestockwebappcatpricestockreusecalls upBFFsBFFsservicesservices
Count the edges that don't point down. Each sideways or upward edge couples two teams' deploys, which is how a set of BFFs turns into a distributed monolith.

Check your understanding · Exercise 8

Which of these jobs belong in an API gateway rather than a BFF? Select all that apply.

In real products, and in the interview

Where you'll see it

  • SoundCloud is where the name started: client teams got their own backends so they could ship without changing a shared public API.
  • Netflix made a similar move for its many TV, console, and phone apps: its 2012 API redesign let device teams write their own server-side adapter code, running inside the API service, to shape responses for each device.
  • Next.js, Remix, Nuxt, and SvelteKit apps are often BFFs without the name. If you have used a Next.js route handler to hide an API key from the browser, you have built a small BFF.
  • Identity products ship the token handler pattern ready-made, such as Duende's BFF library for ASP.NET Core and Curity's token handler.

When to bring it up

Raise a BFF when the problem has at least one of these: a screen that needs data from several services, several client types with different needs, a mobile client on slow networks, or a web app that must handle sign-in securely. In a component question (design an autocomplete, a carousel) it rarely belongs; in an app question (design a shop, a news feed, a video site) it often does.

Equally, say when you would not add one: a single backend that already serves view-shaped endpoints, a small team with one client, or a page that makes one call. Saying "I'd skip a BFF here because..." is as strong a signal as drawing one.

A decision tree. First question: does a screen need data from two or more services, or does the web app have tokens to hide? If no, skip the BFF and call the API directly. If yes, second question: can the frontend team build and run a server? If no, consider a shared GraphQL layer or ask a backend team for view endpoints. If yes, third question: are there several client types with different needs? If yes, one BFF per client type. If no, one web BFF.2+ services, or tokens?per screennoskip the BFFyesteam can run it?deploys, on-callnoshared GraphQLyesseveral clients?web, mobile, TVyesBFF per clientnoone web BFF
A BFF needs both a reason (several services per screen, or tokens to hide) and an owner (a client team that can run a server). With both, the number of BFFs follows the number of distinct clients.

What to draw and say

  1. Draw the client, one box labelled "Web BFF (owned by web team)", and three or four services behind it. Add a session store if auth is in scope.
  2. Write the one endpoint the screen calls, such as GET /api/pages/product/:id, and sketch its response shape: the frontend's API contract.
  3. Say how the BFF fans out (which call is first, which run in parallel) and the rough latency math.
  4. Say what happens when a service fails: required versus optional sections, timeouts, a partial marker.
  5. If auth is in scope, say "tokens stay in the BFF; the browser gets an HttpOnly SameSite session cookie", and mention CSRF.
LevelWhat a strong answer covers
JuniorWhat a BFF is, why one call beats six from a phone, that it trims the payload.
MidParallel fan-out and the critical path, partial failures with allSettled and timeouts, one BFF per client type, a clear view-shaped contract.
SeniorOwnership and on-call cost, the token handler pattern and CSRF, caching split between public and private data, tail latency in fan-out, BFF versus gateway versus federation, and the signs of a distributed monolith.

Think first

An interviewer says: "Doesn't a BFF just add latency? It's an extra hop." How do you answer?

Summary

  • A BFF is a small server per client type, owned by that client's team, that turns many resource-shaped service APIs into one view-shaped response per screen.
  • It wins by moving round trips from the phone (about 150 ms each) to the data center (a few ms each). Total time is one client round trip plus the critical path of dependent calls.
  • Shape responses with an allowlist of fields, batch per-item lookups to avoid N+1 fan-out, and keep business rules in services: the BFF decides how data looks, never what is true.
  • For web apps, the token handler pattern keeps OAuth tokens in the BFF and gives the browser only an HttpOnly, Secure, SameSite session cookie. Add CSRF defenses.
  • Fan-out makes tail latency common. Give every call a timeout from the budget, use Promise.allSettled, sort sections into required and optional, and report partial results.
  • Cache service results inside the BFF with per-source lifetimes, and keep anything personal out of shared caches.
  • A gateway is generic edge plumbing, a BFF is one client's screen glue, and GraphQL federation is one shared graph. They often stack.
  • Watch for the distributed monolith: business logic in the BFF, BFF-to-BFF calls, lockstep deploys, or one BFF for every client.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

What is a backend for frontend, and how is it different from an API gateway?

Question 2

Why one BFF per client type instead of one BFF for everyone?

Question 3

How would you handle authentication for a single-page app with a BFF?

Question 4

One of the six services behind your product page BFF is down. What does the user see?

Question 5

How do you cache in a BFF when the response contains personal data?

Question 6

When does a BFF become a problem rather than a solution?

Practice it

These design problems lean on backend for frontend (bff).

References

Read next