Implementing BFF (Backend for Frontend) Pattern
BFF is a pattern where each client type (web, mobile, TV app) gets its own backend layer. Instead of one universal API trying to satisfy all clients, each BFF returns data optimized for its client.
Problem Without BFF
A mobile app makes 5 requests to display one profile screen:
-
GET /users/{id}— basic data -
GET /orders?userId={id}&limit=3— recent orders -
GET /notifications/unread— notification count -
GET /recommendations?userId={id}— recommendations -
GET /loyalty/points/{id}— loyalty points
Each request is a separate round trip. On mobile networks, this totals 500–1500ms.
Solution with BFF
Mobile BFF Web BFF
(Node.js) (Node.js)
iOS App ──────────► /mobile/dashboard │
Android App ───────► │ │
│ │
├─► User Service ◄─── Web SPA
├─► Order Service ◄──────────
├─► Notification ◄──────────
└─► Recommendation ◄──────────
Mobile BFF Implementation
// mobile-bff/routes/dashboard.ts
router.get('/mobile/dashboard', authenticate, async (req, res) => {
const userId = req.user.id;
// Parallel requests to microservices
const [userResult, ordersResult, notificationsResult, loyaltyResult] =
await Promise.allSettled([
userService.get(`/users/${userId}`),
orderService.get(`/orders?customerId=${userId}&limit=3&fields=id,status,total,createdAt`),
notificationService.get(`/notifications/${userId}/unread-count`),
loyaltyService.get(`/loyalty/${userId}/summary`)
]);
// Aggregation with partial failure handling
const response = {
user: userResult.status === 'fulfilled' ? {
id: userResult.value.data.id,
name: userResult.value.data.displayName,
avatar: userResult.value.data.avatarUrl
} : null,
recentOrders: ordersResult.status === 'fulfilled'
? ordersResult.value.data.items.map(transformOrderForMobile)
: [],
unreadCount: notificationsResult.status === 'fulfilled'
? notificationsResult.value.data.count
: 0,
loyalty: loyaltyResult.status === 'fulfilled' ? {
points: loyaltyResult.value.data.balance,
tier: loyaltyResult.value.data.tier
} : null
};
res.json(response);
});
// Transform data for mobile UI
function transformOrderForMobile(order: Order): MobileOrder {
return {
id: order.id,
status: localizeStatus(order.status), // 'Delivered' instead of 'DELIVERED'
total: formatCurrency(order.total, 'USD'),
date: formatRelativeDate(order.createdAt) // '2 days ago'
};
}
Web BFF — Different Format for Same Data
// web-bff/routes/dashboard.ts
router.get('/web/dashboard', authenticate, async (req, res) => {
const userId = req.user.id;
// Web version requests more data for rich UI
const [user, orders, stats, notifications] = await Promise.allSettled([
userService.get(`/users/${userId}`),
orderService.get(`/orders?customerId=${userId}&limit=10`),
analyticsService.get(`/analytics/user/${userId}/stats`),
notificationService.get(`/notifications/${userId}?limit=5&unread=true`)
]);
// Web format — more data, different structure
res.json({
user: user.status === 'fulfilled' ? user.value.data : null,
orders: orders.status === 'fulfilled' ? orders.value.data : { items: [], total: 0 },
analytics: stats.status === 'fulfilled' ? stats.value.data : null,
notifications: notifications.status === 'fulfilled' ? notifications.value.data : []
});
});
GraphQL BFF
If the client is a React app with Apollo Client, BFF can export GraphQL:
// web-bff/graphql/schema.ts
const typeDefs = gql`
type Query {
dashboard: Dashboard!
order(id: ID!): Order
}
type Dashboard {
user: User!
recentOrders: [Order!]!
stats: UserStats!
}
`;
const resolvers = {
Query: {
dashboard: async (_, __, { userId }) => {
const [user, orders, stats] = await Promise.all([
userService.getUser(userId),
orderService.getRecentOrders(userId),
analyticsService.getUserStats(userId)
]);
return { user, recentOrders: orders, stats };
}
}
};
Authorization and Authentication in BFF
BFF is the natural place for JWT verification and session management. Especially for browser clients:
// BFF stores refresh token in httpOnly cookie,
// not exposing it to browser JavaScript
router.post('/auth/refresh', async (req, res) => {
const refreshToken = req.cookies.refresh_token;
if (!refreshToken) return res.status(401).json({ error: 'No token' });
const tokens = await authService.refreshTokens(refreshToken);
res.cookie('refresh_token', tokens.refreshToken, {
httpOnly: true, secure: true, sameSite: 'strict',
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
});
res.json({ accessToken: tokens.accessToken });
});
Caching in BFF
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
async function getCachedOrFetch<T>(
key: string,
ttl: number,
fetcher: () => Promise<T>
): Promise<T> {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const data = await fetcher();
await redis.setex(key, ttl, JSON.stringify(data));
return data;
}
// Cache recommendations for 5 minutes
const recommendations = await getCachedOrFetch(
`recommendations:${userId}`,
300,
() => recommendationService.get(`/recommendations/${userId}`)
);
Implementation Timeline
- BFF for one client (3–5 aggregating endpoints) — 1–2 weeks
- GraphQL BFF + authorization + caching — 2–3 weeks
- BFF for 2–3 clients with shared service call library — 3–4 weeks







