What you'll learn: By the end of this lesson you will be able to describe what a front end should ask of its Backend for Frontend (BFF), design a screen-shaped response that can fail in parts and write the client code that renders it, explain how a BFF changes browser sign-in so that no access token lives in page JavaScript, write a small client wrapper that sends the cookie and a CSRF-defence header and reacts to an expired session, say how BFF responses should be cached, and judge when a BFF is worth its extra hop and when a gateway or GraphQL is enough.
Imagine booking a trip through a travel agent. You say "Lisbon, three nights, next weekend". The agent calls the airline, the hotel and the car hire company, and hands you one itinerary. If the car company does not answer, the agent tells you "the car is not confirmed yet" and gives you the rest. You never learn the airline's booking system, and you never hold the credentials for any of them.
A Backend for Frontend (BFF) is that agent for one user interface. Sam Newman, who named the pattern, describes the user-facing application as two parts: "a client-side application living outside your perimeter, and a server-side component (the BFF) inside your perimeter". The BFF is "tightly coupled to a specific user experience" and is usually maintained by the team that owns the UI.
This lesson is the consuming side. The back-end lesson, Backend for Frontend (BFF), covers what the BFF owns, how it fans out to services and who should run it, and API Gateway and Service Mesh covers the neighbouring pattern. Here the question is different: what should the code in the browser expect, and what should it demand?
The running example is a signed-in store manager dashboard: today's order count, low-stock alerts and announcements, each owned by a different service. A previous lesson, Consuming REST, GraphQL and tRPC, compared the styles a client can use to fetch such data. A BFF is another answer to the same problem.
The core idea
From the browser, a BFF is one origin that speaks the screen's language. It offers a small number of endpoints shaped like screens, keeps the browser's identity in a session, and answers in a way that says what is missing. That gives the front end five things to ask for.
| Ask | Why the front end wants it | How to check it from the client |
|---|---|---|
| One request per screen | Fewer round trips and no waterfall | Count requests in the network panel for one screen |
| Honest partial failure | One slow service must not blank the screen | Force one section to fail and look at the page |
| A session, not a token | No credentials for script to steal | Look in storage and JavaScript-readable cookies for a token |
| Same origin | No cross-origin rules to debug | Check the request's origin in the network panel |
| A contract the UI team controls | Client and server change together | Look at who can merge a change to the response |
The next sections take the five asks in turn, then the costs.
Ask 1: one request per screen
Without a BFF, the dashboard calls three services itself and waits for the slowest. Newman notes that in a microservice setup "it will be common for a single call in to a BFF to result in multiple downstream calls to microservices." The browser sees one call.
Figure 1. One call from the browser, three behind it
How to read this: the BFF starts the calls together and sets its own limit on how long it waits. The reply carries what it has plus a clear marker for what it does not.
Illustration 1 (minimal example): the screen-shaped contract
Newman says the BFF should decide how to degrade gracefully when a downstream service fails (his example is leaving out stock levels), and that clients must be able to render partial responses. In code, that means the response says, per section, whether the data is there. The names below are illustrative.
export type Section<T> = { status: "ok"; data: T } | { status: "unavailable" };
export interface DashboardResponse {
orders: Section<{ today: number }>;
alerts: Section<{ lowStock: string[] }>;
notices: Section<{ items: string[] }>;
}
// One line per section: the screen decides what "unavailable" looks like.
export function summarise(d: DashboardResponse): string[] {
return [
d.orders.status === "ok" ? `Orders today: ${d.orders.data.today}` : "Orders: unavailable, retry",
d.alerts.status === "ok" ? `Low stock: ${d.alerts.data.lowStock.join(", ") || "none"}` : "Alerts: unavailable, retry",
d.notices.status === "ok" ? `Notices: ${d.notices.data.items.length}` : "Notices: unavailable, retry",
];
}
Run in Node 26 with orders { today: 42 }, alerts unavailable and one notice, summarise returns three lines: Orders today: 42, Alerts: unavailable, retry and Notices: 1.
The key design point is the discriminated union. A type that forces the code to look at status before reading data makes forgetting the failure case a compile error, not a white screen. The HTTP status of the whole response stays 200 because the request itself worked; this is the same "success with warnings" situation as a GraphQL partial response, handled by the BFF's contract instead of the query language's.
Illustration 3 (failure case): the section that spins forever
Suppose the BFF waits for every service without its own limit. When the alerts service hangs, the whole dashboard request hangs, and the browser shows a spinner for the slowest dependency. The front end cannot fix this alone, but it can protect itself and check for it: give the call a client-side timeout as a backstop (the planned lesson on request cancellation, retries and timeouts covers how), and test the page with one section forced to fail. A BFF that never says "unavailable" is a BFF that has not decided how to degrade, which Newman places inside the BFF's responsibilities.
Ask 2: a session, not a token
Without a BFF, a single-page app often holds an access token in JavaScript and sends it to each API. Any script that runs on the page, including a compromised dependency, can read it. RFC 10017 (OAuth 2.0 for Browser-Based Applications, BCP 212, August 2026) describes the BFF as the first of its architecture patterns. The BFF "has three core responsibilities": it is a confidential OAuth client towards the authorization server, it manages access and refresh tokens inside a cookie-based session, and it forwards API requests with the access token attached. In the RFC's words, "Since tokens are only available to the BFF, there are no tokens available to extract from the browser."
Figure 2. Sign-in with a BFF
How to read this: the browser only ever holds the session cookie. The tokens stay on the server, so the sign-in code in the page is mostly navigation and one "check session" call.
What this means for client code:
- The page calls a session-check endpoint. The RFC calls it the "check session" endpoint: the BFF's response says whether a session is active and can include identity information. The app calls it at start-up to decide whether to show the signed-in screen or send the user to sign in.
- Sign-in is a navigation, not a
fetch. The browser has to follow redirects to the authorization server, so the app setswindow.locationto the BFF's sign-in URL. Afetchto it cannot show the user a sign-in page. - There is no token in storage. The requests carry the cookie, and the BFF removes it and attaches the access token before it calls the resource server.
The cookie is now the credential
Because the cookie is the whole credential, its attributes matter. MDN says an HttpOnly cookie "can't be accessed by JavaScript", that Secure sends it only over HTTPS, and that SameSite controls whether it is sent on cross-site requests, with Lax as the default when the attribute is missing. The RFC requires the BFF to set Secure and HttpOnly, and recommends SameSite=Strict, path /, no Domain attribute and a cookie-name prefix; MDN documents the __Host- prefix, which forces Secure, no Domain and path /.
HttpOnly protects against script reading the cookie. It does not stop script, or another site, from making requests that carry it. The RFC says the BFF "MUST implement a proper CSRF defense" (cross-site request forgery: another site causes the user's browser to send a request with the user's cookie). It adds that SameSite alone is not enough when the BFF shares a site with other applications, such as sibling subdomains that could be taken over.
The RFC's first suggestion is for the BFF to require a custom request header on every request: "Cross-origin requests with a custom request header always require a preflight, which makes CORS an effective CSRF defense." A page on another site cannot get the BFF to approve that preflight, so its cookie-bearing request is never sent. The RFC notes the BFF must check that every request carries the header, and that requests which do not trigger a preflight leave an endpoint unprotected. The front-end half of this is to send the header on every call. Same-Origin Policy and CORS and CORS Preflight, Credentials and Debugging explain the preflight mechanism.
export class SessionExpired extends Error {}
type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
// Same-origin BFF call: cookies go with it by default; a static custom header is added to every request.
export async function bffFetch(path: string, init: RequestInit = {}, fetchImpl: FetchLike = fetch): Promise<Response> {
const headers = new Headers(init.headers);
headers.set("X-Requested-By", "web-app"); // the name is illustrative: any static name the BFF requires
const res = await fetchImpl(path, { ...init, headers });
if (res.status === 401) throw new SessionExpired("session missing or expired");
return res;
}
Run in Node 26 with a fake fetch, the wrapper sends the header X-Requested-By: web-app on a request answered with 200, and throws SessionExpired when the fake answers 401. RFC 9110 defines 401 as "lacks valid authentication credentials", which is the response a client sees when its session is missing or has expired.
Same-origin matters here. MDN says that by default fetch includes credentials only on same-origin requests (the credentials option's default is same-origin). A BFF served from the same origin as the app needs no credentials: "include" and no CORS configuration for the app's own calls. If the BFF is on another origin, every call needs credentials: "include" and the server needs matching CORS headers; if it is also on another site, MDN says the cookie must use SameSite=None to be sent, which gives up that protection. That is a reason to put the BFF on the same origin as the page.
Ask 3: honest caching
A BFF reply is built for one user's screen, so it is personal. MDN says to add the private directive "for user-personalized content, especially for responses received after login and for sessions managed via cookies", and warns that forgetting it can let a shared cache store the response and reuse it for several users. no-store tells every cache, private or shared, not to store it. A shared cache that stores one manager's dashboard and serves it to another is the worst bug a BFF can have.
That does not make caching useless. In the Azure page's example, the mobile BFF "takes advantage of caching" and a gateway in front of it serves repeated requests from its cache. The safe pattern is to cache public sub-results inside the BFF (the same catalogue data for everyone) and mark the assembled, per-user response as private. The client-side layer is yours: the client cache keys the dashboard on the session's user so a sign-out cannot leave the previous manager's data on screen. HTTP Caching covers the directives in detail.
Ask 4: a contract the UI team controls
Newman says a BFF is usually maintained by the team that owns the UI, so the team can change the API as the UI needs and release client and server changes together. The Azure page makes the same point: frontend teams manage their BFF independently, with control over language, release cadence and prioritisation. For the front end this is the real gain: the response shape is a design decision of the screen, not a negotiation with every other consumer.
It does not remove version skew. A long-lived browser tab runs the old bundle against the new BFF. Keep changes additive for at least as long as the oldest bundle you expect to see, and treat renaming a field as a two-step release (serve both names, then remove the old one). The same advice appears in Consuming REST, GraphQL and tRPC.
What a BFF costs, and when it is not needed
The Azure Architecture Center lists the costs plainly: "Maintaining and deploying more services means increased operational overhead", the BFF "introduces an extra network hop" that might add latency, and code duplication is "a probable outcome". It also says the BFF should "only handle client-specific logic related to a specific user experience", with cross-cutting features such as monitoring and authorization handled separately.
It lists when to use the pattern: a shared backend needs heavy development effort to maintain, you want to optimise the backend for particular client interfaces, or you are customising a general backend for several interfaces. It may not suit you when interfaces make the same or similar requests, or only one interface uses the backend. The page also says that with GraphQL and frontend-specific resolvers a BFF might add no value, and that an API gateway with microservices can be enough. Newman adds that for a web-only UI a BFF mainly pays when there is significant server-side aggregation.
Figure 3. Do I need a BFF for this front end?
or heavy shaping?"} B -->|"no"| D1["Call the service directly"] B -->|"yes"| C{"More than one client type
with different needs?"} C -->|"yes"| E["BFF per experience
is a candidate"] C -->|"no"| F{"Gateway or GraphQL
resolvers already do it?"} F -->|"yes"| G["Probably no new BFF"] F -->|"no"| H{"Need browser sign-in
without tokens in script?"} H -->|"yes"| E H -->|"no"| I["Start simple,
revisit when pain shows"]
How to read this: aggregation and several client types point towards a BFF. The sign-in question is an independent reason, and RFC 10017 names the BFF as its first browser-app pattern.
| Concern | Direct calls from the browser | BFF | Gateway or GraphQL layer |
|---|---|---|---|
| Round trips per screen | One per service | One per screen | One per screen (GraphQL) or per service (gateway) |
| Tokens in the browser | Held in script | None (cookie session) | Depends on the design |
| Partial failure | Each call fails alone | One contract decides | Gateway: per call; GraphQL: data plus errors |
| Who owns the shape | The services | The UI team | A platform team or the schema |
| Extra hop and operations | None | Yes | Already present |
| Fit | One service, simple screens | Screen-specific aggregation, browser sign-in | Cross-cutting rules, many clients with a shared shape |
The other side of this topic
The back-end lesson Backend for Frontend (BFF) covers what belongs in a BFF and what does not, how it runs its fan-out with timeouts, who owns it and how many to build, and the BFF as part of browser sign-in. A front-end designer should read it for the server's side of the contract: whatever timeouts and degradation rules it has are the ones the screen has to render.
In practice: a dashboard that keeps its tokens in the browser
This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.
The situation
This section is Illustration 2, the realistic example. A single-page app for shop managers shows today's orders, low-stock alerts and announcements. The three services are separate deployments. The app signs in with an identity provider, keeps the access token in localStorage, and calls the three services directly with the token in an Authorization header. Each service has CORS configured for the app's origin. A security review flags the token in localStorage: any script on the page can read it, and the app loads several third-party scripts. The product team also wants the dashboard to load faster on shop tablets. Four front-end and two platform engineers are involved.
Starting architecture
Figure 4. The browser holds the token and calls three services
How to read this: the amber app both holds the credential and calls every service. Anything that runs in the page can read the token, and three services each need cross-origin rules for it.
Options considered
| Option | What it fixes | What it costs | Risk |
|---|---|---|---|
| A BFF on the app's origin with a cookie session | Tokens leave the browser; one call per screen; no CORS for the app's calls | A new service, an owner, session storage | An extra hop; the BFF grows if it takes business rules |
| Keep tokens in the app, move them to memory | Smaller exposure than localStorage |
Token still readable by script; sign-in state is lost on reload | Does not meet the review's concern |
| Put a shared gateway in front of the services | Central CORS and rate limiting | Does not shape the screen or remove the token from script | Aggregation stays in the browser |
The decision
| Field | Decision record |
|---|---|
| Context | Tokens are readable by any script on the page; three calls and three CORS configurations per screen. |
| Decision | Add a BFF on the app's own origin for the dashboard. The BFF is the OAuth client, keeps tokens in a server-side session, and returns one screen-shaped response with a status per section. The app uses a session-check call, navigates to the BFF to sign in, sends a static custom header on every call, and renders partial results. |
| Consequences, good | No token in the browser; one request per screen; the UI team controls the response; no CORS rules for the app. |
| Consequences, bad | One more service to run and monitor; an extra network hop; session storage and logout behaviour to design; an owner on call. |
| Revisit when | A second client with different needs appears (consider a BFF per experience), or the BFF starts to hold business rules (move them back to services). |
Rollout and verification
- Contract first. Agree the response type with a section that fails, and build the screen against a mock.
- Check the browser. After sign-in, confirm there is no token in storage and that the session cookie is
HttpOnlyandSecure. - Test the three outcomes for each section: ok, unavailable, and a session that expires mid-use.
- Check CSRF. Send a request without the custom header and confirm the BFF rejects it.
- Check caching. Confirm the assembled response carries
privateorno-store, and that signing out clears the client cache.
The 2 a.m. failure
Hours after the rollout, support reports that some managers see the sign-in page again and again and never reach the dashboard. The BFF's logs show valid sessions. The identity provider's logs show a burst of successful sign-ins from the same users.
The cause is on the client. The BFF began rejecting requests that lack the custom header, and for one legacy path, a small polling widget that used raw fetch and did not go through the wrapper, the rejection came back as 401. The app's global handler treats every 401 as "session expired" and navigates to sign-in. The identity provider's session is still valid, so sign-in completes instantly, the user is redirected back, the widget polls without the header, gets 401, and the loop starts again.
First three checks: (1) look at the sequence of requests in a failing browser and find the first 401; (2) check which code path sent it and whether it used the wrapper; (3) compare the BFF's response for a missing header with its response for a missing session. Mitigation: turn off the legacy widget and, on the BFF, answer a missing custom header with a different status from a missing session (RFC 9110 defines 403 as understood but refused). Follow-up: route every request through one wrapper, add a loop guard that stops after one automatic sign-in attempt and shows a message, and test with a header deliberately removed.
Architect's notes
- Decide the response contract first. A per-section status, written down early, is the difference between a degraded screen and a blank one.
- Put the BFF on the page's origin unless there is a strong reason not to. It removes a class of CORS and cookie problems.
- Route every request through one wrapper. The custom header, the
401handling and the timeout all belong in one place. - Keep business rules out of the BFF. Client-specific shaping belongs in it; pricing and stock rules belong in services. The Azure page says the BFF should handle only client-specific logic.
- Treat sign-out as a design item. It must end the server session, clear the cookie and empty the client cache, in that order.
- Count the cost. Name an owner and an on-call rota before the first release.
Common mistakes
- Assuming
HttpOnlymakes the cookie safe. It stops script from reading it, not from causing requests that carry it. - Relying on
SameSitealone. The RFC says it is not enough when the BFF shares a site with other applications. - Treating every
401as "sign in again". A loop follows when a non-session problem also returns401. - Letting a shared cache see a per-user response. Use
privateorno-store, not freshness headers alone. - Reading
datawithout looking atstatus. One unavailable section blanks the page. - Building one BFF for every client. The guideline Newman quotes is "one experience, one BFF".
- Putting business rules in the BFF. It becomes a second back end that every team must edit.
Key takeaways
- A BFF is a server-side component for one user experience, kept inside the perimeter, with the client outside it.
- Ask it for one request per screen, a per-section status, a cookie session, the page's own origin and a contract the UI team controls.
- RFC 10017 describes the BFF as an OAuth client that keeps tokens in a cookie-based session, so none reach the browser.
- The cookie becomes the credential:
Secure,HttpOnly, a strictSameSiteand a__Host-style name, plus a CSRF defence. - The RFC's first CSRF suggestion is a custom request header on every call, which forces a preflight for cross-origin requests.
- Sign-in is a navigation, and the app calls a session-check endpoint at start-up.
- Per-user responses must not be stored by shared caches:
privateorno-store. - A BFF costs an extra hop, an owner and some duplication; it may add nothing where one interface makes simple requests or GraphQL resolvers already shape the data.
- Always change contracts additively for as long as old bundles may be open.
Practice exercises
Exercise 1: Starter (30 min). Take a screen that calls three services. Draw the sequence with and without a BFF, mark where each credential lives, and list what the browser can read in each case.
Exercise 2: Intermediate (2 hours). Implement summarise and a bffFetch wrapper in a small project with a fake fetch. Add tests: a section unavailable, a 401, and a missing header. Then add a loop guard so that two 401 responses within a few seconds show a message instead of navigating again.
Exercise 3: Advanced (half day). Write the cache rules for a BFF response: which parts are public and may be cached by the BFF, which headers the assembled response carries, and how the client cache is keyed and cleared on sign-out. Test a sign-out followed by a sign-in as a different user.
Related reading
In this course:
- Consuming REST, GraphQL and tRPC, the previous lesson: the styles a client can use, and the error models.
- Same-Origin Policy and CORS and CORS Preflight, Credentials and Debugging, the preflight and credentials rules behind the custom-header defence.
- HTTP Caching,
private,no-storeand shared caches. - Micro-frontend Auth and Shared Session, sessions when several front ends share a user.
Elsewhere on the site:
- Backend for Frontend (BFF), the back-end view of the same pattern.
- System Design: API Gateway and Service Mesh, the neighbouring pattern.
Later lessons in this course (planned, not yet published) cover OAuth, OIDC and PKCE in single-page apps, token storage and secure cookies, and CSRF defences in depth.
Official documentation
- Sam Newman, Backends For Frontends, the pattern's source.
- Microsoft Azure Architecture Center, Backends for Frontends pattern.
- IETF, RFC 10017: OAuth 2.0 for Browser-Based Applications, section 6.1 (BFF).
- MDN, Using HTTP cookies,
HttpOnly,Secure,SameSiteand the__Host-prefix. - MDN, Cache-Control,
privateandno-store. - MDN, Using the Fetch API, the
credentialsoption.