Skip to main content

BFF (Backend for Frontend) ๐ŸŽฏ

The BFF pattern introduces a dedicated backend layer for each client type โ€” mobile, web, IoT, or third-party integrations โ€” that sits between the client and downstream microservices. Each BFF is purpose-built: it knows exactly what data that specific client needs, how to fetch it efficiently, and how to shape the response for that client's UI.

One-size-fits-all APIs force every client to carry the complexity. BFF puts that complexity back on the server, where it belongs โ€” and tailors it per client.


The Problem BFF Solvesโ€‹

In a microservices architecture, clients often need data from multiple services to render a single screen. Without BFF, clients face several problems:

ProblemWithout BFFWith BFF
Over-fetchingMobile receives 60 fields for a list view that only needs 6BFF strips unused fields before sending to mobile
Under-fetchingClient makes 5โ€“8 round-trips to different services for one screenBFF aggregates into 1 request
Chatty clientsHigh-latency mobile networks amplify round-trip costBFF reduces calls; client pays one network round-trip
Protocol mismatchMobile wants compact binary; service speaks verbose JSONBFF translates protocols per client
Client-side logic bloatData transformation, merging, and error handling live in the appAll orchestration lives in the BFF
Tight couplingEvery client embeds knowledge of every service endpointClients know only their BFF contract
Versioning painChanging a downstream service breaks all clientsBFF absorbs the change; client contract stays stable

Architectureโ€‹

graph TD
subgraph Clients
WEB[๐ŸŒ Web Browser<br/>SPA / SSR]
MOBILE[๐Ÿ“ฑ Mobile App<br/>iOS / Android]
IOT[๐Ÿ“Ÿ IoT Device<br/>Constrained]
PARTNER[๐Ÿค Third-Party<br/>Integration]
end

subgraph BFF Layer
BFF_WEB[Web BFF<br/>Next.js / Express / NestJS]
BFF_MOBILE[Mobile BFF<br/>Express / NestJS]
BFF_IOT[IoT BFF<br/>Lightweight gRPC / MQTT]
BFF_PARTNER[Partner BFF<br/>REST with API Key Auth]
end

subgraph Downstream Services
AUTH[๐Ÿ” Auth Service]
USERS[๐Ÿ‘ค User Service]
PRODUCTS[๐Ÿ“ฆ Product Service]
ORDERS[๐Ÿ›’ Order Service]
SEARCH[๐Ÿ” Search Service]
NOTIF[๐Ÿ”” Notification Service]
end

subgraph Infrastructure
GW[API Gateway<br/>Rate Limiting / Auth / Routing]
DISCOVERY[Service Registry<br/>Consul / K8s DNS]
end

WEB --> BFF_WEB
MOBILE --> BFF_MOBILE
IOT --> BFF_IOT
PARTNER --> BFF_PARTNER

BFF_WEB --> GW
BFF_MOBILE --> GW
BFF_IOT --> GW
BFF_PARTNER --> GW

GW --> AUTH
GW --> USERS
GW --> PRODUCTS
GW --> ORDERS
GW --> SEARCH
GW --> NOTIF

GW -.-> DISCOVERY
BFF_WEB -.-> DISCOVERY
BFF_MOBILE -.-> DISCOVERY

Note: In some architectures, the BFF itself handles authentication and directly calls services via service discovery โ€” bypassing an API gateway for internal east-west traffic. Both patterns are valid; the diagram above shows one common layout.


BFF Responsibilitiesโ€‹

A BFF is not a simple proxy. It owns the orchestration layer for its client:

1. Data Aggregationโ€‹

Combine responses from multiple downstream services into a single, coherent response tailored to the screen the client is rendering.

Client requests: GET /dashboard
BFF internally calls:
โ†’ GET user-service:/users/me (profile)
โ†’ GET order-service:/orders?status=active (active orders)
โ†’ GET notif-service:/notifications/unread (unread count)
Response to client: { user, activeOrders, unreadNotifications }

2. Data Transformationโ€‹

Reshape, rename, and restructure data so the client receives exactly the shape its UI needs โ€” no mapping logic on the client side.

// Downstream User Service returns:
{ id: 42, first_name: "Alice", last_name: "Kim", email: "alice@example.com", ...(extraneous 20 fields) }

// Mobile BFF transforms to:
{ id: 42, displayName: "Alice K.", initials: "AK" }

3. Protocol Translationโ€‹

Clients may speak different protocols than downstream services. A BFF bridges that gap:

Client โ†’ BFFBFF โ†’ Services
REST (JSON)gRPC
GraphQLREST
WebSocketREST + Redis Pub/Sub
MQTT (IoT)gRPC / REST
Binary (protobuf)REST (JSON)

4. Response Stripping & Field Selectionโ€‹

The web BFF might return 40 fields for a rich admin dashboard, while the mobile BFF returns only 8 fields for the same logical resource โ€” dramatically reducing payload size over cellular networks.

