Skip to content
Express.js ex microservices 5 min read

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.

ConcernWithout a BFFWith a BFF
Payload shapeOne schema for all clientsTailored per client
Round tripsClient stitches many callsOne call per screen
Over-fetchingMobile downloads unused fieldsServer trims to what’s needed
OwnershipBackend team owns the APIUI 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/catch becomes 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 use Promise.allSettled where partial failure should degrade gracefully.
  • Wrap every downstream call in a timeout and AbortController so 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.
Last updated June 14, 2026
Was this helpful?