The Backend for Frontend (BFF) Pattern: Stop Forcing Your Mobile App to Parse a Desktop-First API
Your mobile app is three times slower than your web app because your API was designed for a desktop browser. The BFF pattern gives each client its own backend layer, cutting payloads in half and eliminating client-side joins. Here is how to build one without duplicating business logic.
Your mobile team just shipped version 2.0. The feature set matches the web app. The screens look beautiful. And every single screen takes 2.4 seconds to load on 4G because the API returns 180 KB of JSON per page and the phone has to parse four extra fields, join data from two endpoints, and compute a derived value that the server already computed for the desktop view.
You have a single API serving two completely different clients. The web app needs lots of navigational links, embedded user profile cards, and rich text markdown. The mobile app needs the same data in a flatter structure with only the fields it renders, pre-joined so it does not have to make three round trips. Your monolithic API cannot be both things without being horrible for one of them.
The fix is the Backend for Frontend (BFF) pattern: a dedicated server-side layer per client that transforms, aggregates, and trims backend data into exactly what that client needs. One BFF per client type. Zero client-side joins. No field the mobile app does not render.
This post walks through the pattern with a real Node.js implementation, the routes where it helps most, the line between a BFF and a gateway, and the rules that keep your BFF from turning into a second monolith.
What a BFF actually is
A BFF is a thin server that sits between your clients and your backend services. Each client type (mobile, web, smart TV, CLI) has its own BFF. The BFF knows exactly what that client renders and fetches only the data the client needs, in the shape the client wants.
This is not an API gateway. An API gateway routes, rate-limits, and authenticates. That is infrastructure. A BFF is application logic. It aggregates data from multiple downstream services, transforms response shapes, formats dates for the client’s locale, and strips out fields the client does not display. It exists because the client team knows what the client needs, and that is not the same as what the backend team exposes.
Sam Newman coined the term in 2015 in a blog post about microservices. The core observation was: a general-purpose backend API is the wrong abstraction when your clients have fundamentally different rendering patterns, network characteristics, and data requirements. A mobile app on 4G is not a desktop browser on WiFi. Treating them the same makes both worse.
The problem in code
Here is a typical API response from a backend service that serves both a desktop web app and a mobile app:
{
"id": "order_9k3m2",
"status": "shipped",
"created_at": "2026-06-08T14:23:11Z",
"items": [
{
"product_id": "prod_101",
"sku": "WIDGET-BLUE-L",
"name": "Blue Widget, Large",
"description": "A large blue widget made from recycled materials with a 12-month warranty and a happy-satisfaction guarantee. Suitable for indoor and outdoor use.",
"specs": {
"weight": "1.2kg",
"dimensions": "30x20x15cm",
"material": "recycled polymer",
"color": "blue",
"max_load": "15kg"
},
"price": 29.99,
"currency": "USD",
"inventory_count": 143,
"supplier": "Acme Corp",
"supplier_id": "su_442",
"supplier_contact": "orders@acme-corp.example.com",
"warehouse_location": "Aisle 12, Bay 4"
}
],
"shipping_address": {
"street": "123 Main St",
"city": "Portland",
"state": "OR",
"zip": "97201",
"country": "US",
"latitude": 45.5152,
"longitude": -122.6784
},
"payment": {
"method": "visa",
"last_four": "4242",
"billing_zip": "97201"
},
"estimated_delivery": "2026-06-12",
"tracking_url": "https://track.example.com/9k3m2"
}
Now look at what the mobile order-detail screen actually renders:
- Order number (just the ID)
- Status badge (mapped to a color and icon)
- Item name and price (not description, specs, or supplier info)
- Shipping city and state (not lat/lng or full address)
- Estimated delivery date
That is about 15% of the payload. The mobile app downloads the other 85% just to discard it. Every field that is not rendered is wasted bytes on a mobile connection, wasted CPU cycles parsing JSON on a device with limited memory, and wasted time decoding a response that could have been 18 KB instead of 180 KB.
A BFF for the mobile app returns exactly this:
{
"id": "order_9k3m2",
"status": "shipped",
"items": [
{
"name": "Blue Widget, Large",
"price": 29.99,
"image_url": "https://cdn.example.com/widget-blue-l-thumb.jpg"
}
],
"shipping_city": "Portland",
"shipping_state": "OR",
"estimated_delivery": "2026-06-12"
}
That is 100% useful bytes. The mobile app parses it in one pass, renders the screen, and moves on.
Building a BFF in Node.js
A BFF should be a separate service with its own deployment, its own scaling characteristics, and its own API contract per client. Here is a minimal but production-ready structure for a mobile BFF in Node.js with Express.
// mobile-bff/src/server.ts
import express from 'express';
import { orderRoutes } from './routes/orders';
import { profileRoutes } from './routes/profile';
import { errorHandler } from './middleware/error-handler';
const app = express();
// Mobile BFF knows exactly which routes it supports.
// Every route is scoped to what the mobile app renders.
app.use('/api/v2/mobile', orderRoutes);
app.use('/api/v2/mobile', profileRoutes);
app.use(errorHandler);
app.listen(3001, () => {
console.log('Mobile BFF listening on 3001');
});
// mobile-bff/src/routes/orders.ts
import { Router } from 'express';
import { getOrderById } from '../services/order-service';
import { OrderMobileDTO } from '../dto/order-mobile';
const router = Router();
router.get('/orders/:id', async (req, res, next) => {
try {
// The BFF orchestrates calls to multiple backend services
const [order, tracking] = await Promise.all([
getOrderById(req.params.id),
getTrackingInfo(req.params.id),
]);
// The DTO is explicit about what the mobile app gets
res.json(new OrderMobileDTO(order, tracking));
} catch (err) {
next(err);
}
});
The key design decision is that the DTO (Data Transfer Object) lives in the BFF, not in the backend service. The backend service does not know what the mobile app needs. The BFF does.
// mobile-bff/src/dto/order-mobile.ts
interface RawOrder {
id: string;
status: string;
created_at: string;
items: RawOrderItem[];
shipping_address: RawAddress;
payment: RawPayment;
estimated_delivery: string;
tracking_url: string;
// ... 20 more fields
}
interface RawTracking {
estimated_arrival: string;
current_status: string;
carrier: string;
}
export class OrderMobileDTO {
id: string;
status_label: string;
items: Array<{ name: string; price: number; image_url: string }>;
shipping_city: string;
shipping_state: string;
estimated_delivery: string;
constructor(order: RawOrder, tracking: RawTracking) {
this.id = order.id;
this.status_label = this.mapStatus(order.status);
this.items = order.items.map((item) => ({
name: item.name,
price: item.price,
image_url: this.buildImageUrl(item.product_id),
}));
this.shipping_city = order.shipping_address.city;
this.shipping_state = order.shipping_address.state;
this.estimated_delivery = tracking.estimated_arrival;
}
private mapStatus(status: string): string {
const labels: Record<string, string> = {
pending: 'Preparing',
confirmed: 'Confirmed',
shipped: 'On the way',
delivered: 'Delivered',
cancelled: 'Cancelled',
};
return labels[status] ?? status;
}
private buildImageUrl(productId: string): string {
return `https://cdn.example.com/${productId}-thumb.jpg`;
}
}
The DTO has three responsibilities:
- Select exactly the fields the mobile screen renders. Nothing else.
- Transform data into the shape the mobile client expects (status strings, not status enums; image URLs, not product IDs).
- Join data from multiple backend calls into a single response so the mobile app makes one HTTP request per screen instead of three.
Where the BFF earns its keep
Not every route needs a BFF. The pattern pays for itself on these three types of endpoints:
Detail screens with high data density. Order details, user profiles, product pages. These screens pull from three to five backend services and render a small subset of each. Without a BFF, the client either makes N round trips or downloads N times more data than it renders.
List screens with computed fields. A timeline feed, a notification list, a home screen. These screens need data from multiple sources aggregated into a single sorted list with computed values (relative timestamps, status labels, unread counts). The BFF computes these server-side and the mobile app just renders the list.
Screens with device-specific logic. A mobile home screen might show a quick-order button that requires pre-computed pricing, inventory, and delivery estimates. A web home screen might show an editorial carousel that pulls from a CMS. The BFF encapsulates the difference so the shared backend does not have to model “home screen behavior.”
The web BFF is different
The mobile BFF above is one BFF. The web app should have a separate one, because the web app needs different things:
// web-bff/src/dto/order-web.ts
interface CMSContent {
promotion_text: string;
related_articles: Array<{ title: string; url: string }>;
}
export class OrderWebDTO {
id: string;
status: string;
items: Array<{
name: string;
description: string; // Web renders markdown descriptions
specs: Record<string, string>; // Web shows a spec table
price: number;
supplier_name: string; // Web shows supplier attribution
}>;
shipping: {
street: string; // Web shows full address for editing
city: string;
state: string;
zip: string;
};
promotion: string | null; // Web shows CMS-driven upsells
related_content: typeof CMSContent.related_articles;
constructor(order: RawOrder, cms: CMSContent) {
// Web DTO includes rich fields the mobile BFF intentionally drops
this.items = order.items.map((item) => ({
name: item.name,
description: item.description,
specs: item.specs,
price: item.price,
supplier_name: item.supplier,
}));
this.shipping = {
street: order.shipping_address.street,
city: order.shipping_address.city,
state: order.shipping_address.state,
zip: order.shipping_address.zip,
};
this.promotion = cms.promotion_text ?? null;
this.related_content = cms.related_articles;
}
}
Same backend. Same order. Two BFFs. Each returns exactly what its client renders. The backend never has to know.
BFF vs API Gateway vs GraphQL
Teams often ask whether a BFF replaces an API gateway or GraphQL. It does neither.
| Layer | Owned by | Concern |
|---|---|---|
| API Gateway | Platform/Infra | Routing, auth, rate limiting, TLS |
| BFF | Client team | Data shaping, aggregation, transforms |
| Backend API | Backend team | Business logic, persistence, domain |
A gateway routes traffic to the right BFF. The BFF aggregates data from backend APIs. GraphQL can exist inside a BFF or at the backend API layer, but BFFs do not need GraphQL to do their job. A BFF that returns hand-crafted DTOs over REST is simpler to reason about, easier to profiler, and harder to accidentally N+1.
If you already use GraphQL, a BFF can sit in front of it: the BFF runs a single well-known query against your GraphQL backend and transforms the result into the mobile DTO. You get GraphQL’s flexibility at the backend and the BFF’s payload guarantees at the client. This is common in production.
The rules that keep a BFF from rotting
A BFF is a thin layer. It stays thin only if you enforce these rules:
Rule 1: No business logic. The BFF does not validate inventory, calculate prices, enforce permissions, or decide shipping dates. It fetches data from backend services that do these things and transforms the result. If you find an if statement that checks whether something is allowed, move it to the backend.
Rule 2: One BFF per client type. Do not build one BFF that serves both mobile and web by branching on a header. That is just the monolith with extra steps. Two BFFs that share a library of downstream client helpers (call timeout configs, retry wrappers) are fine. Two BFFs that share route handlers are not.
Rule 3: The BFF owns its schema. The backend team can add fields. The BFF decides whether to pass them through or drop them. The frontend team owns the BFF code and deploys it independently. This is the organizational win: the mobile team can ship a new screen without a backend PR.
Rule 4: Keep the BFF stateless. A BFF should be horizontally scalable without sticky sessions. Session state belongs in a session store that the BFF reads from. The BFF itself is just stateless transformation pipelines.
Rule 5: Cache aggressively. Since the BFF returns pre-shaped data that changes less often than the backend’s raw tables, it is an excellent place to add HTTP caching layers. A mobile BFF returning order DTOs can set Cache-Control: private, max-age=60 and reduce backend load by an order of magnitude for repeat views.
When you do not need a BFF
The BFF pattern adds a deployable service, a CI pipeline, monitoring, and on-call rotation. That is overhead. Skip it when:
- You have one client type (internal admin dashboard, single-page app, no mobile).
- Your backend API already returns flat DTOs per client via a GraphQL layer with persisted queries.
- Your team is three people and your “backend” is a single Node.js service. Add a BFF when the API team is separate from the frontend team or when mobile performance becomes a measurable problem.
The right time to add a BFF is not before you need it. It is the day your mobile team opens a PR that adds a ?client=mobile query parameter to every backend endpoint and the backend team says “this is getting out of hand.”
The takeaway
A general-purpose API is a compromise that makes every client slightly unhappy. The BFF pattern replaces compromise with precision: one backend per client, each returning exactly the data its client renders, in the shape its client expects, without client-side joins or discarded fields.
The implementation is simple: a thin Node.js service per client type with explicit DTOs, orchestrating calls to backend services and transforming results. The organizational structure is where the pattern really matters: the mobile team owns the mobile BFF, the web team owns the web BFF, and the backend team owns the core business logic. Each team ships independently.
Start with one BFF for your worst-performing client. Put it behind your existing API gateway. Measure the payload reduction and the time-to-render. The numbers will tell you whether to build the next one.
A note from Yojji
The BFF pattern is a classic example of architecture that follows organizational boundaries: giving each client team ownership of its own data-shaping layer instead of forcing a single backend API to satisfy contradictory requirements. Getting the deployment boundaries, the DTO structure, and the caching strategy right is the difference between a BFF that accelerates your team and one that becomes just another service to maintain.
Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their teams specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and full-cycle product engineering from discovery through DevOps. If your team is weighing API architecture decisions and wants to move faster without accumulating portable complexity, Yojji is worth a conversation.