5. Client-Specific Auth & Session Managementโ€‹

  • Web BFF: Cookie-based sessions with CSRF protection
  • Mobile BFF: Bearer token with refresh token rotation
  • IoT BFF: mTLS with client certificates
  • Partner BFF: API key + HMAC request signing

6. Error Normalizationโ€‹

Each downstream service may return errors in its own format. The BFF normalizes them into a consistent structure the client expects:

// Order Service returns:
{ error: "OUT_OF_STOCK", itemId: "sku-123" }

// User Service returns:
{ status: 404, message: "User not found", code: "ENTITY_NOT_FOUND" }

// BFF normalizes both to:
{
error: {
code: "DOWNSTREAM_ERROR",
message: "Unable to load dashboard. Some services are unavailable.",
details: [
{ service: "orders", reason: "Item SKU-123 is out of stock" },
{ service: "users", reason: "User profile not found" }
]
}
}

Per-Client BFF Typesโ€‹

Web BFFโ€‹

The web BFF serves browser-based SPAs (React, Vue, Angular) and server-rendered apps (Next.js, Nuxt).

Characteristics:

  • Returns rich, fully-hydrated JSON payloads
  • Cookie-based session handling (HttpOnly, Secure, SameSite)
  • CSRF token management
  • SSR support โ€” can pre-fetch data on the server before rendering
  • Often colocated with the web server (Next.js API routes, Express middleware)

Typical tech stack: Next.js API routes, Express, NestJS, Fastify

// Web BFF endpoint for a product detail page
// Next.js App Router โ€” Route Handler
// app/api/products/[id]/route.ts
export async function GET(request: Request, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 });

const [product, reviews, recommendations] = await Promise.all(
[
fetch(`http://product-service/products/${params.id}`),
fetch(`http://review-service/products/${params.id}/reviews?limit=10`),
fetch(`http://rec-service/products/${params.id}/similar?limit=4`),
].map((p) => p.then((r) => r.json())),
);

return Response.json({
product,
reviews: reviews.data,
recommendations: recommendations.data,
// Web-specific: include SEO metadata
seo: {
title: product.name,
description: product.shortDescription,
ogImage: product.images[0],
},
});
}

Mobile BFFโ€‹

The mobile BFF optimizes for constrained environments: high-latency cellular networks, limited bandwidth, and battery-conscious applications.

Characteristics:

  • Payload minimization โ€” strip every unused field; every byte matters
  • Batched endpoints โ€” one request per screen, not per widget
  • Delta/partial updates โ€” send only what changed (e.g., via If-Modified-Since or ETags)
  • Offline-first โ€” include lastModified timestamps for client-side caching
  • Image resizing hints โ€” return CDN URLs with size parameters
  • Binary protocol support โ€” protobuf or MessagePack for extreme efficiency
// Mobile BFF โ€” stripped-down product endpoint
// Returns ~200 bytes vs ~8 KB from the full service

interface MobileProductDTO {
id: string;
title: string;
price: string; // Pre-formatted: "$29.99"
thumb: string; // 120ร—120 thumbnail URL
inStock: boolean;
rating: number; // 4.5 (not the full breakdown)
}

app.get('/api/mobile/products/:id', async (req, res) => {
const product = await productService.get(req.params.id);

const mobileResponse: MobileProductDTO = {
id: product.id,
title: product.name,
price: formatCurrency(product.price, product.currency),
thumb: `${CDN_URL}/products/${product.id}?w=120&h=120&fit=crop`,
inStock: product.inventory.available > 0,
rating: product.reviews.averageRating,
};

res.json(mobileResponse);
});

IoT BFFโ€‹

IoT devices often have severe constraints: limited RAM (kilobytes), narrow bandwidth (kilobits/sec), and intermittent connectivity.

Characteristics:

  • Compact binary protocols (protobuf, CBOR, MessagePack)
  • MQTT or CoAP for transport (not HTTP)
  • Register device-specific data contracts
  • Handle device wake/sleep cycles
  • Edge computing โ€” run part of the BFF on the edge near the device

Partner / Third-Party BFFโ€‹

When exposing APIs to external partners, a dedicated BFF provides a stable, versioned, and well-documented contract that insulates partners from internal service changes.

Characteristics:

  • Strict versioning (/v1/, /v2/)
  • API key or OAuth2 Client Credentials authentication
  • Rate limiting per partner
  • Detailed API documentation (OpenAPI spec published)
  • Webhook orchestration
  • SLA monitoring and usage billing

BFF vs API Gateway vs GraphQLโ€‹

These three patterns are often confused. Here's how they compare:

graph LR
subgraph "API Gateway"
GW2[Gateway]
end
subgraph "GraphQL"
GQL[GraphQL Server]
end
subgraph "BFF"
BFF_W[Web BFF]
BFF_M[Mobile BFF]
end

