Skip to content

    Frontend System Design

    The Backend for Frontend (BFF) from the Client's Side

    Learn what a front end should ask of its BFF: one request per screen, cookie sessions with no token in JavaScript, honest partial failure and safe caching.

    By Published 18 min read

    • frontend-system-design
    • bff
    • authentication
    • api-integration
    Share

    Flagship lessonintermediateFrontend System Design

    Before this: Consuming REST, GraphQL and tRPC

    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

    sequenceDiagram accTitle: Dashboard request through a BFF with one slow service accDescr: The browser sends one request to the BFF. The BFF calls the orders, alerts and notices services at the same time. The alerts service is slow, so the BFF answers with orders and notices and marks alerts unavailable. The browser renders the partial result. participant B as Browser participant F as BFF participant O as Orders service participant A as Alerts service participant N as Notices service B->>F: GET /bff/dashboard (cookie) par the three calls start together F->>O: today's orders F->>A: low-stock alerts F->>N: announcements end O-->>F: ok N-->>F: ok Note over F,A: alerts is too slow, the BFF stops waiting F-->>B: 200, alerts marked unavailable Note over B: renders orders and notices, shows a retry for alerts

    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

    sequenceDiagram accTitle: Browser sign-in through a BFF accDescr: The browser asks the BFF whether a session exists and gets none. The browser navigates to the BFF to start sign-in. The BFF redirects to the authorization server, the user signs in, and the BFF receives a code and exchanges it for tokens. The BFF sets a session cookie. Later API calls carry only the cookie, and the BFF swaps it for the access token when it calls the API. participant B as Browser participant F as BFF participant I as Authorization server participant R as Resource API B->>F: check session F-->>B: no session B->>F: navigate to start sign-in F-->>B: redirect to authorization server B->>I: user signs in I-->>B: redirect back with code B->>F: code arrives at the BFF F->>I: exchange code (client credentials, PKCE verifier) I-->>F: tokens (kept on the server) F-->>B: set session cookie B->>F: API call with cookie F->>R: same call with access token R-->>F: data F-->>B: data

    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 sets window.location to the BFF's sign-in URL. A fetch to 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.

    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 suspects 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?

    flowchart TD accTitle: Whether a front end needs a BFF accDescr: Start with whether one screen needs data from several services. If not, call the service directly. If yes, ask whether other clients with different needs exist. If so, a BFF per experience is a candidate. If not, ask whether the browser needs sessions without tokens in script, and whether a gateway or GraphQL resolvers already do the job. A["One screen's data"] --> B{"Needs several services
    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

    NOTE

    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

    Starting architecture: the single-page app holds the access token and calls three servicesDashboard SPA to Orders service (Bearer token); Dashboard SPA to Alerts service (Bearer token); Dashboard SPA to Notices service (Bearer token); Identity provider to Dashboard SPA (token to script)Dashboard SPA: token in localStorageDashboard SPAtoken inlocalStorageOrders service: CORS for the appOrders serviceCORS for the appAlerts service: CORS for the appAlerts serviceCORS for the appNotices service: CORS for the appNoticesserviceCORS for the appIdentity provider: issues the tokenIdentityproviderissues the tokenBearer tokenBearer tokenBearer tokentoken to script

    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

    1. Contract first. Agree the response type with a section that fails, and build the screen against a mock.
    2. Check the browser. After sign-in, confirm there is no token in storage and that the session cookie is HttpOnly and Secure.
    3. Test the three outcomes for each section: ok, unavailable, and a session that expires mid-use.
    4. Check CSRF. Send a request without the custom header and confirm the BFF rejects it.
    5. Check caching. Confirm the assembled response carries private or no-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 401 handling 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 HttpOnly makes the cookie safe. It stops script from reading it, not from causing requests that carry it.
    • Relying on SameSite alone. The RFC says it is not enough when the BFF shares a site with other applications.
    • Treating every 401 as "sign in again". A loop follows when a non-session problem also returns 401.
    • Letting a shared cache see a per-user response. Use private or no-store, not freshness headers alone.
    • Reading data without looking at status. 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 strict SameSite and 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: private or no-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.

    In this course:

    Elsewhere on the site:

    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

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments