Backend for Frontend Pattern: API Composition, Client-Specific Endpoints, and Reducing Frontend Complexity
A production guide to the BFF pattern: when to introduce a dedicated backend layer per client, how to implement one with Hono on Cloudflare Workers, where GraphQL fits versus REST, authentication forwarding, caching strategies, and the real operational cost of maintaining multiple BFFs.
Frontend teams calling microservices directly is a pattern that works until it does not. The moment a mobile client needs a different response shape than the web app, the moment three services must be composed for a single screen render, the moment you need to add authentication headers that the frontend should not be managing — the distributed call graph becomes a liability. The backend for frontend (BFF) pattern is how you address that.
The idea is straightforward: each distinct frontend surface gets its own backend layer. That layer is responsible for aggregating calls to downstream services, shaping responses for that specific client, handling auth token forwarding, and absorbing the version and transformation churn that would otherwise leak into client code. The tradeoff is that you now have more services to run, more deployment surface, and a new place for subtle ownership problems to accumulate.
This article covers when a BFF is worth it, how to build one with Hono on Cloudflare Workers, where GraphQL makes sense versus REST, caching at the BFF layer, and what maintaining multiple BFFs actually costs.
When Direct Microservice Calls Break Down
Before introducing any new service, the question to answer is: what specific problem does a BFF solve that cannot be solved more cheaply?
The clearest signal that you need a BFF is when a client must make three or more sequential service calls to render a single screen. Sequential calls compound latency. A mobile client on a 200ms network connection making four serial calls to load a dashboard adds up to 800ms before the first byte of meaningful data arrives — before any rendering.
The second signal is response shape divergence. Your web app shows a full order history with line items and status detail. Your mobile app shows a summary card with one status badge. If both call the same orders service, one of them is over-fetching. Over-fetching is fine at low scale. At high volume on mobile, you are transmitting and parsing bytes the client immediately discards.
The third signal is auth complexity creeping into the frontend. If the frontend is managing OAuth token refreshes, service-specific header injection, or per-service credential rotation, you have leaked infrastructure concerns into client code. A BFF is the right place for that logic.
What does not warrant a BFF: a single-frontend application calling two or three services with stable response shapes. The operational cost of a dedicated service is not worth it for that. A lightweight aggregation middleware in your Next.js API routes or a single API gateway with routing rules is sufficient.
Implementing a BFF with Hono on Cloudflare Workers
Hono is a strong choice for BFF implementation on the edge. It is small, it compiles under Cloudflare’s 1MB bundle limit without tree-shaking heroics, and the type system lets you express the exact response contract the frontend consumes — with inference that flows through to hono/client if you want a typed client in the browser.
The following example is a simplified web BFF that aggregates user profile data and recent orders from two downstream services. Both downstream calls happen in parallel.
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
type Bindings = {
USER_SERVICE_URL: string;
ORDER_SERVICE_URL: string;
INTERNAL_API_KEY: KVNamespace;
};
type Variables = {
userId: string;
};
const app = new Hono<{ Bindings: Bindings; Variables: Variables }>();
// Auth middleware: extract and validate the session, set userId in context
app.use("/api/*", async (c, next) => {
const sessionToken = c.req.header("Authorization")?.replace("Bearer ", "");
if (!sessionToken) return c.json({ error: "Unauthorized" }, 401);
// Validate session against your auth service, not the downstream microservices
const session = await validateSession(sessionToken);
if (!session) return c.json({ error: "Unauthorized" }, 401);
c.set("userId", session.userId);
await next();
});
// Aggregated dashboard endpoint: one round trip instead of three
app.get("/api/dashboard", async (c) => {
const userId = c.get("userId");
const baseHeaders = {
"X-Internal-Key": c.env.USER_SERVICE_URL, // illustrative; real key from KV
"X-User-Id": userId,
};
// Parallel downstream calls
const [profileRes, ordersRes] = await Promise.all([
fetch(`${c.env.USER_SERVICE_URL}/users/${userId}`, { headers: baseHeaders }),
fetch(`${c.env.ORDER_SERVICE_URL}/orders?userId=${userId}&limit=5`, {
headers: baseHeaders,
}),
]);
if (!profileRes.ok || !ordersRes.ok) {
return c.json({ error: "Upstream failure" }, 502);
}
const [profile, orders] = await Promise.all([
profileRes.json<UserProfile>(),
ordersRes.json<Order[]>(),
]);
// Shape the response specifically for the web dashboard
return c.json({
user: {
id: profile.id,
displayName: `${profile.firstName} ${profile.lastName}`,
avatarUrl: profile.avatar.url,
},
recentOrders: orders.map((o) => ({
id: o.id,
status: o.status,
total: o.totalCents / 100,
placedAt: o.createdAt,
})),
});
});
The same pattern for a mobile BFF would return a different shape: no avatar URL (handled natively by the mobile app’s image cache), a compressed order summary, and potentially a lower default limit for the orders query. The downstream services do not change. Only the composition and shaping layer differs.
Notice where authentication lives: in the BFF middleware, not in the client. The X-User-Id and internal API key headers are injected by the BFF. The downstream services trust the BFF, not the end user. This is one of the primary security benefits of the pattern.
GraphQL BFF vs REST BFF
Whether to expose a GraphQL schema or REST endpoints from your BFF is a meaningful decision, not a religious one.
A GraphQL BFF fits well when:
- The frontend team iterates quickly on UI and the data requirements change frequently per-view
- Multiple components on the same page need slightly different subsets of the same underlying data
- You want the frontend to co-own the query definition (schema-first, frontend drives field selection)
A REST BFF fits well when:
- Your client surfaces are stable and well-understood
- You need aggressive HTTP layer caching (CDN-level, not application-level)
- The team is smaller and the operational overhead of a schema, introspection endpoint, and resolver graph is not justified
- You are already building on Cloudflare Workers where CDN cache integration for
GETrequests is immediate
The hybrid that works in practice for many teams: REST endpoints for stable, high-volume, cacheable reads (product listings, catalog, public content), and a thin GraphQL layer for authenticated, user-specific, frequently-evolving views (dashboards, account pages, settings). You do not have to pick one for the entire BFF.
If you go GraphQL, Hono does not include a GraphQL runtime natively. You would add graphql-yoga or a similar library and route POST requests to it:
import { createYoga } from "graphql-yoga";
import { createSchema } from "graphql-yoga";
const schema = createSchema({
typeDefs: /* GraphQL */ `
type Query {
dashboard(userId: ID!): Dashboard
}
type Dashboard {
user: UserSummary!
recentOrders: [OrderSummary!]!
}
type UserSummary {
id: ID!
displayName: String!
}
type OrderSummary {
id: ID!
status: String!
total: Float!
}
`,
resolvers: {
Query: {
dashboard: async (_root, { userId }, ctx) => {
// Same parallel fetch logic as the REST example
const [profile, orders] = await Promise.all([
fetchUserProfile(userId, ctx.env),
fetchRecentOrders(userId, ctx.env),
]);
return { user: profile, recentOrders: orders };
},
},
},
});
const yoga = createYoga({ schema, graphqlEndpoint: "/api/graphql" });
app.use("/api/graphql", (c) => yoga.handle(c.req.raw, c.env));
One thing to watch with GraphQL on Workers: introspection queries from dev tooling can be expensive if your schema is large. Disable introspection in production.
Caching at the BFF Layer
The BFF sits between the client and downstream services. It is the right place to absorb repeated reads without hammering downstream APIs, but you need to be deliberate about which responses are safe to cache and for how long.
For Cloudflare Workers, the Cache API gives you control over what gets stored at the edge. For GET endpoints on a REST BFF, you can write responses to the cache and serve them directly on the next request within the TTL:
app.get("/api/products/:id", async (c) => {
const cacheKey = new Request(c.req.url);
const cache = caches.default;
const cached = await cache.match(cacheKey);
if (cached) return cached;
const product = await fetch(
`${c.env.CATALOG_SERVICE_URL}/products/${c.req.param("id")}`,
{ headers: { "X-Internal-Key": c.env.INTERNAL_KEY } }
);
const response = new Response(product.body, {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=120, stale-while-revalidate=30",
},
});
c.executionCtx.waitUntil(cache.put(cacheKey, response.clone()));
return response;
});
For authenticated endpoints, do not cache at the CDN layer. Cache at the application layer with a user-scoped key and a short TTL, or use Cloudflare KV for session-level caching. A dashboard that aggregates three service calls is a legitimate candidate for a 10-second KV cache keyed by userId:dashboard. That absorbs the thundering herd from a slow-loading SPA where multiple component mounts trigger the same endpoint simultaneously.
The rule: public read endpoints get CDN cache headers. Authenticated endpoints get short-lived application-level caching scoped by identity. Never mix the two; cache poisoning attacks exploit exactly that boundary.
Authentication Forwarding
When a BFF calls downstream services, it needs to prove the identity of the calling user without passing raw client tokens downstream. There are two common approaches.
The first is service-to-service tokens. The BFF authenticates the end user, then mints a short-lived internal JWT (signed with a key only the BFF and downstream services share) that asserts the user identity. Downstream services verify this internal JWT. The end user’s credential never reaches a downstream service.
import { sign } from "hono/jwt";
async function mintInternalToken(userId: string, secret: string): Promise<string> {
return sign(
{
sub: userId,
iss: "web-bff",
exp: Math.floor(Date.now() / 1000) + 30, // 30-second TTL
},
secret
);
}
// In the route handler:
const internalToken = await mintInternalToken(userId, c.env.INTERNAL_JWT_SECRET);
const res = await fetch(`${c.env.ORDER_SERVICE_URL}/orders`, {
headers: { Authorization: `Bearer ${internalToken}` },
});
The second approach is header forwarding. The BFF extracts the validated user identity from the session and forwards it as a trusted header (X-User-Id, X-User-Roles). Downstream services accept these headers only from the internal network (or a specific mTLS client). This is simpler but requires strict network-level controls to prevent header injection from untrusted callers.
Service-to-service tokens are the more defensible choice if the downstream services are exposed beyond a strictly private network.
Tradeoffs
| Dimension | BFF adds value | BFF adds cost |
|---|---|---|
| Client diversity | Multiple clients with different data needs benefit from tailored endpoints | A single SPA calling two stable APIs does not need the overhead |
| Latency | Parallel aggregation at the edge reduces total client round trips | BFF adds one more network hop for every request |
| Security | Auth logic and credential management are centralized | The BFF is now a high-value attack surface that requires its own hardening |
| Cache control | BFF can apply fine-grained caching per endpoint per client | Two caching layers (BFF and CDN) require careful coordination to avoid stale data issues |
| Team autonomy | Frontend teams can evolve their BFF independently from backend teams | Each BFF is a service that needs its own CI, deployment, monitoring, and on-call coverage |
| Response shaping | Transformations, field selection, and computed fields are isolated from both frontend and backend | Any bug in the transformation layer is invisible to both the frontend and the upstream service until runtime |
| Versioning | The BFF absorbs breaking changes from downstream services before they reach clients | If the BFF accumulates too many workarounds, it becomes the new legacy layer |
The Operational Cost Nobody Mentions
Adding a BFF per client surface is not free. The pattern solves a real problem but introduces a real maintenance burden. Here is what that looks like in practice after twelve months.
Each BFF is a service. That means its own repository or package, its own deployment pipeline, its own environment variable management, its own error budget, and its own incident response. If you have three client surfaces (web, mobile, internal tooling), you have three services to keep running.
The ownership question becomes thorny. The frontend team owns the BFF because it exists to serve the frontend. But the BFF makes calls to backend services, so backend changes can break BFF behavior. You need integration tests that cover BFF-to-downstream contracts, not just unit tests of the transformation logic.
Drift is the long-term risk. A web BFF and a mobile BFF that both aggregate the same downstream services will start to duplicate logic. Helper functions for auth forwarding, retry logic, circuit breaking — unless this code lives in a shared library, it gets copied and diverges. When the user service changes its response schema, you update both BFFs and hope the shared test suite catches the missed one.
The mitigation is not architectural heroics. It is a shared internal package for auth helpers and HTTP client utilities, enforced in CI. One service, one deployment pipeline, documented contracts between the BFF and its downstream dependencies.
If that operational cost sounds like too much for where your team is today, a single API gateway with client-specific routing rules (query param, header, or subdomain-based) is a reasonable intermediate step. It is less tailored than a true BFF but captures most of the aggregation and auth forwarding value without multiplying service count.
Closing
The BFF pattern earns its complexity when you have genuinely divergent client needs: different response shapes, different aggregation requirements, different performance budgets. It is not a default architecture; it is a solution to a specific class of problems. When those problems are present, a Hono-based BFF on Cloudflare Workers gives you parallel aggregation, edge-native caching, and a type-safe response contract with minimal cold start overhead. When those problems are not present, the pattern adds service count without adding value.
Introduce the pattern when the cost of not having it becomes measurable: frontend teams spending significant time on data-fetching orchestration, mobile performance budgets blown by over-fetching, auth logic scattered across client codebases. Those are the signals.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.