GW2 --> SVC[Downstream<br/>Services]
GQL --> SVC
BFF_W --> SVC
BFF_M --> SVC
CriteriaAPI GatewayBFFGraphQL
PurposeSingle entry point, cross-cutting concernsTailored API per clientFlexible query language
How many?One per systemOne per client type (web, mobile, IoT...)One (or federated)
Client specificityGeneric โ€” all clients share the same APIHigh โ€” each BFF is client-specificMedium โ€” clients control what they query
Data shape controlService decidesBFF decidesClient decides
Cross-cuttingAuth, rate limiting, TLS, loggingAuth, aggregation, transformation, cachingAuth, query validation, depth limiting
N+1 problemUpstream (gateway calls services sequentially or in parallel)Controlled internally by BFFClassic N+1 resolved with DataLoader
Over-fetchingYes โ€” fixed responses from servicesNo โ€” BFF strips unused fieldsNo โ€” client asks for exact fields
ComplexityLow โ€” mostly routingMedium โ€” per-client codebaseMediumโ€“High โ€” schema design, resolver optimization, security
Best forRouting, rate limiting, initial setupTeams with distinct client needsData-rich apps with deeply nested entities

Decision Frameworkโ€‹

Do you have multiple client types (web, mobile, IoT) with different data needs?
โ”‚
โ”œโ”€โ”€ NO โ†’ Do you need flexible queries where clients pick fields?
โ”‚ โ”œโ”€โ”€ YES โ†’ GraphQL
โ”‚ โ””โ”€โ”€ NO โ†’ Simple REST API (no BFF or Gateway complexity needed)
โ”‚
โ””โ”€โ”€ YES โ†’ Do clients need to aggregate data from 3+ services per screen?
โ”œโ”€โ”€ YES โ†’ BFF per client type + API Gateway for cross-cutting
โ””โ”€โ”€ NO โ†’ API Gateway alone may suffice

They Work Togetherโ€‹

These patterns are not mutually exclusive. A mature architecture often layers them:

[Web Client] โ†’ [Web BFF] โ†’ [API Gateway] โ†’ [Downstream Services]
[Mobile App] โ†’ [Mobile BFF] โ”€โ”€โ”˜

The API Gateway handles cross-cutting concerns (auth, rate limiting, TLS, logging); each BFF handles client-specific aggregation, transformation, and protocol translation.


BFF Implementationโ€‹

Express BFFโ€‹

Express is the most common choice for lightweight BFF implementations. Its middleware model maps naturally to BFF concerns:

// express-bff/src/server.ts
import express from 'express';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { authenticate } from './middleware/auth';
import { circuitBreaker } from './middleware/circuitBreaker';
import { cache } from './middleware/cache';

const app = express();

app.use(cors({ origin: process.env.WEB_APP_ORIGIN, credentials: true }));
app.use(cookieParser());
app.use(express.json());

// ---- Auth middleware ----
app.use('/api/*', authenticate);

// ---- Dashboard endpoint (aggregation) ----
app.get('/api/dashboard', cache({ ttl: 30 }), async (req, res, next) => {
try {
const userId = req.user!.id;

// Parallel aggregation of independent calls
const [profile, activeOrders, notifications, recommendations] = await Promise.allSettled([
userService.getProfile(userId),
orderService.getActiveOrders(userId),
notificationService.getUnreadCount(userId),
recommendationService.getForUser(userId),
]);

// Graceful degradation: partial data is better than no data
res.json({
profile: getValue(profile),
activeOrders: getValue(activeOrders, []),
unreadNotifications: getValue(notifications, 0),
recommendations: getValue(recommendations, []),
degraded: getDegradedFields({ profile, activeOrders, notifications, recommendations }),
});
} catch (err) {
next(err);
}
});

// ---- Product detail with downstream BFF aggregation ----
app.get('/api/products/:id', async (req, res) => {
const { id } = req.params;

// Sequential + parallel hybrid:
// Step 1: Fetch product (needed for step 2)
const product = await productService.getById(id);

// Step 2: Fetch related data in parallel (all depend on product)
const [reviews, inventory, relatedProducts] = await Promise.all([
reviewService.getForProduct(id, { limit: 10 }),
inventoryService.getStock(product.skus),
productService.getRelated(id, { limit: 4 }),
]);

// Transform to web-specific shape
res.json({
product: {
...product,
// Web-specific: render full review breakdown
ratingBreakdown: reviews.summary,
// Web-specific: show inventory by warehouse
stockByWarehouse: inventory.warehouses,
},
reviews: reviews.items,
relatedProducts,
});
});

// ---- Error handler (normalize downstream errors) ----
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
const normalizedError = normalizeError(err, req.id);
logger.error({ err: normalizedError, requestId: req.id });
res.status(normalizedError.statusCode).json({ error: normalizedError });
});

app.listen(4000, () => console.log('Web BFF listening on :4000'));

NestJS BFFโ€‹

NestJS brings structure, dependency injection, and a module system that scales well for larger BFF codebases:

// nestjs-bff/src/bff-web/bff-web.module.ts
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { CacheModule } from '@nestjs/cache-manager';
import { BffWebController } from './bff-web.controller';
import { BffWebService } from './bff-web.service';
import { UserServiceClient } from './clients/user-service.client';
import { OrderServiceClient } from './clients/order-service.client';
import { NotificationServiceClient } from './clients/notification-service.client';
import { CircuitBreakerService } from '../common/circuit-breaker.service';

