Siddhant Deval
Siddhant DevalAuthor
Senior Full-Stack Engineer·Oct 6, 2026·18 min read

BFF Architecture: Client Ownership, Concurrent Aggregation & Edge Runtimes

A Backend-for-Frontend is not a proxy — it is a client-contract-shaped integration layer that translates between what downstream microservices produce and what the client UI needs to render. This article implements a production-grade BFF with Fastify and Hono on the edge, with Promise.allSettled fan-out and circuit-breaker resilience patterns.

BFF Architecture: Client Ownership, Concurrent Aggregation & Edge Runtimes

Architecture is not about drawing boxes on a whiteboard — it is about enforcing boundary contracts, deterministic caching, and secure data mediation across independent release units. Nowhere is the mediation problem more acute than at the boundary between a browser-based client and a landscape of heterogeneous microservices. The client needs a slim, UI-shaped payload. The microservices produce normalized, domain-shaped data. Between them, someone must translate.
Most teams let the client do this translation: the browser makes five parallel API calls, stitches the responses together, handles the partial failures, and converts gRPC Protobuf responses into JSON. The result is fat clients with complex error-handling logic, five-to-ten round-trip network waterfalls visible in every Lighthouse trace, and frontend teams blocked on microservice API design decisions made for backend consumers.
The Backend-for-Frontend removes this complexity from the client entirely. But a BFF that is designed poorly becomes a centralized bottleneck, a responsibility vacuum, and eventually a backend monolith with a misleading name. This article builds it correctly.

1. The API Gateway vs. BFF Paradigm

1.1 Why a General-Purpose API Gateway Fails Heterogeneous Clients

An API gateway sits in front of all microservices and routes requests. Enterprise gateways like Kong, AWS API Gateway, and NGINX handle authentication, rate limiting, SSL termination, and routing. They are excellent at what they do.
They are the wrong tool for client-tailored data aggregation.
Client: "I need the product name, the first image URL, the current stock level,
         and whether this product is in the user's wishlist — as a single request."

API Gateway: "I can route you to Product Service, Image Service, Inventory Service,
              and Wishlist Service. Make four requests."
