API Composition and BFF (Backend for Frontend): Orchestrating Microservices for Better UX
Frontend applications calling multiple microservices directly face over-fetching, under-fetching, and fragmented data. BFF patterns centralize API composition, reduce client complexity, and enable platform-specific optimizations.
Executive summary
Frontend applications calling multiple microservices directly face over-fetching, under-fetching, and fragmented data. BFF patterns centralize API composition, reduce client complexity, and enable platform-specific optimizations.
Last updated: 3/15/2026
Executive summary
The naive microservices approach—frontend calling multiple backend services directly—creates significant problems: over-fetching and under-fetching of data, N+1 query problems across service boundaries, fragmented error handling, and duplicate logic across frontend platforms.
Backend for Frontend (BFF) patterns solve these problems by introducing a composition layer that aggregates data from multiple backend services, normalizes responses, and provides platform-specific APIs for web, mobile, and other clients.
In 2026, API composition has evolved from simple REST aggregation to sophisticated GraphQL federation, edge computing, and intelligent caching strategies. Teams that implement BFF patterns reduce frontend complexity, improve user experience, and enable independent evolution of frontend and backend services.
The problem with direct frontend-to-microservice communication
Anti-pattern: Frontend calling multiple services
Frontend Application
↓
[User Service] → GET /users/123
↓
[Orders Service] → GET /users/123/orders
↓
[Payments Service] → GET /orders/456/payments
↓
[Catalog Service] → GET /products/789Problems:
- Over-fetching: Frontend receives more data than needed
- Under-fetching: Frontend must make multiple calls for complete data
- N+1 queries: Fetching related data across service boundaries
- Fragmented error handling: Each service has different error format
- Platform-specific needs: Web and mobile need different data shapes
- Caching complexity: Each service must be cached independently
The BFF solution
Frontend Applications
↓
[Web BFF] ────────┐
↓ │
[Mobile BFF] ──────┼──→ [User Service]
↓ │ [Orders Service]
[Desktop BFF] ─────┤ [Payments Service]
↓ │ [Catalog Service]
[Caching Layer]Benefits:
- Single request from frontend
- Aggregated data from multiple services
- Platform-specific optimization
- Centralized error handling
- Unified caching strategy
BFF architecture patterns
Pattern 1: REST-based composition
Aggregate data from multiple REST services:
typescript// BFF service for user dashboard
interface DashboardData {
user: User;
recentOrders: Order[];
activeSubscription: Subscription | null;
recommendations: Product[];
notifications: Notification[];
}
class DashboardBFF {
constructor(
private userService: UserService,
private orderService: OrderService,
private subscriptionService: SubscriptionService,
private catalogService: CatalogService,
private notificationService: NotificationService
) {}
async getDashboard(userId: string, platform: 'web' | 'mobile' | 'desktop'): Promise<DashboardData> {
// Parallel calls to backend services
const [user, orders, subscription, notifications] = await Promise.allSettled([
this.userService.getUser(userId),
this.orderService.getRecentOrders(userId, 5),
this.subscriptionService.getActiveSubscription(userId),
this.notificationService.getNotifications(userId)
]);
// Handle partial failures gracefully
const dashboard: DashboardData = {
user: this.resolveOrDefault(user, { id: userId, name: 'Unknown' }),
recentOrders: this.resolveOrDefault(orders, []),
activeSubscription: this.resolveOrDefault(subscription, null),
notifications: this.resolveOrDefault(notifications, [])
};
// Platform-specific data fetching
if (platform === 'web') {
dashboard.recommendations = await this.catalogService.getRecommendations(userId, 20);
} else if (platform === 'mobile') {
dashboard.recommendations = await this.catalogService.getRecommendations(userId, 10);
}
return dashboard;
}
private resolveOrDefault<T>(
result: PromiseSettledResult<T>,
defaultValue: T
): T {
if (result.status === 'fulfilled') {
return result.value;
}
// Log the error but don't fail the entire request
console.error(`BFF aggregation failed: ${result.reason}`);
return defaultValue;
}
}
// Express route
app.get('/api/dashboard', async (req: Request, res: Response) => {
const userId = req.user.id;
const platform = req.headers['x-platform'] || 'web';
const dashboard = await dashboardBFF.getDashboard(userId, platform);
res.json(dashboard);
});Pattern 2: GraphQL-based composition
Use GraphQL to provide flexible data composition:
typescriptimport { ApolloServer, gql } from 'apollo-server-express';
const typeDefs = gql`
type User {
id: ID!
name: String!
email: String!
subscription: Subscription
}
type Order {
id: ID!
createdAt: DateTime!
total: Float!
status: OrderStatus!
items: [OrderItem!]!
}
type Product {
id: ID!
name: String!
price: Float!
}
type Subscription {
id: ID!
plan: String!
status: String!
renewsAt: DateTime!
}
type Dashboard {
user: User!
recentOrders: [Order!]!
activeSubscription: Subscription
}
type Query {
dashboard(userId: ID!, platform: String!): Dashboard!
}
`;
const resolvers = {
Query: {
async dashboard(_: any, { userId, platform }: any, context: any) {
// Parallel data fetching
const [user, orders, subscription] = await Promise.all([
context.dataSources.userService.getUser(userId),
context.dataSources.orderService.getRecentOrders(userId, 5),
context.dataSources.subscriptionService.getActiveSubscription(userId)
]);
return { user, recentOrders: orders, activeSubscription: subscription };
}
},
User: {
subscription: (user: User) => {
return user.subscriptionId
? context.dataSources.subscriptionService.getSubscription(user.subscriptionId)
: null;
}
},
Order: {
items: (order: Order) => {
return context.dataSources.orderService.getOrderItems(order.id);
}
}
};
const server = new ApolloServer({
typeDefs,
resolvers,
dataSources: {
userService: new UserService(),
orderService: new OrderService(),
subscriptionService: new SubscriptionService()
}
});Pattern 3: Gateway-level composition
Implement composition at the API Gateway level:
yaml# Kong API Gateway configuration
services:
- name: dashboard-service
url: http://dashboard-bff:8080
routes:
- name: dashboard-route
paths:
- /api/dashboard
plugins:
- name: request-transformer
config:
add:
headers:
- x-platform:$(http_x_platform)
- name: rate-limiting
config:
minute: 100
policy: local
- name: prometheus
config:
per_consumer: true
- name: user-service
url: http://user-service:8080
routes:
- name: user-route
paths:
- /api/users
- name: order-service
url: http://order-service:8080
routes:
- name: order-route
paths:
- /api/ordersPlatform-specific optimization
Web BFF
Optimize for web browsers with larger payloads and complex UI:
typescriptclass WebDashboardBFF {
async getDashboard(userId: string): Promise<WebDashboardData> {
const [user, orders, subscription, notifications] = await Promise.allSettled([
this.userService.getUser(userId),
this.orderService.getRecentOrders(userId, 10), // More orders for web
this.subscriptionService.getActiveSubscription(userId),
this.notificationService.getNotifications(userId, 20) // More notifications
]);
return {
user: this.resolveOrDefault(user),
recentOrders: this.resolveOrDefault(orders),
activeSubscription: this.resolveOrDefault(subscription),
notifications: this.resolveOrDefault(notifications),
recommendations: await this.catalogService.getRecommendations(userId, 30), // More recommendations
analytics: await this.analyticsService.getUserAnalytics(userId), // Web-only analytics
featureFlags: await this.featureFlagService.getUserFlags(userId)
};
}
}Mobile BFF
Optimize for mobile with smaller payloads and limited data:
typescriptclass MobileDashboardBFF {
async getDashboard(userId: string): Promise<MobileDashboardData> {
const [user, orders, subscription] = await Promise.allSettled([
this.userService.getUser(userId),
this.orderService.getRecentOrders(userId, 3), // Fewer orders for mobile
this.subscriptionService.getActiveSubscription(userId)
]);
return {
user: this.resolveOrDefault(user),
recentOrders: this.resolveOrDefault(orders),
activeSubscription: this.resolveOrDefault(subscription),
quickActions: this.getQuickActionsForMobile(), // Mobile-specific actions
offlineCache: await this.getOfflineCacheData(userId), // Offline-first data
notificationsCount: await this.notificationService.getUnreadCount(userId) // Just count
};
}
private getQuickActionsForMobile(): QuickAction[] {
return [
{ type: 'scan_qr_code', label: 'Scan QR Code' },
{ type: 'view_wallet', label: 'View Wallet' },
{ type: 'support_chat', label: 'Support' }
];
}
private async getOfflineCacheData(userId: string): Promise<OfflineCache> {
return {
userProfile: await this.userService.getUser(userId),
recentOrders: await this.orderService.getRecentOrders(userId, 3),
// Data specifically optimized for offline access
};
}
}Caching strategies for BFF
Strategy 1: Response caching
Cache aggregated responses at the BFF level:
typescriptclass CachedDashboardBFF {
private cache = new Map<string, CacheEntry>();
private readonly CACHE_TTL = 300000; // 5 minutes
async getDashboard(userId: string, platform: string): Promise<DashboardData> {
const cacheKey = `dashboard:${userId}:${platform}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() < cached.expiresAt) {
return cached.data;
}
// Fetch fresh data
const data = await this.fetchDashboard(userId, platform);
// Cache the response
this.cache.set(cacheKey, {
data,
expiresAt: Date.now() + this.CACHE_TTL
});
return data;
}
private async fetchDashboard(userId: string, platform: string): Promise<DashboardData> {
// Implementation as shown in previous examples
}
}Strategy 2: Partial caching with cache keys
Cache individual service responses and compose on-demand:
typescriptclass PartiallyCachedDashboardBFF {
private cache = new Map<string, CacheEntry>();
async getDashboard(userId: string, platform: string): Promise<DashboardData> {
// Cache keys for individual data
const userCacheKey = `user:${userId}`;
const ordersCacheKey = `orders:${userId}`;
const subscriptionCacheKey = `subscription:${userId}`;
// Check cache for each data point
const [user, orders, subscription] = await Promise.all([
this.getCached(userCacheKey, () => this.userService.getUser(userId)),
this.getCached(ordersCacheKey, () => this.orderService.getRecentOrders(userId, 5)),
this.getCached(subscriptionCacheKey, () => this.subscriptionService.getActiveSubscription(userId))
]);
return {
user,
recentOrders: orders,
activeSubscription: subscription,
// Platform-specific data not cached
recommendations: await this.catalogService.getRecommendations(userId, platform === 'web' ? 20 : 10)
};
}
private async getCached<T>(
cacheKey: string,
fetchFn: () => Promise<T>
): Promise<T> {
const cached = this.cache.get(cacheKey);
if (cached && Date.now() < cached.expiresAt) {
return cached.data;
}
const data = await fetchFn();
this.cache.set(cacheKey, {
data,
expiresAt: Date.now() + this.getTTL(cacheKey)
});
return data;
}
private getTTL(cacheKey: string): number {
// Different TTL for different data types
if (cacheKey.startsWith('user:')) {
return 300000; // 5 minutes
} else if (cacheKey.startsWith('orders:')) {
return 60000; // 1 minute
} else if (cacheKey.startsWith('subscription:')) {
return 3600000; // 1 hour
}
return 60000; // Default 1 minute
}
}Error handling and resilience
Pattern: Graceful degradation
typescriptclass ResilientDashboardBFF {
async getDashboard(userId: string, platform: string): Promise<DashboardData> {
const results = await Promise.allSettled([
this.userService.getUser(userId),
this.orderService.getRecentOrders(userId, 5),
this.subscriptionService.getActiveSubscription(userId),
this.catalogService.getRecommendations(userId, 10),
this.notificationService.getNotifications(userId, 5)
]);
const dashboard: Partial<DashboardData> = {};
// Handle each service result independently
if (results[0].status === 'fulfilled') {
dashboard.user = results[0].value;
} else {
console.error('User service failed:', results[0].reason);
dashboard.user = this.getDefaultUser();
}
if (results[1].status === 'fulfilled') {
dashboard.recentOrders = results[1].value;
} else {
console.error('Order service failed:', results[1].reason);
dashboard.recentOrders = [];
}
if (results[2].status === 'fulfilled') {
dashboard.activeSubscription = results[2].value;
} else {
console.error('Subscription service failed:', results[2].reason);
dashboard.activeSubscription = null;
}
// Optional features can fail silently
if (results[3].status === 'fulfilled') {
dashboard.recommendations = results[3].value;
} else {
console.warn('Recommendations service unavailable');
dashboard.recommendations = [];
}
return dashboard as DashboardData;
}
private getDefaultUser(): User {
return {
id: '',
name: 'Guest',
email: 'guest@example.com',
subscriptionId: null
};
}
}Implementation best practices
1. Version BFF APIs independently
/api/v1/dashboard → Web BFF v1
/api/v2/dashboard → Web BFF v2
/api/v1/mobile/dashboard → Mobile BFF v12. Use platform detection
typescriptfunction detectPlatform(request: Request): Platform {
const userAgent = request.headers['user-agent'] || '';
if (userAgent.includes('Android') || userAgent.includes('iPhone')) {
return 'mobile';
}
if (userAgent.includes('Electron') || userAgent.includes('Desktop')) {
return 'desktop';
}
return 'web';
}3. Implement proper error responses
typescriptinterface BFFErrorResponse {
error: {
code: string;
message: string;
details?: any;
requestId: string;
timestamp: string;
};
}
function createErrorResponse(
code: string,
message: string,
details?: any
): BFFErrorResponse {
return {
error: {
code,
message,
details,
requestId: generateRequestId(),
timestamp: new Date().toISOString()
}
};
}
// Usage
app.get('/api/dashboard', async (req: Request, res: Response) => {
try {
const userId = req.user.id;
const platform = detectPlatform(req);
const dashboard = await dashboardBFF.getDashboard(userId, platform);
res.json(dashboard);
} catch (error) {
if (error instanceof ServiceUnavailableError) {
res.status(503).json(createErrorResponse(
'SERVICE_UNAVAILABLE',
'Some services are temporarily unavailable',
{ available: error.availableServices }
));
} else {
res.status(500).json(createErrorResponse(
'INTERNAL_ERROR',
'An unexpected error occurred'
));
}
}
});4. Monitor and measure BFF performance
typescriptclass BFFMetrics {
async trackRequest(
operation: string,
platform: string,
duration: number,
success: boolean,
serviceLatencies: Record<string, number>
): Promise<void> {
await metrics.track({
operation,
platform,
duration,
success,
timestamp: new Date(),
serviceLatencies
});
// Alert on slow aggregations
if (duration > 2000) { // 2 seconds
await alerting.notify({
message: 'Slow BFF request',
details: { operation, platform, duration }
});
}
}
}Conclusion
API composition and BFF patterns have become essential for microservices architectures in 2026. By introducing a composition layer between frontend and backend services, teams can reduce client complexity, optimize user experience for each platform, and enable independent evolution of services.
The mature organization doesn't choose between REST, GraphQL, or platform-specific APIs—they implement BFF layers that can combine multiple approaches based on the specific needs of each frontend platform. BFF is not just about aggregation—it's about providing the right data, in the right format, for the right platform.
Need to implement BFF patterns or optimize your API architecture for better UX? Talk to Imperialis about API composition architecture, BFF implementation, and frontend-backend integration patterns.
Sources
- BFF Pattern - SoundCloud Engineering — Original BFF concept
- GraphQL Federation - Apollo Documentation — GraphQL federation
- API Gateway Patterns - Microsoft Architecture — Gateway patterns
- BFF with GraphQL - Netflix Tech Blog — BFF implementation examples
- API Design Guide - RESTful API Design — REST best practices