@Module({
imports: [
HttpModule.register({ timeout: 5000, maxRedirects: 2 }),
CacheModule.register({ ttl: 30, max: 100 }),
],
controllers: [BffWebController],
providers: [
BffWebService,
UserServiceClient,
OrderServiceClient,
NotificationServiceClient,
CircuitBreakerService,
],
})
export class BffWebModule {}
// nestjs-bff/src/bff-web/bff-web.service.ts
import { Injectable } from '@nestjs/common';
import { UserServiceClient } from './clients/user-service.client';
import { OrderServiceClient } from './clients/order-service.client';
import { NotificationServiceClient } from './clients/notification-service.client';
import { CircuitBreakerService } from '../common/circuit-breaker.service';

interface DashboardData {
profile: UserProfileDTO | null;
activeOrders: OrderDTO[];
unreadNotifications: number;
degraded: string[];
}

@Injectable()
export class BffWebService {
constructor(
private readonly userClient: UserServiceClient,
private readonly orderClient: OrderServiceClient,
private readonly notificationClient: NotificationServiceClient,
private readonly circuitBreaker: CircuitBreakerService,
) {}

async getDashboard(userId: string): Promise<DashboardData> {
const degraded: string[] = [];

const [profile, orders, notifications] = await Promise.allSettled([
this.circuitBreaker.execute('user-service', () => this.userClient.getProfile(userId)),
this.circuitBreaker.execute('order-service', () => this.orderClient.getActiveOrders(userId)),
this.circuitBreaker.execute('notification-service', () =>
this.notificationClient.getUnreadCount(userId),
),
]);

return {
profile: this.unwrap(profile, null, 'userProfile', degraded),
activeOrders: this.unwrap(orders, [], 'activeOrders', degraded),
unreadNotifications: this.unwrap(notifications, 0, 'notifications', degraded),
degraded,
};
}

private unwrap<T>(
result: PromiseSettledResult<T>,
fallback: T,
field: string,
degraded: string[],
): T {
if (result.status === 'fulfilled') return result.value;
degraded.push(field);
return fallback;
}
}
// nestjs-bff/src/bff-web/bff-web.controller.ts
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import { AuthGuard } from '../common/auth.guard';
import { BffWebService } from './bff-web.service';

@Controller('api')
@UseGuards(AuthGuard)
export class BffWebController {
constructor(private readonly bffService: BffWebService) {}

@Get('dashboard')
async getDashboard(@Req() req: AuthenticatedRequest) {
return this.bffService.getDashboard(req.user.id);
}
}

Authentication Flow in BFFโ€‹

The BFF sits at the trust boundary: it authenticates the client and then propagates identity to downstream services. There are two common patterns:

Pattern A: Token Relayโ€‹

The BFF receives the client's token, validates it, and forwards it to downstream services:

sequenceDiagram
participant Client
participant BFF
participant AuthService
participant OrderService

Client->>BFF: GET /dashboard + Bearer <access_token>
BFF->>AuthService: POST /verify { token }
AuthService-->>BFF: { userId, roles }
BFF->>OrderService: GET /orders?userId=42<br/>Authorization: Bearer <access_token>
OrderService->>AuthService: Verify token (or trust propagated identity)
OrderService-->>BFF: [orders...]
BFF-->>Client: { dashboard data }

Pattern B: Service Token (preferred for internal calls)โ€‹

The BFF exchanges the client's external token for an internal service token with broader privileges:

// The BFF authenticates the client, then uses its own service account
// for downstream calls โ€” the downstream services trust the BFF.

async function getDashboard(userId: string) {
// Internal service token โ€” not the user's token
const serviceToken = await getServiceToken({ scope: 'bff:web' });

const headers = { Authorization: `Bearer ${serviceToken}` };
const [profile, orders] = await Promise.all(
[
fetch(`http://user-service/users/${userId}`, { headers }),
fetch(`http://order-service/orders?userId=${userId}`, { headers }),
].map((p) => p.then((r) => r.json())),
);

return { profile, orders };
}
ApproachProsCons
Token RelayTrue end-to-end user context; audit trail intactUser tokens may not have downstream permissions; token expiry mid-request
Service TokenBFF controls scope; no expiry issues; simpler downstream authDownstream loses original user context (pass X-User-Id header explicitly)
HybridService token for auth + X-On-Behalf-Of header with user IDBest of both worlds โ€” recommended

Data Aggregation Patternsโ€‹

When a BFF must call multiple downstream services to build a response, how you sequence those calls matters for latency, reliability, and correctness.

Sequential (Waterfall)โ€‹

Use when one call depends on the result of a previous call:

async function getOrderWithDetails(orderId: string) {
// Step 1: Fetch the order
const order = await orderService.getById(orderId); // 60 ms

// Step 2: Fetch related data (depends on order)
const [user, tracking, invoice] = await Promise.all([
userService.getById(order.userId), // 45 ms
shippingService.getTracking(order.trackingNumber), // 80 ms
billingService.getInvoice(order.invoiceId), // 50 ms
]);

// Total: 60 + max(45, 80, 50) = 60 + 80 = 140 ms
return { ...order, user, tracking, invoice };
}

