Backend for Frontend (BFF)
A single shared API rarely fits every client equally well. A web dashboard wants rich, fully-expanded objects; a mobile app on a flaky connection wants slim payloads and as few round trips as possible. The Backend for Frontend (BFF) pattern solves this by giving each client type its own dedicated backend that aggregates downstream services and shapes responses for exactly that experience. Express is ideal for a BFF because it is a thin, composable HTTP layer — precisely what sits between a client and your services.
What problem the BFF solves
When many client types share one general-purpose API, that API drifts toward the lowest common denominator. Either it returns everything (over-fetching that hurts mobile) or clients make many small calls to assemble a screen (chattiness that hurts latency). A BFF moves that orchestration and shaping out of the client and into a backend owned by the same team that builds the UI.
| Concern | Without a BFF | With a BFF |
|---|---|---|
| Payload shape | One schema for all clients | Tailored per client |
| Round trips | Client stitches many calls | One call per screen |
| Over-fetching | Mobile downloads unused fields | Server trims to what’s needed |
| Ownership | Backend team owns the API | UI team owns its BFF |
Tip: A BFF is not an API gateway. A gateway is one shared edge for cross-cutting concerns (auth, rate limiting, routing). A BFF is per-client and contains presentation logic. Many systems run BFFs behind a gateway.
One backend per client type
The defining rule of the pattern is one BFF per user-facing experience: a web BFF, a mobile BFF, perhaps a partner/public BFF. Each is a small Express app that depends on the same downstream services but composes them differently. Keep them as separate deployables so a change for mobile never risks the web experience.
npm install express
// mobile-bff/server.js
const express = require('express');
const app = express();
const services = {
users: 'http://users-service:3001',
orders: 'http://orders-service:3002',
catalog: 'http://catalog-service:3003',
};
const SERVICE_TIMEOUT = 2000;
async function fetchJson(url, headers) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), SERVICE_TIMEOUT);
try {
const res = await fetch(url, { headers, signal: controller.signal });
if (!res.ok) throw new Error(`${url} -> ${res.status}`);
return await res.json();
} finally {
clearTimeout(timer);
}
}
module.exports = { app, services, fetchJson };
Aggregating downstream services
The core job of a BFF endpoint is to map one screen to one request. Instead of the client calling users, orders, and catalog separately, the BFF fans out concurrently with Promise.all and assembles the result. Running the calls in parallel means the latency is roughly the slowest single service, not the sum.
const { app, services, fetchJson } = require('./server');
// One call powers the entire mobile "home" screen
app.get('/home', async (req, res, next) => {
const headers = { 'X-User-Id': req.headers['x-user-id'] };
try {
const [profile, orders] = await Promise.all([
fetchJson(`${services.users}/${req.headers['x-user-id']}`, headers),
fetchJson(`${services.orders}?limit=3`, headers),
]);
// Shape for mobile: trim fields, flatten, rename
res.json({
greeting: `Hi ${profile.firstName}`,
avatar: profile.avatarUrl,
recentOrders: orders.map((o) => ({
id: o.id,
status: o.status,
total: o.totalCents / 100,
})),
});
} catch (err) {
next(err);
}
});
Output:
GET /home
X-User-Id: u_42
HTTP/1.1 200 OK
{
"greeting": "Hi Ada",
"avatar": "https://cdn.example.com/u_42.png",
"recentOrders": [
{ "id": "o_7", "status": "shipped", "total": 42.00 }
]
}
The mobile client receives one compact, ready-to-render payload. Note the BFF did the unit conversion (totalCents / 100) and dropped every field the screen never shows.
Shaping responses per client
The same downstream data should look different per BFF. A web BFF backing a desktop dashboard can afford to expand related entities and keep more detail; the mobile BFF trims aggressively. Because each BFF owns its own transform, neither constrains the other.
// web-bff: richer shape for a desktop dashboard
app.get('/dashboard', async (req, res, next) => {
const headers = { 'X-User-Id': req.headers['x-user-id'] };
try {
const [profile, orders] = await Promise.all([
fetchJson(`${services.users}/${req.headers['x-user-id']}`, headers),
fetchJson(`${services.orders}?limit=50`, headers),
]);
// Expand product details inline — fine on a fast connection
const enriched = await Promise.all(
orders.map(async (o) => ({
...o,
product: await fetchJson(`${services.catalog}/${o.productId}`, headers),
})),
);
res.json({ profile, orders: enriched });
} catch (err) {
next(err);
}
});
Warning: Resist letting business rules leak into the BFF. It should aggregate and reshape, not enforce domain invariants — those belong in the owning service. A BFF stuffed with logic becomes a second monolith that two teams must coordinate on.
Reducing chatty frontend calls
The latency win is real on mobile networks where each round trip can cost 100ms or more. Replacing three sequential client requests with one BFF call removes two full network round trips for the user and converts them into fast, in-datacenter parallel calls. For resilience, prefer Promise.allSettled when a screen can still render with a section missing, so one failing service degrades gracefully rather than blanking the page.
const [profile, orders, promos] = await Promise.allSettled([
fetchJson(`${services.users}/${userId}`, headers),
fetchJson(`${services.orders}?limit=3`, headers),
fetchJson(`${services.catalog}/promos`, headers),
]);
res.json({
profile: profile.status === 'fulfilled' ? profile.value : null,
orders: orders.status === 'fulfilled' ? orders.value : [],
promos: promos.status === 'fulfilled' ? promos.value : [],
});
Note: On Express 5, an async handler that rejects is forwarded to your error middleware automatically, so the
try/catchbecomes optional. On Express 4 you still need it (or an async wrapper) to avoid an unhandled rejection.
Best Practices
- Run one BFF per client experience (web, mobile, partner) and deploy each independently so changes stay isolated.
- Keep BFFs thin: aggregate and reshape responses, but leave business rules and domain validation inside the services.
- Fan out to downstream services concurrently with
Promise.all, and usePromise.allSettledwhere partial failure should degrade gracefully. - Wrap every downstream call in a timeout and
AbortControllerso one stalled service cannot hang the BFF. - Let the team that owns the UI also own its BFF — the pattern’s value comes from that shared ownership.
- Place BFFs behind a shared API gateway for auth and rate limiting rather than reimplementing those concerns in each one.
- Forward a correlation/request ID downstream so a single user action stays traceable across every service it touches.