Cloud and platform

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.

3/15/20269 min readCloud
API Composition and BFF (Backend for Frontend): Orchestrating Microservices for Better UX

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/789

Problems:

  1. Over-fetching: Frontend receives more data than needed
  2. Under-fetching: Frontend must make multiple calls for complete data
  3. N+1 queries: Fetching related data across service boundaries
  4. Fragmented error handling: Each service has different error format
  5. Platform-specific needs: Web and mobile need different data shapes
  6. 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/orders

Platform-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 v1

2. 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

Related reading