Parallel (Scatter-Gather)โ€‹

Use when calls are independent. Always use Promise.allSettled (not Promise.all) so one failure doesn't kill the entire response:

async function getDashboard(userId: string) {
const [profile, orders, notifications, recs] = await Promise.allSettled([
userService.getProfile(userId), // independent
orderService.getActiveOrders(userId), // independent
notificationService.getUnreadCount(userId), // independent
recommendationService.getForUser(userId), // independent
]);

return {
profile: settle(profile, null),
orders: settle(orders, []),
unread: settle(notifications, 0),
recommended: settle(recs, []),
};
}

function settle<T>(result: PromiseSettledResult<T>, fallback: T): T {
return result.status === 'fulfilled' ? result.value : fallback;
}

Hybrid (DAG โ€” Directed Acyclic Graph)โ€‹

Real-world screens often require a dependency graph. Model it as a DAG where nodes are service calls and edges are dependencies:

graph TD
START((Request)) --> A[Get User Profile]
START --> B[Get Cart]
A --> C[Get Order History]
B --> D[Get Inventory for Cart Items]
B --> E[Get Recommendations<br/>based on Cart]
C --> F[Get Shipping Status<br/>for Recent Orders]
async function getFullUserContext(userId: string, cartId: string) {
// Level 1: No dependencies โ€” fire in parallel
const [user, cart] = await Promise.all([
userService.getProfile(userId),
cartService.getCart(cartId),
]);

// Level 2: Depends on results from Level 1
const [wishlist, orderHistory] = await Promise.all([
wishlistService.getForUser(userId), // can run with L1
orderService.getHistory(userId), // can run with L1
]);

// Level 3: Depends on cart items from Level 1 + orderHistory from Level 2
const [inventory, shippingStatuses] = await Promise.all([
inventoryService.getForSkus(cart.items.map((i) => i.sku)), // depends on cart
shippingService.getStatuses(
// depends on orderHistory
orderHistory.orders.slice(0, 3).map((o) => o.trackingId),
),
]);

// Level 4: Recommendations based on cart + order history
const recommendations = await recommendationService.getForUser(userId, {
basedOnCart: cart.items.map((i) => i.productId),
excludePreviouslyPurchased: orderHistory.productIds,
});

return { user, cart, wishlist, orderHistory, inventory, shippingStatuses, recommendations };
}

Aggregation Comparisonโ€‹

PatternLatencyComplexityFailure IsolationWhen to Use
SequentialSum of all callsLowMust abort on first failureStrict dependencies
ParallelMax of all callsLowโ€“MediumUse allSettled for partial resultsIndependent calls
Hybrid (DAG)Critical path lengthMediumโ€“HighPer-node fallbacksComplex screens, 5+ services

BFF Cachingโ€‹

Caching at the BFF layer reduces downstream load and improves response times. Since the BFF understands client needs, it can cache intelligently:

Cache Strategiesโ€‹

// 1. TTL Cache โ€” simple, great for dashboard data
app.get('/api/dashboard', cache({ ttl: 30 }), dashboardHandler);

// 2. Stale-While-Revalidate โ€” serve cached data immediately, refresh in background
async function getWithSWR<T>(key: string, fetcher: () => Promise<T>, ttl: number): Promise<T> {
const cached = await redis.get(key);
if (cached) {
// Trigger background refresh
fetcher()
.then((data) => redis.setex(key, ttl, JSON.stringify(data)))
.catch(() => {});
return JSON.parse(cached);
}
const data = await fetcher();
await redis.setex(key, ttl, JSON.stringify(data));
return data;
}

// 3. ETag/If-None-Match โ€” client stores hash; BFF returns 304 if unchanged
app.get('/api/products/:id', async (req, res) => {
const product = await productService.getById(req.params.id);
const etag = computeETag(product);

res.setHeader('ETag', etag);
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.json(product);
});

// 4. Per-User Cache Invalidation โ€” when user data changes
// Use Redis keys like: bff:web:dashboard:user:42
// Invalidate on relevant events (order placed, profile updated)

What to Cache at the BFFโ€‹

Good CandidatesPoor Candidates
Dashboard aggregations (TTL: 15โ€“60s)Real-time stock prices
Product detail pages (TTL: 5โ€“15 min)User-specific live notifications
Category listings (TTL: 5โ€“30 min)Shopping cart contents
User preferences/settings (TTL: 5โ€“60 min)Order status during checkout flow
Configuration/feature flags (TTL: 1โ€“5 min)Anything requiring strong consistency

Error Handling & Resilienceโ€‹

A BFF aggregates data from multiple services โ€” any of which can fail. The BFF must never let a downstream failure cascade into a broken client experience.

Circuit Breakerโ€‹

Prevent cascading failures by stopping calls to a failing service after a threshold:

// circuit-breaker.ts
type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN';

