Backend-for-Frontend (BFF)
โฑ๏ธ ~3-minute bite ยท solve the sandbox to master
5-Year-Old Metaphor
โ The physical, real-world picture. No jargon.๐ BFF = personal shopper. Instead of wandering all 7 floors yourself, your shopper fetches exactly what you need and brings it in one trip.
Without BFF โ the maze
Mobile app navigates 7 microservice floors. Each floor speaks a different dialect. The app must know the layout of every floor, assemble the final picture itself, and carry all the over-fetched data through a slow mobile connection.
Mobile โ User Service (80ms)
Mobile โ Order Service (95ms)
Mobile โ Product Service (110ms)
Total: 375ms, 48KB
With BFF โ the personal shopper
Mobile calls the BFF once. The BFF speaks all 7 service dialects. Server-side calls are sub-millisecond on the same network. BFF returns exactly the 8 fields the mobile screen needs.
Mobile โ BFF (12ms round trip)
BFF โ all services in parallel (internal)
Total: 37ms, 3.2KB
BFF vs API Gateway โ different jobs
| Concern | API Gateway | BFF |
|---|---|---|
| Purpose | Traffic routing, auth, rate limiting | Client-specific data aggregation |
| Who writes | Platform / infrastructure team | Frontend team (owns the BFF) |
| Per client | One gateway for all clients | Separate BFF per client type |
| Logic | Cross-cutting: auth, logging, throttling | Aggregation, transformation |
| Output | Proxied microservice responses | Client-optimized data shape |
Interactive Sandbox
โ Move something, see it react instantly.Pattern
Mobile app makes 7 parallel API calls to different microservices. Each service returns its full object โ client assembles the view. High latency, massive over-fetching, N+1 waterfall on dependent calls.
Request flow
Client API calls
7
Total latency
~375ms (slowest parallel chain)
Payload
48KB
Challenge
Step through all 5 BFF patterns. Compare the API call count and latency for each.
Why Should I Care?
โ The exact interview question + the bug it kills.Interview questions
Q: What is the difference between BFF and API Gateway?
An API Gateway is infrastructure: it handles cross-cutting concerns (auth, rate limiting, SSL termination, routing) for all clients uniformly. It proxies requests to microservices without understanding the client's data needs. A BFF is application-layer: it aggregates multiple service calls into one response tailored to a specific client type (mobile, web, TV). They're often combined โ the gateway handles infra, the BFF handles aggregation.
Q: When does a BFF become a bottleneck?
A BFF becomes a bottleneck when: (1) it contains business logic that should live in services โ BFFs should aggregate, not decide; (2) it becomes a single point of failure for multiple clients; (3) it doesn't parallelize service calls โ sequential calls stack latencies; (4) it accumulates technical debt from multiple team ownerships. The fix: treat the BFF as a thin translation layer, keep it stateless, and move business logic to the microservices.
Q: How does tRPC eliminate the BFF layer for full-stack apps?
| 1 | // server/routers/homeScreen.ts |
| 2 | export const homeScreenRouter = router({ |
| 3 | getPageData: publicProcedure |
| 4 | .input(z.object({ userId: z.string() })) |
| 5 | .query(async ({ input, ctx }) => { |
| 6 | const [user, orders, recos] = await Promise.all([ |
| 7 | ctx.prisma.user.findUnique({ where: { id: input.userId } }), |
| 8 | ctx.prisma.order.findMany({ where: { userId: input.userId }, take: 3 }), |
| 9 | getRecommendations(input.userId), |
| 10 | ]); |
| 11 | return { user, orders, recos }; // TypeScript infers the return type |
| 12 | }), |
| 13 | }); |
| 14 | ย |
| 15 | // client/HomeScreen.tsx โ fully typed, zero codegen |
| 16 | const { data } = trpc.homeScreen.getPageData.useQuery({ userId: '42' }); |
| 17 | // data.user.name โ autocomplete works! |
| 18 | // data.orders[0].total โ type error if field doesn't exist |
Bug: BFF containing business logic
| 1 | // โ BAD: BFF enforcing business rules |
| 2 | app.get('/checkout', async (req, res) => { |
| 3 | const cart = await cartService.get(req.userId); |
| 4 | // BFF shouldn't decide this: |
| 5 | if (cart.totalItems > 99) throw new Error('Cart too large'); |
| 6 | if (!cart.items.every(item => item.inStock)) throw new Error('Out of stock'); |
| 7 | await emailService.send(req.userId, 'checkout-started'); |
| 8 | // ... |
| 9 | }); |
| 10 | ย |
| 11 | // โ GOOD: BFF aggregates only |
| 12 | app.get('/checkout', async (req, res) => { |
| 13 | const [cart, user, shipping] = await Promise.all([ |
| 14 | cartService.get(req.userId), // validation happens in cartService |
| 15 | userService.get(req.userId), |
| 16 | shippingService.getOptions(req.userId), |
| 17 | ]); |
| 18 | res.json({ cart, user, shipping }); // shape it for the client |
| 19 | }); |
The Deep Dive
โ Spec refs, engine internals, the minutiae.Sam Newman's BFF pattern origin
Sam Newman (author of Building Microservices) coined the BFF pattern in 2015 after observing that SoundCloud's API was becoming a mess of generic endpoints trying to satisfy every client type simultaneously. The insight: instead of making services know about clients, create a client-owned aggregation layer that translates the rich service API into the exact dialect each client needs.
Caching strategies within BFF
| 1 | // BFF can cache aggregated responses |
| 2 | import { LRUCache } from 'lru-cache'; |
| 3 | ย |
| 4 | const cache = new LRUCache<string, unknown>({ |
| 5 | max: 500, |
| 6 | ttl: 1000 * 60 * 5, // 5 minutes |
| 7 | }); |
| 8 | ย |
| 9 | app.get('/home-screen', async (req, res) => { |
| 10 | const cacheKey = `home:${req.userId}`; |
| 11 | const cached = cache.get(cacheKey); |
| 12 | if (cached) return res.json(cached); |
| 13 | ย |
| 14 | const data = await aggregateHomeScreenData(req.userId); |
| 15 | cache.set(cacheKey, data); |
| 16 | res.json(data); |
| 17 | }); |
| 18 | ย |
| 19 | // Cache-Aside at BFF layer: |
| 20 | // User-specific: short TTL (30s) |
| 21 | // Shared/catalog data: long TTL (5min) + CDN |
Edge BFF with Cloudflare Workers
| 1 | // cloudflare-worker.ts โ runs in 250+ cities globally |
| 2 | export default { |
| 3 | async fetch(request: Request, env: Env): Promise<Response> { |
| 4 | const url = new URL(request.url); |
| 5 | const userId = url.searchParams.get('userId')!; |
| 6 | ย |
| 7 | // Parallel origin fetches from the edge PoP |
| 8 | const [user, orders] = await Promise.all([ |
| 9 | fetch(`${env.USER_SERVICE_URL}/users/${userId}`).then(r => r.json()), |
| 10 | fetch(`${env.ORDER_SERVICE_URL}/orders?user=${userId}&limit=3`).then(r => r.json()), |
| 11 | ]); |
| 12 | ย |
| 13 | return new Response(JSON.stringify({ user, orders }), { |
| 14 | headers: { |
| 15 | 'Content-Type': 'application/json', |
| 16 | 'Cache-Control': 'private, max-age=30', |
| 17 | }, |
| 18 | }); |
| 19 | }, |
| 20 | }; |
When to use each BFF approach
REST BFF
Multiple client types with very different data needs. Simple aggregation. Any language stack.
GraphQL BFF
Frontend teams want schema control. Complex data graph. Existing GraphQL investment.
tRPC BFF
Full-stack TypeScript. Next.js app. Team owns both client and server. No 3rd-party clients.
Edge BFF
Globally distributed users. Latency-sensitive UI. Light computation. Cloudflare/Vercel stack.
Interview Questions
โ Real questions from real interviews โ with answers.Parallelize all upstream calls in the BFF; minimize serial dependencies; cache stable data.
Gateway: cross-cutting infra (auth, rate limiting, routing). BFF: client-specific aggregation. Both are complementary.
Skip BFF when clients have identical data needs, or when the team is too small to own another service.
BFF validates the client token and issues service-to-service tokens for internal calls.
tRPC makes the server function directly callable from the client with end-to-end TypeScript types.
Memory Game
โ Quick quiz โ lock the concept in long-term memory.What does `LRUCache` in a BFF caching layer stand for, and what eviction strategy does it use?