Backend for Frontend — Cache Key Versioning Pitfalls
- BFF = per client, owned by frontend team. Deployed independently. No business logic — only aggregation, transformation, error normalisation.
- Promise.allSettled over Promise.all. Classify every dependency as critical or non-critical. One flaky non-critical service should never 503 the page.
- Field projection = whitelist. If a field isn't whitelisted, it never leaves the BFF. Protects bandwidth and prevents internal data leaks.
- BFF = dedicated backend per client type (mobile, web, partner). Owned by frontend team. Deployed independently.
- Does three things: aggregates downstream calls, transforms response shapes, normalises errors. No business logic.
- Promise.allSettled (not Promise.all) + classify dependencies as critical vs non-critical = one flaky service doesn't 503 the page.
- Field projection = whitelist fields per client. Mobile gets 4 fields from 40-field user service. Smaller payload, no internal data leaks.
- Versioned cache keys: 'mobile:homescreen:v2:userId'. Bump version when response shape changes. Old keys expire naturally, no flush needed.
- Production killer: unversioned cache + response shape change = stale field names = client renders 'undefined' for an hour.
BFF — 60-Second Diagnosis
Check if BFF is using Promise.allSettled for fan-out
grep -r 'Promise.all' src/routes/grep -r 'allSettled' src/routes/Check field projection coverage
grep -r 'projectFields\|fieldWhitelist' src/curl -s BFF_ENDPOINT | jq 'keys' | wc -lCheck cache key versioning
grep -r 'cacheKey\|redis.set' src/ | grep -v 'v[0-9]'redis-cli KEYS 'mobile:*' | grep -v 'v[0-9]'Check for business logic leak into BFF
grep -r 'price\|discount\|validation\|calculation' src/git log --since="3 months ago" -- src/ | grep -E 'price|discount'Production Incident
Production Debug GuideClient gets wrong data? Page partially loads? Cache serves stale fields? Here's the diagnosis map.
Every distributed system eventually hits the same wall: one set of backend microservices, but clients couldn't be more different. A mobile app on 4G cares about payload size and battery drain. A desktop web app wants rich aggregated data in one round trip. A partner integration needs a stable, versioned contract.
Trying to serve all of them from one general-purpose API Gateway is where the pain starts. Your mobile team complains about 40-field responses. Your partner team complains about breaking changes. Your web team complains about N+1 queries.
The Backend for Frontend (BFF) pattern solves this by giving each client its own dedicated backend. This article covers the three rules that make BFF work in production: fan-out with degradation, field projection as a security boundary, and versioned cache keys that don't poison your CDN.
Why a Single API Gateway Breaks Down at Scale — The Case for BFF
The naive starting point is a single API Gateway sitting in front of all your microservices. It handles auth, routing, rate limiting, and maybe a bit of response shaping. This works fine for one or two clients with similar data appetites. The cracks appear the moment you ship a mobile app.
Your mobile team starts complaining that the /user/profile endpoint returns 47 fields when they only render 6. They're paying for bandwidth on every response, parsing data they discard, and your API is throttled by the slowest downstream service even when the mobile screen only needs data from the fastest one. Meanwhile the web team adds a field, breaks the mobile contract, and you spend a week arguing about backward compatibility.
The core problem is impedance mismatch: your backend services model the domain, but your clients model the user experience. Those are genuinely different shapes. A BFF is the translation layer that converts domain model responses into UX-optimised payloads, per client. Critically, the team that owns the frontend also owns its BFF. This is the sociotechnical insight that makes BFF work — Conway's Law turned to your advantage. The mobile team controls the mobile BFF and can iterate it independently without negotiating with the web team or the core services team.
┌─────────────────────────────────────────────────────────┐ │ CLIENT LAYER │ │ ┌──────────────┐ ┌──────────────┐ ┌───────────────┐ │ │ │ iOS/Android │ │ React Web │ │ Partner API │ │ │ │ Mobile App │ │ Dashboard │ │ Consumer │ │ │ └──────┬───────┘ └──────┬───────┘ └──────┬────────┘ │ └─────────┼────────────────┼─────────────────┼────────────┘ │ │ │ ▼ ▼ ▼ ┌─────────────────────────────────────────────────────────┐ │ BFF LAYER │ │ ┌──────────────┐ ┌──────────────┐ ┌───────────────┐ │ │ │ Mobile BFF │ │ Web BFF │ │ Partner BFF │ │ │ │ (Node.js) │ │ (Node.js) │ │ (Node.js) │ │ │ │ │ │ │ │ │ │ │ │ - Compresses │ │ - Aggregates │ │ - Versioned │ │ │ │ payloads │ │ multi-svc │ │ contracts │ │ │ │ - Offline │ │ - SSE/WS │ │ - OAuth2 │ │ │ │ delta sync │ │ support │ │ scoping │ │ │ └──────┬───────┘ └──────┬───────┘ └──────┬────────┘ │ └─────────┼────────────────┼─────────────────┼───────────┘ │ │ │ └────────────────┴─────────────────┘ │ ┌────────────────▼────────────────┐ │ INTERNAL SERVICE MESH │ │ ┌───────────┐ ┌────────────┐ │ │ │ User Svc │ │ Order Svc │ │ │ └───────────┘ └────────────┘ │ │ ┌───────────┐ ┌────────────┐ │ │ │Product Svc│ │Inventory │ │ │ └───────────┘ │Svc │ │ │ └────────────┘ │ └─────────────────────────────────┘ KEY INSIGHT: Each BFF is owned by the frontend team that uses it. The internal services have no knowledge of client-specific concerns.
the same downstream microservices but exposing client-optimised interfaces.
No client talks directly to an internal service.
Building a Production-Grade Mobile BFF in Node.js — Aggregation, Auth, and Error Normalisation
A BFF has three primary jobs: aggregate calls to multiple downstream services into one client request, transform response shapes to match what the UI actually renders, and normalise errors so the client gets consistent, actionable error payloads regardless of which downstream service failed.
Authentication lives in the BFF too. The mobile client sends a JWT or session token to the BFF; the BFF validates it and then uses a machine-to-machine credential (service account, mTLS cert, or internal API key) when calling downstream services. This keeps internal service auth completely hidden from the client — a critical security boundary.
The code below is a production-representative Node.js BFF endpoint for a mobile home screen. It fans out to three services in parallel using Promise.allSettled (not Promise.all — that distinction matters enormously in production), applies field projection to reduce payload size, and returns a normalised error envelope if any dependency fails. Every decision here has a reason.
// Mobile BFF — Home Screen Aggregation Endpoint // Owned by: Mobile Platform Team // Downstream deps: User Service, Order Service, Recommendation Service import express from 'express'; import { verifyMobileJwt } from './auth/jwtValidator.js'; import { fetchUserProfile } from './clients/userServiceClient.js'; import { fetchRecentOrders } from './clients/orderServiceClient.js'; import { fetchRecommendations } from './clients/recommendationServiceClient.js'; import { projectFields } from './utils/fieldProjector.js'; import { buildErrorEnvelope } from './utils/errorNormaliser.js'; const router = express.Router(); // ───────────────────────────────────────────────────────────── // FIELD PROJECTION MAPS // These define EXACTLY what the mobile home screen renders. // If a field isn't in this map, it never leaves the BFF. // This is your first line of defence against over-fetching. // ───────────────────────────────────────────────────────────── const MOBILE_USER_FIELDS = ['userId', 'displayName', 'avatarUrl', 'loyaltyTier']; const MOBILE_ORDER_FIELDS = ['orderId', 'status', 'estimatedDelivery', 'itemCount']; const MOBILE_RECO_FIELDS = ['productId', 'thumbnailUrl', 'title', 'priceFormatted']; // ───────────────────────────────────────────────────────────── // AUTH MIDDLEWARE // Validates the mobile JWT. On success, attaches decoded payload // to req.authenticatedUser so downstream handlers don't re-verify. // The BFF then calls internal services with a SERVICE_ACCOUNT_TOKEN // — the client never sees or needs internal credentials. // ───────────────────────────────────────────────────────────── router.use(verifyMobileJwt); // ───────────────────────────────────────────────────────────── // GET /mobile/v1/home // Returns a single aggregated payload for the mobile home screen. // Designed for: < 50KB response, < 500ms p95 on 4G. // ───────────────────────────────────────────────────────────── router.get('/v1/home', async (req, res) => { const { userId } = req.authenticatedUser; // populated by verifyMobileJwt middleware const requestStartTime = Date.now(); // ── PARALLEL FAN-OUT ────────────────────────────────────── // We use Promise.allSettled instead of Promise.all. // Promise.all would FAIL ENTIRELY if recommendations are down. // Promise.allSettled lets us return partial data gracefully — // the home screen can still render without recommendations. const [userResult, ordersResult, recoResult] = await Promise.allSettled([ fetchUserProfile(userId), fetchRecentOrders(userId, { limit: 3 }), // mobile only shows 3 fetchRecommendations(userId, { limit: 6 }), // 2-column grid = 6 tiles ]); // ── CRITICAL DEPENDENCY CHECK ───────────────────────────── // User profile is non-negotiable. If it fails, the home screen // cannot render at all. Return a normalised 503 immediately. if (userResult.status === 'rejected') { const errorEnvelope = buildErrorEnvelope({ code: 'USER_PROFILE_UNAVAILABLE', message: 'Could not load your profile. Please try again.', traceId: req.traceId, // propagated from upstream via X-Trace-Id header retryable: true, }); return res.status(503).json(errorEnvelope); } // ── NON-CRITICAL DEPENDENCY DEGRADATION ────────────────── // Orders or recommendations being unavailable degrades gracefully. // We log the failure for alerting but don't blow up the response. const recentOrders = ordersResult.status === 'fulfilled' ? projectFields(ordersResult.value.orders, MOBILE_ORDER_FIELDS) : []; // empty array tells the UI to render the 'no recent orders' state const recommendations = recoResult.status === 'fulfilled' ? projectFields(recoResult.value.items, MOBILE_RECO_FIELDS) : []; // UI renders a placeholder skeleton instead of crashing // ── LOG DEGRADED DEPENDENCIES ──────────────────────────── // In production: emit a metric here (e.g. StatsD/Prometheus counter) // so your on-call team sees recommendation-service degradation // on the dashboard before users start complaining. if (ordersResult.status === 'rejected') { console.error('[MobileBFF] Order service degraded', { userId, reason: ordersResult.reason?.message, traceId: req.traceId, }); } if (recoResult.status === 'rejected') { console.error('[MobileBFF] Recommendation service degraded', { userId, reason: recoResult.reason?.message, traceId: req.traceId, }); } // ── RESPONSE PROJECTION ─────────────────────────────────── // projectFields strips every key not in the MOBILE_*_FIELDS arrays. // The user service returns ~40 fields. We expose 4. // This is not just bandwidth — it prevents accidentally leaking // internal fields like 'fraudScore' or 'internalSegmentTag'. const projectedUser = projectFields(userResult.value, MOBILE_USER_FIELDS); // ── RESPONSE ENVELOPE ───────────────────────────────────── // Single, consistent response shape. The mobile app team defined // this contract — they own the BFF so they own the contract. const responsePayload = { meta: { traceId: req.traceId, generatedAt: new Date().toISOString(), latencyMs: Date.now() - requestStartTime, degraded: recentOrders.length === 0 || recommendations.length === 0, }, user: projectedUser, recentOrders, recommendations, }; // ── CACHE HEADERS FOR CDN/MOBILE CACHE ─────────────────── // Home screen data is user-specific — never publicly cacheable. // s-maxage=0 prevents CDN caching. max-age=30 allows the mobile // client to use stale data for 30 seconds on navigation back. res.set('Cache-Control', 'private, max-age=30, s-maxage=0'); return res.status(200).json(responsePayload); }); export default router;
{
"meta": {
"traceId": "abc-123-xyz",
"generatedAt": "2024-11-15T09:32:11.204Z",
"latencyMs": 187,
"degraded": false
},
"user": {
"userId": "usr_9821",
"displayName": "Sarah K.",
"avatarUrl": "https://cdn.example.com/avatars/usr_9821.webp",
"loyaltyTier": "GOLD"
},
"recentOrders": [
{ "orderId": "ord_771", "status": "OUT_FOR_DELIVERY", "estimatedDelivery": "Today, 2–4 PM", "itemCount": 3 }
],
"recommendations": [
{ "productId": "prd_441", "thumbnailUrl": "...", "title": "Wireless Charger", "priceFormatted": "$29.99" }
]
}
// Degraded response (recommendation service down):
{
"meta": { "latencyMs": 203, "degraded": true, ... },
"user": { ... },
"recentOrders": [ ... ],
"recommendations": [] // UI renders skeleton, no crash
}
Caching Strategy Inside a BFF — Where to Cache and What Goes Wrong
Caching in a BFF is tricky because BFFs sit at the intersection of user-specific data (never publicly cacheable) and shared domain data (very cacheable). Getting this wrong in either direction causes either stale personalised data (a privacy incident waiting to happen) or completely uncacheable responses that hammer your downstream services.
The right model is layered caching with TTL tiering. Domain data that changes rarely (product catalogue, store locations, feature flags) gets cached aggressively at the BFF level — in-process for ultra-low latency reads, with Redis as the L2 for multi-instance consistency. User-specific aggregated data should not be cached in the BFF at all; instead, set accurate Cache-Control headers and let the client cache it locally, where it's scoped to that user's session.
The subtler gotcha is cache stampede on the aggregated data. If you cache the home screen response in Redis with a 60-second TTL and you have 100k mobile users, when that cache expires simultaneously you get a thundering herd that fans out across all three downstream services at once. You need either probabilistic early expiration (PER) or a per-user cache key with jittered TTLs.
And the most common production failure: unversioned cache keys. Your response shape changes (rename a field, change a type), but Redis still serves the old shape until TTL expires. Clients expecting the new field name crash. Version your cache keys. Every time.
// BFF Cache Layer — Redis-backed with stampede protection // Uses probabilistic early recompute (PER) to avoid thundering herd. import { createClient } from 'redis'; const redisClient = createClient({ url: process.env.REDIS_URL }); await redisClient.connect(); // ───────────────────────────────────────────────────────────── // PROBABILISTIC EARLY RECOMPUTE (PER) // Instead of letting every instance race to recompute an expired key, // we start recomputing early with a probability that increases as // the TTL approaches 0. Only one instance does the recompute. // Formula from the academic paper by Vattani et al. (2015): // recompute_now = current_time - (recompute_cost * beta * ln(random())) // > expiry_time // ───────────────────────────────────────────────────────────── const BETA = 1.0; // tuning parameter; 1.0 is a safe default async function getOrRecompute({ cacheKey, ttlSeconds, recomputeMs, fetchFn }) { // Fetch the raw cached value AND its remaining TTL in one pipeline const pipeline = redisClient.multi(); pipeline.get(cacheKey); pipeline.ttl(cacheKey); // returns remaining seconds, -2 if key doesn't exist const [cachedJson, remainingTtl] = await pipeline.exec(); if (cachedJson) { const cachedValue = JSON.parse(cachedJson); // ── PER EARLY RECOMPUTE CHECK ─────────────────────────── // Convert recompute cost to seconds for comparison with TTL const recomputeCostSeconds = recomputeMs / 1000; // Math.log returns a negative number for 0 < x < 1, so we negate it // This gives us a positive 'recompute window' proportional to cost const earlyRecomputeWindow = recomputeCostSeconds * BETA * -Math.log(Math.random()); const shouldRecomputeEarly = remainingTtl < earlyRecomputeWindow; if (!shouldRecomputeEarly) { // Cache hit — return immediately without touching downstream services return { data: cachedValue, fromCache: true, remainingTtl }; } // Falls through to recompute — probabilistic, so only some instances do this } // ── CACHE MISS OR EARLY RECOMPUTE ──────────────────────── console.info(`[BFFCache] Recomputing: ${cacheKey}`); const freshData = await fetchFn(); // calls the actual aggregation logic // Store with a jittered TTL to prevent synchronised mass expiration. // Without jitter: all 100k user caches expire at :00 every minute. // With jitter: expiry is spread across 45–75 seconds. const jitterSeconds = Math.floor(Math.random() * 30) - 15; // ±15s const effectiveTtl = ttlSeconds + jitterSeconds; await redisClient.set(cacheKey, JSON.stringify(freshData), { EX: effectiveTtl, // sets TTL in seconds }); return { data: freshData, fromCache: false, remainingTtl: effectiveTtl }; } // ───────────────────────────────────────────────────────────── // FIELD PROJECTOR // Strips all keys not in the allowedFields array. // Works on both single objects and arrays of objects. // This is a whitelist approach — safer than a blacklist. // ───────────────────────────────────────────────────────────── export function projectFields(input, allowedFields) { if (Array.isArray(input)) { return input.map(item => projectFields(item, allowedFields)); } // Object.fromEntries + filter = clean, readable field projection return Object.fromEntries( Object.entries(input).filter(([key]) => allowedFields.includes(key)) ); } // ───────────────────────────────────────────────────────────── // USAGE EXAMPLE — How the home screen route uses the cache layer // ───────────────────────────────────────────────────────────── export async function getCachedHomeScreenData(userId, aggregateFn) { const cacheKey = `mobile:homescreen:v2:${userId}`; // versioned key! // If you change the response shape, bump v2 → v3 to avoid stale // shape mismatches. Unversioned cache keys are a production horror. return getOrRecompute({ cacheKey, ttlSeconds: 60, // 60s base TTL, ±15s jitter applied inside recomputeMs: 250, // estimated cost of the aggregation fan-out fetchFn: () => aggregateFn(userId), }); }
[BFFCache] Recomputing: mobile:homescreen:v2:usr_9821
{ data: { ...homeScreenPayload }, fromCache: false, remainingTtl: 53 }
// Cache hit (subsequent requests within TTL window):
{ data: { ...homeScreenPayload }, fromCache: true, remainingTtl: 47 }
// PER early recompute triggered (TTL low, probability fires):
[BFFCache] Recomputing: mobile:homescreen:v2:usr_9821
// ↑ happens transparently — client still gets the old cached data
// while one instance refreshes in the background
BFF vs API Gateway vs GraphQL — When Each Pattern Actually Wins
Engineers debate these three patterns constantly, often because they're solving different problems and the differences only become clear under load or at organisational scale.
An API Gateway is infrastructure. It handles cross-cutting concerns — TLS termination, rate limiting, request routing, auth token validation. It should not know what a mobile home screen looks like. When you push field projection, aggregation, or client-specific error handling into a gateway, you've created a shared bottleneck that every team must touch to change anything client-specific.
GraphQL solves the over-fetching problem elegantly for a single client type where the client knows what it wants to ask for. But in practice, mobile clients frequently need to fan out across 4–5 resolvers in a single query, and each resolver carries N+1 query risks unless you implement DataLoader — which adds complexity. GraphQL also surfaces your schema externally, which is a versioning and security surface area problem with partner APIs.
A **BFF** wins when: (1) different clients have genuinely different data shapes and update frequencies, (2) teams need independent deployment of client-specific logic, (3) you need to hide the internal service topology from clients entirely. The BFF pattern scales organisationally — the cost is an extra service per client surface that must be deployed, monitored, and maintained.
DECISION FLOWCHART: API Gateway vs BFF vs GraphQL START │ ├─ Do ALL your clients need the same data shape? │ └─ YES → API Gateway with response caching is probably enough. │ BFF adds cost without benefit here. │ ├─ Do you have ONE flexible client (web SPA) that knows │ what fields it needs at query time? │ └─ YES → GraphQL BFF may be the right call. │ But plan for DataLoader from day one or │ you'll have N+1 queries in production within a week. │ ├─ Do you have MULTIPLE distinct client surfaces │ (mobile, web, third-party) with different teams? │ └─ YES → BFF per client surface. │ Each team owns their BFF. │ Deploy independently. Schema evolves independently. │ └─ Are you a startup with 2 engineers and 1 client? └─ YES → Monolith or single lightweight API. BFF is premature abstraction at this scale. Add it when the second client surface arrives. ───────────────────────────────────────────────────────────── ORGANISATIONAL OWNERSHIP MAPPING ───────────────────────────────────────────────────────────── API Gateway → Platform/Infra Team owns it (shared, slow to change) BFF (Mobile) → Mobile Team owns it (fast iteration, team autonomy) BFF (Web) → Web Frontend Team owns it (fast iteration, team autonomy) Core Services → Domain Teams own them (stable APIs, domain logic only) ───────────────────────────────────────────────────────────── PERFORMANCE CHARACTERISTICS UNDER LOAD ───────────────────────────────────────────────────────────── Single API Gateway (aggregation pushed into gateway): - One bottleneck for all clients - Any client's traffic pattern affects all others - Horizontal scaling scales for everyone, wastefully Dedicated BFF per client: - Mobile BFF scales independently of web traffic spikes - Web BFF can use larger instances (web pays for richer data) - Mobile BFF can use smaller, cheaper instances (smaller payloads) - Failure in web BFF doesn't affect mobile availability
Use this during system design interviews to structure your answer.
Examiners respond well to explicit trade-off analysis.
| Aspect | API Gateway | BFF (per client) | GraphQL (single BFF) |
|---|---|---|---|
| Team Ownership | Platform/Infra team (shared) | Frontend team (autonomous) | Frontend or API team |
| Deployment Frequency | Slow — shared risk surface | Fast — independent per client | Medium — schema changes require coordination |
| Over-fetching Prevention | Manual field filtering, brittle | Field projection per client | Client-driven query selection |
| Aggregation of Services | Possible but anti-pattern | Core use case | Via resolvers + DataLoader |
| N+1 Query Risk | None (routing only) | None — BFF fan-out is explicit | High if DataLoader is skipped |
| Payload Optimisation | One-size-fits-all | Per client (mobile gets ~90% smaller payloads) | Client chooses fields, variable |
| HTTP Caching Semantics | Full CDN + Cache-Control support | Full CDN + Cache-Control support | POST requests are not CDN-cacheable by default |
| Schema Versioning | API versioning via path (/v1, /v2) | Route versioning per BFF | Schema evolution with @deprecated directives |
| Fault Isolation | Gateway failure = all clients down | BFF failure = one client surface down | Gateway failure = all clients down |
| Cold Start / Infra Cost | Single service, low infra cost | N services, higher infra cost | Single service, medium cost |
| Best for | Auth, routing, rate limiting | Multiple distinct client surfaces | One flexible client with varying data needs |
🎯 Key Takeaways
- BFF = per client, owned by frontend team. Deployed independently. No business logic — only aggregation, transformation, error normalisation.
- Promise.allSettled over Promise.all. Classify every dependency as critical or non-critical. One flaky non-critical service should never 503 the page.
- Field projection = whitelist. If a field isn't whitelisted, it never leaves the BFF. Protects bandwidth and prevents internal data leaks.
- Versioned cache keys: 'service:entity:v3:userId'. Bump version when response shape changes. Unversioned keys = stale fields = client crashes.
- API Gateway = infrastructure (auth, rate limiting). GraphQL = flexible queries for one client. BFF = per-client shaping. They solve different problems.
- BFFs should never call other BFFs. Chain of BFF calls destroys independence and creates failure cascades. Call domain services directly.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QYou have a mobile app, a web dashboard, and a partner API all consuming the same microservices. How would you decide whether to use a single API Gateway with response shaping versus separate BFFs? Walk me through the trade-offs.SeniorReveal
- QIn your Mobile BFF, you're aggregating data from 5 downstream services. The recommendation service has 99.2% uptime — so it fails about 7 hours per month. How do you design the BFF so that recommendation service failures don't affect mobile home screen availability?SeniorReveal
- QA candidate says 'we could just use GraphQL and let clients ask for exactly the fields they need — why would we ever need a BFF?' How do you respond? Where does GraphQL fall short that a dedicated BFF handles better?SeniorReveal
Frequently Asked Questions
What is the Backend for Frontend (BFF) pattern in microservices?
The BFF pattern is an architectural approach where you create a dedicated backend service for each distinct client type — typically one BFF for mobile apps, one for web, and one for third-party integrations. Each BFF aggregates calls to multiple internal microservices, projects the response to exactly the fields that client needs, and normalises errors. The key differentiator from a shared API Gateway is team ownership: the frontend team owns and deploys their BFF independently.
When should I NOT use the BFF pattern?
Don't use BFF if you have a single client type, a small team (fewer than 4-5 engineers), or if your clients genuinely need the same data in the same shape. BFF adds a service to deploy, monitor, and maintain — that cost is only justified when you have multiple client surfaces with meaningfully different data needs and separate teams working on them. For early-stage products, a single lightweight API with field filtering is almost always the right call.
Can a BFF call another BFF, or does it only talk to microservices?
BFFs should never call other BFFs — that creates coupling between client surfaces and defeats the entire purpose of isolation. A BFF should only communicate with internal domain services (User Service, Order Service, etc.) and the API Gateway layer above it. If two BFFs need the same aggregated data, the correct answer is to extract that aggregation into a shared downstream service or a common library, not to chain BFF calls together.
Why chaining BFFs is dangerous: Mobile BFF calling Web BFF means Web BFF becomes a dependency for Mobile BFF's availability. Web BFF down → Mobile BFF down. Team coordination returns because changing Web BFF might break Mobile BFF. The entire point of BFF is to eliminate cross-client coupling. Chaining BFFs reintroduces it.
How do you handle authentication in a BFF architecture?
Pattern: Client authenticates with BFF. BFF validates token (JWT, session cookie, API key). BFF then uses a machine-to-machine credential (service account, mTLS certificate, internal API key) to call downstream services.
Why this boundary matters: Downstream services only trust the BFF, not the external client directly. The client never sees internal credentials. The BFF can also enforce client-specific auth policies — mobile might have tighter rate limits than web, partner might have different scopes.
Implementation: 1. BFF receives client token, validates signature/expiry 2. BFF attaches internal credentials (e.g., 'X-Service-Account: mobile-bff') to downstream requests 3. Downstream services authorise based on the BFF's identity, not the original client's
Security benefit: Compromised client token cannot directly call internal services. The BFF is a security boundary and an audit point.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.