class CircuitBreaker {
private state: CircuitState = 'CLOSED';
private failureCount = 0;
private lastFailureTime = 0;
private readonly threshold: number;
private readonly resetTimeout: number;

constructor(threshold = 5, resetTimeoutMs = 30_000) {
this.threshold = threshold;
this.resetTimeout = resetTimeoutMs;
}

async execute<T>(name: string, fn: () => Promise<T>): Promise<T> {
if (this.state === 'OPEN') {
if (Date.now() - this.lastFailureTime > this.resetTimeout) {
this.state = 'HALF_OPEN';
logger.info(`[CircuitBreaker] ${name}: OPEN โ†’ HALF_OPEN`);
} else {
throw new CircuitOpenError(`Circuit for ${name} is OPEN`);
}
}

try {
const result = await fn();
this.onSuccess(name);
return result;
} catch (err) {
this.onFailure(name);
throw err;
}
}

private onSuccess(name: string): void {
if (this.state === 'HALF_OPEN') {
this.state = 'CLOSED';
this.failureCount = 0;
logger.info(`[CircuitBreaker] ${name}: HALF_OPEN โ†’ CLOSED`);
}
this.failureCount = 0;
}

private onFailure(name: string): void {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.state === 'HALF_OPEN' || this.failureCount >= this.threshold) {
this.state = 'OPEN';
logger.warn(`[CircuitBreaker] ${name}: โ†’ OPEN (${this.failureCount} failures)`);
}
}
}

Fallback Strategiesโ€‹

StrategyDescriptionExample
Static fallbackReturn hardcoded default valuerecommendations: []
Stale cacheServe expired cached data when service is downLast known dashboard from 5 min ago
Graceful degradationOmit the failed section, signal it in the response{ degraded: ["recommendations"] }
Feature flag offDisable the feature if its service is downHide "You might also like" section
Retry with backoffRetry 2โ€“3 times with exponential delay before fallback100ms โ†’ 200ms โ†’ 400ms โ†’ fallback

Graceful Degradation Patternโ€‹

interface DashboardResponse {
profile: UserProfileDTO | null;
activeOrders: OrderDTO[];
unreadNotifications: number;
recommendations: ProductDTO[];
degraded: string[]; // Tells the client which sections are incomplete
stale: string[]; // Tells the client which sections are from cache
retryAfter?: number; // Suggested retry time in seconds for failed sections
}

async function getDegradedDashboard(userId: string): Promise<DashboardResponse> {
const degraded: string[] = [];
const stale: string[] = [];
const results: Record<string, any> = {};

// Define each call with its fallback strategy
const calls = [
{
key: 'profile',
execute: () => userService.getProfile(userId),
fallback: async () => {
const cached = await cache.get(`profile:${userId}`);
if (cached) {
stale.push('profile');
return cached;
}
return null;
},
},
{
key: 'orders',
execute: () => orderService.getActiveOrders(userId),
fallback: () => [],
},
{
key: 'notifications',
execute: () => notificationService.getUnreadCount(userId),
fallback: () => 0,
},
{
key: 'recommendations',
execute: () => recommendationService.getForUser(userId),
fallback: async () => {
const cached = await cache.get(`recs:${userId}`);
return cached ?? [];
},
},
];

await Promise.all(
calls.map(async ({ key, execute, fallback }) => {
try {
results[key] = await execute();
} catch (err) {
logger.warn(`[BFF] ${key} failed, applying fallback`, { error: err });
degraded.push(key);
results[key] = await fallback();
}
}),
);

return {
profile: results.profile,
activeOrders: results.orders,
unreadNotifications: results.notifications,
recommendations: results.recommendations,
degraded,
stale,
retryAfter: degraded.length > 0 ? 30 : undefined,
};
}

TypeScript Service Client Abstractionโ€‹

A clean pattern for encapsulating downstream service calls with built-in resilience:

// clients/base-service.client.ts
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import { CircuitBreaker } from '../resilience/circuit-breaker';

export abstract class BaseServiceClient {
protected readonly http: AxiosInstance;
private readonly circuitBreaker: CircuitBreaker;

constructor(baseURL: string, serviceName: string) {
this.http = axios.create({
baseURL,
timeout: 5000,
headers: { 'Content-Type': 'application/json' },
});

this.circuitBreaker = new CircuitBreaker(serviceName);

// Inject service token on every request
this.http.interceptors.request.use(async (config) => {
const token = await getInternalServiceToken();
config.headers.Authorization = `Bearer ${token}`;
return config;
});

// Normalize errors
this.http.interceptors.response.use(
(res) => res,
(err) => {
throw new DownstreamError(
err.response?.data?.code ?? 'DOWNSTREAM_ERROR',
err.response?.data?.message ?? err.message,
err.response?.status ?? 502,
);
},
);
}

protected async get<T>(path: string, config?: AxiosRequestConfig): Promise<T> {
return this.circuitBreaker.execute(path, async () => {
const { data } = await this.http.get<T>(path, config);
return data;
});
}

protected async post<T>(path: string, body: unknown, config?: AxiosRequestConfig): Promise<T> {
return this.circuitBreaker.execute(path, async () => {
const { data } = await this.http.post<T>(path, body, config);
return data;
});
}
}
// clients/user-service.client.ts
import { BaseServiceClient } from './base-service.client';
import { SERVICE_REGISTRY } from '../config/service-registry';

