Skip to content
All posts

Unlocking UI performance with BFF architecture

Jul 28, 20267 min readarchitecture · bff · fintech

Everyone talks about scaling microservices. Far fewer people talk about what a dozen well-bounded services do to the client that has to render a screen out of them.

Split a backend into services and each one gets a clean, narrow API. Then a designer draws a single screen that needs data from six of them, and the cost of all that tidiness has to land somewhere. Usually it lands in the browser.

I designed the Backend-for-Frontend layer at SwiffyLabs as an intern prototype and now own it end to end. This is what I have learned about why the pattern earns its keep, and what it costs — because it does cost something, and posts about BFFs tend to skip that part.

A screen is not an entity

Services are organised around entities: a customer, an offer, a policy, a merchant. Screens are organised around a question a person is trying to answer. Those two shapes almost never line up.

So the client fills the gap. It fetches a session to learn who is asking, then a customer to learn what they are allowed to see, then eligibility, then offers, then the merchant attached to those offers. Each call needs something from the one before it, so they cannot run at the same time.

// What "just call the services" turns into. Every await is a round trip, and
// most of them cannot start until the line above has come back.
const session     = await api.get('/session');
const customer    = await api.get('/customer/' + session.customerId);
const eligibility = await api.get('/eligibility?customer=' + customer.id);
const offers      = await api.get('/offers?customer=' + customer.id);
const merchant    = await api.get('/merchant/' + offers[0].merchantId);
const policy      = await api.get('/policy?product=CONSUMER_DURABLES');

Three things are wrong here and only one of them is speed.

The obvious one is the waterfall: six sequential round trips over a mobile network, each paying its own latency tax.

The second is that every one of those responses is a full entity. The screen needs a masked mobile number and a display name; it receives the entire customer record. The bytes you do not use still cost you download time and parse time on a mid-range phone.

The third is the one that actually hurts a year later: the client has quietly become an orchestration layer that nobody designed. The knowledge of which services compose this screen, in what order, and what to do when one of them is slow, now lives in the frontend — and it gets reimplemented, slightly differently, in the web app and the mobile app.

One resolver per screen

The BFF inverts the relationship. The client stops asking for entities and starts asking for a screen.

The sketch below is illustrative, not production source — the SwiffyLabs implementation is private — but the shape is exactly what we run:

// One resolver per screen. The client asks for a screen, not for entities.
export async function resolveLoanOfferScreen(ctx: RequestContext) {
  // Fan out over gRPC in parallel — the critical path is the slowest call,
  // not the sum of all of them.
  const [customer, eligibility, offers, merchant, policy] = await Promise.all([
    customerClient.get({ id: ctx.customerId }),
    eligibilityClient.evaluate({ customerId: ctx.customerId }),
    offersClient.list({ customerId: ctx.customerId }),
    merchantClient.get({ id: ctx.merchantId }),
    policyClient.resolve({ product: 'CONSUMER_DURABLES' }),
  ].map(settle));

  // Partial failure is a first-class outcome: a degraded card, not a blank page.
  return {
    header: pick(customer, ['displayName', 'maskedMobile']),
    offers: offers.ok ? offers.value.map(toOfferCard) : degraded('offers'),
    eligibility: eligibility.ok ? toBanner(eligibility.value) : degraded('eligibility'),
    merchant: merchant.ok ? toMerchantChip(merchant.value) : null,
    disclosures: policy.ok ? policy.value.disclosures : [],
    traceId: ctx.traceId, // every response carries it
  };
}

Read that return value again. It is not a dump of five services — it is the screen, named the way the screen is named. header, offers, eligibility, disclosures. A frontend engineer can render it without knowing that five services exist.

The critical path is the slowest call, not the sum

Moving the fan-out behind the API boundary changes the arithmetic. Six sequential client calls cost the sum of six latencies plus six round trips. Six parallel calls from the BFF cost the slowest one, plus a single round trip to the client.

It also moves those calls onto the network where the services actually live — a datacentre link rather than a phone on a train.

I built an interactive version of this on the case study page: a waterfall you can toggle between client-orchestrated and BFF-composed. The timings there are deliberately relative rather than measured — the point is the shape of the waterfall, not a benchmark I would have to defend.

Partial failure is a product decision

This is the part most BFF write-ups skip, and it is the part I would argue for hardest.

Note the .map(settle) above. A plain Promise.all rejects the whole batch when any single call fails — which means one flaky downstream service turns the entire screen into an error page. That is almost never what the product wants.

Settling each call instead makes partial failure something you design rather than something that happens to you:

  • The offers service is down — show the header, the eligibility banner, and a retry affordance where the offers list would be.
  • The merchant lookup times out — drop the merchant chip. It was decoration.
  • The policy service is unavailable — that one is a hard stop, because shipping a lending screen without its disclosures is a compliance problem, not a UX one.

Those are three different answers, and only the person who understands the product can choose between them. Putting the fan-out in the BFF is what gives you somewhere to write that decision down.

gRPC inside, HTTP at the edge

Internally the BFF talks to services over gRPC: binary, typed, and both ends are mine, so a schema change is a compile error rather than a surprise in production.

At the edge it speaks plain HTTP and JSON. Boring, cacheable, debuggable from a browser devtools panel, and it does not ask a mobile client to carry a gRPC runtime.

The split is not ideology. Use the typed protocol where you own both ends and the call volume is high; use the boring one where you do not.

The contract comes first

Every screen endpoint gets a Swagger contract before a handler exists. It sounds bureaucratic and it is the single highest-leverage habit I have.

The argument about whether eligibility is an object or a string is cheap on a document and expensive in a sprint review. Once the contract is agreed, the frontend generates a typed client from it and starts building against a mock, while I build the resolver behind it. Neither side waits.

Integration stops being a meeting and becomes a diff.

Auth belongs to the layer

Authentication runs through Keycloak at the BFF — SSO and MFA, once. Every client that comes through the layer inherits the same auth story rather than implementing its own interpretation of it.

Auth is the wrong place to be clever. My own implementation would be worse than Keycloak's and I would own it forever.

Every response carries a trace ID

traceId in that return value is not decoration. When someone reports "the offers screen was blank on Tuesday," a trace ID turns a conversation into a waterfall: one identifier that follows the request across every service it touched.

It costs a field in every response and it pays for itself the first time you use it.


What it costs

A post that only lists benefits is marketing. The honest trade-offs:

It is another deployable. Another thing to run, monitor, secure and page someone about at 2am.

It is a coupling point by design. The BFF changes when the screen changes. That is the whole idea, but it means a UI change can now require a backend deploy — and if your release process is slow, you have just made the frontend slower to ship, not faster.

It attracts business logic. The BFF is supposed to shape and compose. It is not supposed to decide whether someone qualifies for a loan. That belongs in the eligibility service, and the pressure to "just put it in the BFF, it's quicker" never stops. Resisting it is ongoing work.

It multiplies with clients. One BFF per client surface is the canonical advice, and it is right — but two BFFs is two things to keep honest.

I would still take that trade every time for a product with more than a couple of services behind a screen. Just take it with open eyes.

Where I would start

If your frontend is doing API gymnastics to render one dashboard, you do not need to adopt a pattern wholesale. Pick the worst screen in your product — the one with the longest waterfall — and give it one endpoint that returns exactly what it renders.

You will learn more from that one screen than from any architecture diagram, this one included.

Share

Written by Chandan SM. If this was useful — or wrong — I'd like to hear about it: chandan.sm.dev@gmail.com.

Always up for a good problem

Let's build something worth maintaining

Have a platform problem, a team that needs a hand, or just want to argue about API design? My inbox is open.