The gateway does not aggregate. It routes. The client either makes four requests (network waterfall) or the team builds custom aggregation logic into the gateway (it is now a BFF in disguise, but owned by a platform team that does not understand the client's requirements).

1.2 The 1-Experience-to-1-BFF Rule

Desktop Web BFF    → serves apps/web (desktop browser, rich interactions)
Mobile BFF         → serves apps/mobile (React Native, battery-constrained, offline)
Partner API BFF    → serves third-party integrators (stable versioned API, OpenAPI spec)
Each client has fundamentally different payload requirements. A mobile client on a 4G connection cannot afford the same response size as a desktop client on broadband. A partner integrator needs backward-compatible versioned responses that a first-party client does not need.
A BFF that serves multiple clients is not a BFF — it is an API gateway with extra steps.

1.3 What a BFF Is Allowed to Do

AllowedForbidden
Fan-out aggregation (call multiple services, compose one response)Business rule computation (pricing algorithms, discount logic)
Payload slimming (remove fields the client doesn't need)Direct database writes (bypasses domain service ownership)
Protocol translation (gRPC → JSON, Protobuf → REST)Cross-cutting state persistence (sessions, feature flags, A/B state)
Response shape transformation (rename fields for client conventions)Business state mutations that should be owned by a domain service
Downstream error handling and fallback payloadsAnything that requires a domain expert to understand
Crucial Requirement
The moment business logic enters a BFF, the BFF has violated its contract. Business rules belong in domain services. The BFF is an integration layer — it knows about clients, not about domains. A BFF that knows about pricing is a checkout backend. A BFF that knows about inventory is an inventory backend. Keep the boundary sharp.

2. Implementing a Production BFF with Fastify

2.1 Why Fastify

CriterionFastifyExpressHono (edge)
JSON serializationFast-json-stringify (2-6x faster)JSON.stringify (slow)Standard (streaming)
Request validationNative schema validation (TypeBox/JSON Schema)Manual or third-partyNative Zod/validator
TypeScript supportFirst-class, typed pluginsRequires types packageFirst-class
Plugin encapsulationScoped plugin system (no global state)Middleware pollutionMiddleware-based
Request throughput~90k req/s~60k req/s~150k req/s (edge)
Deployment targetNode.js (EC2, ECS, Cloud Run)Node.jsCloudflare Workers, Vercel Edge

2.2 BFF Server Setup with TypeBox Validation

typescript
// src/server.ts
import Fastify from 'fastify'
import { Type, type Static } from '@sinclair/typebox'
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'

const app = Fastify({
  logger: {
    transport: {
      target: 'pino-pretty',
      options: { colorize: true }
    }
  }
}).withTypeProvider<TypeBoxTypeProvider>()

// Register plugins
await app.register(import('./plugins/downstream-clients.js'))
await app.register(import('./routes/product-page.js'))
await app.register(import('./routes/health.js'))

export default app
typescript
// src/plugins/downstream-clients.ts
import fp from 'fastify-plugin'

export default fp(async function downstreamClients(app) {
  // Typed fetch wrappers for each downstream service
  app.decorate('services', {
    product: createServiceClient('https://product-service.internal'),
    inventory: createServiceClient('https://inventory-service.internal'),
    recommendations: createServiceClient('https://rec-service.internal'),
    wishlist: createServiceClient('https://wishlist-service.internal')
  })
})

function createServiceClient(baseUrl: string) {
  return {
    async get<T>(path: string, options?: RequestInit): Promise<T> {
      const res = await fetch(`${baseUrl}${path}`, {
        ...options,
        signal: AbortSignal.timeout(3000)  // 3-second per-service timeout
      })
      if (!res.ok) throw new Error(`${res.status} from ${baseUrl}${path}`)
      return res.json() as Promise<T>
    }
  }
}

2.3 Product Page Aggregation Route

typescript
// src/routes/product-page.ts
import { Type, type Static } from '@sinclair/typebox'
import fp from 'fastify-plugin'

// Request schema
const ProductPageParams = Type.Object({
  productId: Type.String({ minLength: 1, maxLength: 64 })
})

const ProductPageQuery = Type.Object({
  userId: Type.Optional(Type.String())
})

// Response schema — only the fields the client UI actually needs
const ProductPageResponse = Type.Object({
  product: Type.Object({
    id: Type.String(),
    name: Type.String(),
    description: Type.String(),
    price: Type.Number(),
    imageUrl: Type.String()
  }),
  stock: Type.Object({
    available: Type.Boolean(),
    quantity: Type.Integer()
  }),
  recommendations: Type.Array(Type.Object({
    id: Type.String(),
    name: Type.String(),
    price: Type.Number(),
    imageUrl: Type.String()
  })),
  isWishlisted: Type.Boolean()
})

export type ProductPageResponse = Static<typeof ProductPageResponse>

export default fp(async function productPageRoutes(app) {
  app.get<{
    Params: Static<typeof ProductPageParams>
    Querystring: Static<typeof ProductPageQuery>
  }>(
    '/api/product/:productId',
    {
      schema: {
        params: ProductPageParams,
        querystring: ProductPageQuery,
        response: { 200: ProductPageResponse }
      }
    },
    async (request, reply) => {
      const { productId } = request.params
      const { userId } = request.query

      // Fan-out: call all services concurrently
      const [productResult, stockResult, recsResult, wishlistResult] =
        await Promise.allSettled([
          app.services.product.get<RawProduct>(`/products/${productId}`),
          app.services.inventory.get<RawStock>(`/stock/${productId}`),
          app.services.recommendations.get<RawRec[]>(`/recommendations?productId=${productId}&limit=4`),
          userId
            ? app.services.wishlist.get<RawWishlist>(`/wishlist/${userId}?productId=${productId}`)
            : Promise.resolve({ contains: false })
        ])

      // Critical service failures → 503
      if (productResult.status === 'rejected') {
        request.log.error({ err: productResult.reason }, 'Product service unavailable')
        return reply.status(503).send({ error: 'Product information unavailable' })
      }

      // Non-critical service failures → graceful degradation
      const stock = stockResult.status === 'fulfilled'
        ? stockResult.value
        : { available: true, quantity: 0 }    // Assume in-stock on inventory failure

      const recommendations = recsResult.status === 'fulfilled'
        ? recsResult.value.slice(0, 4)
        : []                                    // Empty recommendations on failure

      const isWishlisted = wishlistResult.status === 'fulfilled'
        ? wishlistResult.value.contains
        : false                                 // Assume not wishlisted on failure

      if (stockResult.status === 'rejected') {
        request.log.warn({ productId }, 'Inventory service degraded — serving default stock response')
      }

      const product = productResult.value
      
      return reply.send({
        product: {
          id: product.id,
          name: product.name,
          description: product.description,
          price: product.pricing.basePrice,   // Slim: flatten nested pricing object
          imageUrl: product.images[0]?.url ?? ''
        },
        stock,
        recommendations: recommendations.map(r => ({
          id: r.productId,
          name: r.title,
          price: r.pricing.basePrice,
          imageUrl: r.images[0]?.url ?? ''
        })),
        isWishlisted
      })
    }
  )
})
The key design decisions in this implementation:
  1. Promise.allSettled over Promise.all: Every service call runs concurrently. A failure in the recommendations service does not abort the product or inventory calls.
  2. Criticality tiers: The product service is critical — its failure returns 503. Inventory, recommendations, and wishlist are non-critical — their failures return safe defaults.
  3. Payload slimming: The BFF maps product.pricing.basePrice to price and product.images[0]?.url to imageUrl. The client receives a flat, UI-shaped object, not a domain-normalized nested structure.
  4. Per-service timeout: AbortSignal.timeout(3000) ensures a hanging inventory service never holds the request past 3 seconds.

3. Concurrent Aggregation & Failure Isolation

3.1 The Promise.all Trap

typescript
// ❌ Using Promise.all — one failure aborts everything
const [product, stock, recommendations, wishlist] = await Promise.all([
  app.services.product.get(`/products/${productId}`),
  app.services.inventory.get(`/stock/${productId}`),
  app.services.recommendations.get(`/recommendations?productId=${productId}`),
  app.services.wishlist.get(`/wishlist/${userId}`)
])
// If recommendations service is down, this throws.
// User sees a 500 error for a product page that COULD have rendered.
typescript
// ✅ Using Promise.allSettled — each failure is handled independently
const results = await Promise.allSettled([...])
// Classify each result: fulfilled → use value, rejected → use safe default

3.2 Circuit Breaking with opossum

Timeout budgets protect against slow services. Circuit breakers protect against services that are failing consistently — preventing the BFF from wasting thread time on requests that will fail anyway.
typescript
// src/plugins/circuit-breakers.ts
import CircuitBreaker from 'opossum'
import fp from 'fastify-plugin'

export default fp(async function circuitBreakers(app) {
  const options = {
    timeout: 3000,          // Mark call as failure if it exceeds 3s
    errorThresholdPercentage: 50,  // Open circuit when 50% of requests fail
    resetTimeout: 30000     // After 30s, attempt to close circuit (half-open state)
  }

  const recommendationsBreaker = new CircuitBreaker(
    (productId: string) => 
      app.services.recommendations.get<RawRec[]>(`/recommendations?productId=${productId}`),
    options
  )

  recommendationsBreaker.on('open', () => {
    app.log.warn('Recommendations circuit breaker OPEN — all calls will fast-fail')
  })
  recommendationsBreaker.on('halfOpen', () => {
    app.log.info('Recommendations circuit breaker HALF-OPEN — testing recovery')
  })
  recommendationsBreaker.on('close', () => {
    app.log.info('Recommendations circuit breaker CLOSED — service recovered')
  })

  app.decorate('breakers', { recommendations: recommendationsBreaker })
})
When the recommendations service enters a failure cascade, the circuit breaker opens after the threshold is hit. Subsequent calls fast-fail immediately (no network wait) and return the safe default. After the reset timeout, the breaker enters half-open state and tests one request. If it succeeds, the circuit closes.
Architectural Note
The health states of a circuit breaker are: Closed (normal operation), Open (fast-failing all calls), Half-Open (testing recovery with one call). A breaker in Open state eliminates the accumulation of timed-out requests that would otherwise exhaust Node.js's event loop queue in a cascade failure.

4. Ultra-Low-Latency Edge BFF with Hono

4.1 When to Choose Hono Over Fastify

RequirementFastify (Node.js)Hono (Edge)
Cold start~200–500ms<5ms
Global distributionRequires multi-region deploymentAutomatic (CDN edge nodes)
Node.js APIs available✅ Full access❌ Web Standards only
CPU-intensive work✅ Worker threads available❌ Time-limited execution
Redis / database access✅ Direct TCP connections⚠️ Via HTTP APIs only (no direct TCP)
Streaming responses✅ Full support✅ Native Web Streams
Hono is the right choice when:
  • The BFF handles simple aggregation with no direct TCP database connections.
  • Global latency (<5ms response initiation) is a product requirement.
  • The team is comfortable with Web Standards APIs (Request, Response, Headers, URL).

4.2 Product Page BFF in Hono (Cloudflare Workers)

typescript
// src/index.ts — Cloudflare Worker + Hono
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'

const app = new Hono<{ Bindings: CloudflareBindings }>()

const ProductParamsSchema = z.object({
  productId: z.string().min(1).max(64)
})

app.get(
  '/api/product/:productId',
  zValidator('param', ProductParamsSchema),
  async (c) => {
    const { productId } = c.req.valid('param')
    const userId = c.req.query('userId')

    // Edge-compatible concurrent fetch with Web Standards AbortSignal
    const controller = new AbortController()
    const timeout = setTimeout(() => controller.abort(), 5000)

    const [productRes, stockRes, recsRes] = await Promise.allSettled([
      fetch(`${c.env.PRODUCT_SERVICE_URL}/products/${productId}`, {
        signal: controller.signal
      }),
      fetch(`${c.env.INVENTORY_SERVICE_URL}/stock/${productId}`, {
        signal: controller.signal
      }),
      fetch(`${c.env.REC_SERVICE_URL}/recommendations?productId=${productId}`, {
        signal: controller.signal
      })
    ])

    clearTimeout(timeout)

    if (productRes.status === 'rejected' || !productRes.value.ok) {
      return c.json({ error: 'Product information unavailable' }, 503)
    }

    const product = await productRes.value.json<RawProduct>()

    const stock = stockRes.status === 'fulfilled' && stockRes.value.ok
      ? await stockRes.value.json<RawStock>()
      : { available: true, quantity: 0 }

    const recs = recsRes.status === 'fulfilled' && recsRes.value.ok
      ? (await recsRes.value.json<RawRec[]>()).slice(0, 4)
      : []

    return c.json({
      product: {
        id: product.id,
        name: product.name,
        description: product.description,
        price: product.pricing.basePrice,
        imageUrl: product.images[0]?.url ?? ''
      },
      stock,
      recommendations: recs.map(r => ({
        id: r.productId,
        name: r.title,
        price: r.pricing.basePrice,
        imageUrl: r.images[0]?.url ?? ''
      }))
    })
  }
)

export default app
Performance / Safety Warning
Hono on Cloudflare Workers has no access to Node.js-specific APIs: no node:crypto, no node:fs, no TCP sockets, no native Node.js https module. If your BFF needs direct Redis access, database TCP connections, or Node.js-specific encryption primitives, deploy on Node.js (Cloud Run, ECS) or use Cloudflare's KV, D1, and Hyperdrive binding APIs.

5. Distributed Observability

5.1 W3C traceparent Propagation

Every BFF request originates from a browser. For end-to-end distributed tracing (Datadog, Jaeger, Honeycomb), the trace context must flow: Browser → BFF → Downstream Microservices.
typescript
// src/plugins/tracing.ts
import fp from 'fastify-plugin'

export default fp(async function tracing(app) {
  app.addHook('onRequest', async (request, reply) => {
    // Extract incoming traceparent from browser (if set by OpenTelemetry browser SDK)
    const traceparent = request.headers['traceparent'] ?? generateTraceparent()
    
    // Make it available to route handlers
    request.traceparent = traceparent
  })
})

function generateTraceparent(): string {
  const traceId = crypto.randomUUID().replace(/-/g, '')
  const spanId = crypto.randomUUID().replace(/-/g, '').slice(0, 16)
  return `00-${traceId}-${spanId}-01`
}
typescript
// Pass traceparent to every downstream call
function createServiceClient(baseUrl: string) {
  return {
    async get<T>(path: string, traceparent: string): Promise<T> {
      const res = await fetch(`${baseUrl}${path}`, {
        headers: {
          'traceparent': traceparent,
          'Content-Type': 'application/json'
        },
        signal: AbortSignal.timeout(3000)
      })
      if (!res.ok) throw new Error(`${res.status} from ${baseUrl}${path}`)
      return res.json() as Promise<T>
    }
  }
}
With traceparent flowing through every downstream call, a failed product page request in Datadog shows the complete trace: Browser (0ms) → BFF (12ms) → Product Service (8ms) → Inventory Service (timeout at 3000ms). The slow inventory service is immediately visible without log correlation.
Top-down hierarchy diagram of a BFF serving one web client. Top node: Browser (client). Arrow down to: BFF Gateway (fan-out, slim, translate). BFF fans out to four downstream microservice nodes: Product Service (critical — labeled in cyan), Inventory Service (non-critical — labeled in amber), Recommendations Service (non-critical — labeled in amber), and Wishlist Service (non-critical — labeled in amber). Arrows show responses returning to BFF, then a single slimmed payload returning to the browser.
Top-down hierarchy diagram of a BFF serving one web client. Top node: Browser (client). Arrow down to: BFF Gateway (fan-out, slim, translate). BFF fans out t…

6. Health Checks & Graceful Shutdown

6.1 Health Check Endpoint

typescript
// src/routes/health.ts
import fp from 'fastify-plugin'

export default fp(async function healthRoutes(app) {
  // Liveness: is the process running?
  app.get('/health/live', async () => ({ status: 'ok' }))

  // Readiness: can the BFF serve traffic? (checks downstream connectivity)
  app.get('/health/ready', async (request, reply) => {
    const checks = await Promise.allSettled([
      app.services.product.get('/health'),
      app.services.inventory.get('/health')
    ])

    const allReady = checks.every(c => c.status === 'fulfilled')

    if (!allReady) {
      return reply.status(503).send({ status: 'degraded' })
    }

    return { status: 'ready' }
  })
})

6.2 Graceful Shutdown

typescript
// src/main.ts
import app from './server.js'

const port = parseInt(process.env.PORT ?? '3001', 10)

await app.listen({ port, host: '0.0.0.0' })

// Graceful shutdown: wait for in-flight requests to complete
const shutdown = async (signal: string) => {
  app.log.info(`${signal} received — graceful shutdown`)
  await app.close()
  process.exit(0)
}

process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))
Comparison matrix of three BFF implementation approaches — Monolithic API Gateway, Client-Owned BFF (Fastify/Node.js), and Embedded Next.js Route Handlers — evaluated across client payload fit, failure isolation, team autonomy, deployment independence, and observability depth.
Comparison matrix of three BFF implementation approaches — Monolithic API Gateway, Client-Owned BFF (Fastify/Node.js), and Embedded Next.js Route Handlers —…

Summary

ConceptRule
1-BFF-to-1-clientNever serve multiple client types from one BFF — it becomes an API gateway
Promise.allSettledAlways use over Promise.all for fan-out — classify each result independently
Criticality tiersCritical services (product) → 503 on failure; non-critical (recs, wishlist) → safe defaults
Circuit breakersOpen on 50% failure threshold to fast-fail consistently failing services
Fastify vs. HonoFastify for Node.js with TCP connections; Hono for sub-5ms global edge with Web Standards APIs
traceparentPropagate W3C traceparent from browser through BFF to every downstream call

What's Next

In Part 6, we address the security dimension of the BFF architecture: why browser-based SPAs cannot safely hold OAuth access tokens, and how the BFF acts as an OAuth 2.0 confidential client that keeps raw tokens in server-side session storage, converting httpOnly session cookies into downstream Bearer JWT headers on every request.
Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#BFF#Backend-for-Frontend#Fastify#Hono#Edge Runtime#API Architecture#Resilience
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.