interface UserProfileDTO {
id: string;
displayName: string;
avatar: string;
joinedAt: string;
}

export class UserServiceClient extends BaseServiceClient {
constructor() {
super(SERVICE_REGISTRY.userService, 'user-service');
}

async getProfile(userId: string): Promise<UserProfileDTO> {
const user = await this.get<any>(`/users/${userId}`);

// Transform downstream response into BFF DTO
return {
id: user.id,
displayName: user.preferences?.displayName ?? `${user.first_name} ${user.last_name}`,
avatar: user.avatar_url ?? user.default_avatar,
joinedAt: user.created_at,
};
}

async batchGetProfiles(userIds: string[]): Promise<UserProfileDTO[]> {
const { users } = await this.post<{ users: any[] }>('/users/batch', { ids: userIds });
return users.map((user) => ({
id: user.id,
displayName: `${user.first_name} ${user.last_name}`,
avatar: user.avatar_url,
joinedAt: user.created_at,
}));
}
}

Service Discovery Integrationโ€‹

In production, BFF instances should not have hardcoded downstream URLs. They look up services dynamically:

// config/service-registry.ts
import { Consul } from 'consul';

const consul = new Consul({ host: process.env.CONSUL_HOST ?? 'localhost' });

export async function resolveService(serviceName: string): Promise<string> {
const services = await consul.catalog.service.nodes(serviceName);

if (services.length === 0) {
throw new Error(`No healthy instances found for ${serviceName}`);
}

// Simple round-robin or random pick
// In production, use a proper load-balancing strategy
const instance = services[Math.floor(Math.random() * services.length)];
return `http://${instance.ServiceAddress}:${instance.ServicePort}`;
}

// Usage in client initialization:
const userServiceUrl = await resolveService('user-service');
const userClient = new UserServiceClient(userServiceUrl);

For a detailed treatment of service discovery patterns, see Service Discovery.


Testing the BFFโ€‹

Unit Testing BFF Servicesโ€‹

// __tests__/bff-web.service.spec.ts
import { BffWebService } from '../bff-web.service';
import { UserServiceClient } from '../clients/user-service.client';
import { OrderServiceClient } from '../clients/order-service.client';

describe('BffWebService', () => {
let service: BffWebService;
let mockUserClient: jest.Mocked<UserServiceClient>;
let mockOrderClient: jest.Mocked<OrderServiceClient>;

beforeEach(() => {
mockUserClient = { getProfile: jest.fn() } as any;
mockOrderClient = { getActiveOrders: jest.fn() } as any;
service = new BffWebService(mockUserClient, mockOrderClient /* ... */);
});

it('should return partial data when order service fails', async () => {
mockUserClient.getProfile.mockResolvedValue({
id: '42',
displayName: 'Alice',
avatar: '/alice.png',
joinedAt: '2024-01-01',
});
mockOrderClient.getActiveOrders.mockRejectedValue(new Error('Connection refused'));

const result = await service.getDashboard('42');

expect(result.profile).toBeDefined();
expect(result.activeOrders).toEqual([]); // Fallback
expect(result.degraded).toContain('activeOrders'); // Signaled
});

it('should aggregate data from multiple services', async () => {
mockUserClient.getProfile.mockResolvedValue({/* ... */});
mockOrderClient.getActiveOrders.mockResolvedValue([{ id: '1' }, { id: '2' }]);

const result = await service.getDashboard('42');

expect(result.profile).toBeDefined();
expect(result.activeOrders).toHaveLength(2);
expect(result.degraded).toHaveLength(0);
});
});

Integration Testing with Wire Mockโ€‹

// __tests__/bff.integration.spec.ts
import { setupMockServer } from './helpers/wiremock';
import supertest from 'supertest';
import { createApp } from '../app';

describe('BFF Integration', () => {
let app: Express.Application;
let wiremock: ReturnType<typeof setupMockServer>;

beforeAll(async () => {
wiremock = setupMockServer();
// Stub downstream services
await wiremock.stubFor({
request: { method: 'GET', urlPath: '/users/42' },
response: { status: 200, jsonBody: { id: 42, first_name: 'Alice' /* ... */ } },
});
await wiremock.stubFor({
request: { method: 'GET', urlPath: '/orders', queryParameters: { userId: '42' } },
response: { status: 200, jsonBody: { orders: [{ id: 'ord-1' }] } },
});

app = createApp({ userServiceUrl: wiremock.url, orderServiceUrl: wiremock.url });
});

it('GET /api/dashboard returns aggregated data', async () => {
const response = await supertest(app)
.get('/api/dashboard')
.set('Authorization', 'Bearer valid-token')
.expect(200);

expect(response.body.profile.displayName).toBeDefined();
expect(response.body.activeOrders).toHaveLength(1);
expect(response.body.degraded).toHaveLength(0);
});

it('returns degraded response when a service is down', async () => {
await wiremock.stubFor({
request: { method: 'GET', urlPath: '/orders' },
response: { status: 500 },
});

const response = await supertest(app)
.get('/api/dashboard')
.set('Authorization', 'Bearer valid-token')
.expect(200); // Still 200 โ€” graceful degradation

expect(response.body.activeOrders).toEqual([]); // Fallback
expect(response.body.degraded).toContain('activeOrders');
});
});

Contract Testingโ€‹

Since the BFF depends on downstream service contracts, use contract tests to catch breaking changes:

// __tests__/contracts/user-service.contract.spec.ts
import { UserServiceClient } from '../../clients/user-service.client';

describe('UserService Contract', () => {
it('GET /users/:id response matches expected shape', async () => {
const client = new UserServiceClient(process.env.USER_SERVICE_URL!);

// Call against a real or pact-stubbed service
const profile = await client.getProfile('test-user-42');

// Validate the shape the BFF depends on
expect(profile).toMatchObject({
id: expect.any(String),
displayName: expect.any(String),
avatar: expect.any(String),
joinedAt: expect.any(String),
});
});
});

Anti-Patternsโ€‹

Anti-PatternWhy It's HarmfulWhat to Do Instead
Generic BFFOne BFF serving all clients defeats the purpose โ€” you've built a slower API GatewayCreate separate BFFs per client type; share common code via libraries
Business logic in BFFThe BFF becomes a monolith with domain logic spread across multiple BFFsKeep BFFs thin โ€” orchestration only. Business rules belong in downstream services
No fallbacksOne downstream failure returns a 500 to the client, even when partial data would be usefulEvery downstream call should have a fallback. Use allSettled and graceful degradation
Unbounded parallelismFiring 20+ downstream calls in parallel saturates thread pools and connection limitsCap parallelism; use a DAG to identify true dependencies; batch calls where possible
BFF calling BFFCreates spaghetti dependencies; debugging becomes impossibleEach BFF calls downstream services directly or through the API Gateway โ€” never another BFF
Ignoring client constraintsA Mobile BFF that returns 500 KB payloads is worse than no BFFProfile payloads on real devices over throttled networks. Set size budgets per endpoint
Leaking downstream errorsExposing internal service names, stack traces, or DB errors to clientsNormalize all errors into a client-friendly format. Never expose internal details
Over-fetching in BFFCalling a downstream endpoint that returns 80 fields when the BFF only needs 5If you control the service, add sparse fieldsets (?fields=id,name,price). If not, at least strip before forwarding to client
BFF as an ESBAdding message transformation, routing rules, and orchestration engines to the BFFBFF should be simple Node.js apps. If you need an ESB, use a dedicated integration layer
No monitoringYou can't see which downstream service is degrading the BFF experienceInstrument every downstream call: latency, error rate, circuit breaker state. Export to Prometheus/Datadog

Monitoring the BFFโ€‹

The BFF is the aggregator โ€” when it slows down, every client suffers. Instrument it thoroughly:

// middleware/bff-metrics.ts
import { Request, Response, NextFunction } from 'express';
import { Histogram, Counter, Gauge } from 'prom-client';

const downstreamLatency = new Histogram({
name: 'bff_downstream_request_duration_seconds',
help: 'Latency of downstream service calls from BFF',
labelNames: ['service', 'endpoint', 'status'],
buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10],
});

const downstreamErrors = new Counter({
name: 'bff_downstream_errors_total',
help: 'Total number of downstream errors',
labelNames: ['service', 'error'],
});

const circuitBreakerGauge = new Gauge({
name: 'bff_circuit_breaker_state',
help: 'Circuit breaker state (0=CLOSED, 1=OPEN, 2=HALF_OPEN)',
labelNames: ['service'],
});

// Wrap downstream calls with metrics
export async function instrumentedCall<T>(
service: string,
endpoint: string,
fn: () => Promise<T>,
): Promise<T> {
const start = Date.now();
try {
const result = await fn();
downstreamLatency.observe(
{ service, endpoint, status: 'success' },
(Date.now() - start) / 1000,
);
return result;
} catch (err) {
downstreamLatency.observe({ service, endpoint, status: 'error' }, (Date.now() - start) / 1000);
downstreamErrors.inc({ service, error: err.name ?? 'UnknownError' });
throw err;
}
}

Quick Reference: When to Use BFFโ€‹

ScenarioRecommendation
Single client type, simple data needsNo BFF โ€” direct REST API is sufficient
Web + Mobile with significantly different UISeparate BFFs for each
2+ downstream services needed per screenBFF to aggregate and reduce round trips
Mobile app on unreliable networksMobile BFF with minified payloads, SWR caching, and offline hints
Public API for third-party developersPartner BFF with versioning, docs, and rate limiting
IoT fleet with constrained devicesIoT BFF with binary protocol and edge deployment
Micro-frontend architectureBFF per micro-frontend (or per domain slice)

โ† Back to Backend Engineering ยท ยฉ sparshjaswal