Backends for Frontends: Dedicated Backend per Client Type
How to create dedicated backends per client type. Covers BFF for web, mobile, and desktop. Covers API aggregation, client-specific optimization, and GraphQL BFF.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
The backends for frontends (BFF) pattern creates a separate backend service for each frontend client type: one for web, one for mobile, one for desktop. Each BFF aggregates data from multiple microservices, optimizes responses for its client’s needs, and handles client-specific concerns like authentication, caching, and response formatting. Instead of a single generic API that serves all clients (and ends up bloated with client-specific endpoints), each BFF is tailored to its client. The web BFF can return rich data for large screens; the mobile BFF returns minimal payloads for slow networks. Teams own their BFF end-to-end, from frontend to backend.
When to Use
- Multiple frontend clients (web, mobile, desktop, smartwatch) with different data needs
- Mobile clients that need smaller payloads than web
- Frontend teams that want to iterate independently of backend teams
- Applications where different clients need different authentication flows
- Reducing over-fetching: clients receive only the data they need
When NOT to Use
- Single client type (one web app only)
- Simple API with few endpoints that already serves all clients well
- Small team where maintaining multiple BFFs adds too much overhead
- Internal tools where payload size and client optimization don’t matter
Solution
BFF architecture diagram
┌──────────────┐
│ Web Client │
└──────┬───────┘
│
┌──────▼───────┐
│ Web BFF │
│ (Node.js) │
└──────┬───────┘
│
┌────────────┼────────────┐
│ │ │
┌─────▼─────┐ ┌───▼─────┐ ┌───▼──────┐
│ User │ │ Order │ │ Inventory│
│ Service │ │ Service │ │ Service │
└───────────┘ └─────────┘ └──────────┘
│ │ │
│ │ │
┌──────▲───────┐
│ Mobile BFF │
│ (Go) │
└──────▲───────┘
│
┌──────┴───────┐
│ Mobile Client│
└──────────────┘
Web BFF (Node.js)
// web-bff/server.js — Web BFF aggregates multiple services
const express = require("express");
const axios = require("axios");
const app = express();
app.use(express.json());
const USER_SERVICE = process.env.USER_SERVICE_URL || "http://user-service:3001";
const ORDER_SERVICE = process.env.ORDER_SERVICE_URL || "http://order-service:3002";
const INVENTORY_SERVICE = process.env.INVENTORY_SERVICE_URL || "http://inventory-service:3003";
// Web dashboard — aggregates user profile, recent orders, and recommendations
app.get("/api/dashboard", async (req, res) => {
const userId = req.headers["x-user-id"];
if (!userId) return res.status(401).json({ error: "Unauthorized" });
try {
// Fetch data from multiple services in parallel
const [userResp, ordersResp, recommendationsResp] = await Promise.all([
axios.get(`${USER_SERVICE}/users/${userId}`),
axios.get(`${ORDER_SERVICE}/users/${userId}/orders?limit=10`),
axios.get(`${INVENTORY_SERVICE}/recommendations?userId=${userId}&limit=5`)
]);
// Aggregate and format for web client
const dashboard = {
user: {
id: userResp.data.id,
name: userResp.data.name,
email: userResp.data.email,
avatarUrl: userResp.data.avatarUrl,
memberSince: userResp.data.createdAt
},
recentOrders: ordersResp.data.orders.map(order => ({
id: order.id,
date: order.createdAt,
status: order.status,
total: order.total,
items: order.items.length
})),
recommendations: recommendationsResp.data.products.map(product => ({
id: product.id,
name: product.name,
price: product.price,
imageUrl: product.imageUrl,
rating: product.rating
}))
};
res.json(dashboard);
} catch (error) {
console.error("Dashboard aggregation failed:", error.message);
res.status(502).json({ error: "Failed to load dashboard" });
}
});
// Web product detail — includes reviews and related products
app.get("/api/products/:productId", async (req, res) => {
const { productId } = req.params;
try {
const [productResp, reviewsResp, relatedResp] = await Promise.all([
axios.get(`${INVENTORY_SERVICE}/products/${productId}`),
axios.get(`${INVENTORY_SERVICE}/products/${productId}/reviews?limit=20`),
axios.get(`${INVENTORY_SERVICE}/products/${productId}/related`)
]);
res.json({
product: productResp.data,
reviews: reviewsResp.data.reviews,
relatedProducts: relatedResp.data.products
});
} catch (error) {
res.status(502).json({ error: "Failed to load product" });
}
});
app.listen(4000, () => console.log("Web BFF running on port 4000"));
Mobile BFF (Go)
// mobile-bff/main.go — Mobile BFF returns minimal payloads
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
)
type MobileDashboard struct {
UserName string `json:"userName"`
OrderCount int `json:"orderCount"`
LastOrder *MobileOrder `json:"lastOrder,omitempty"`
Suggestions []MobileProduct `json:"suggestions"`
}
type MobileOrder struct {
ID string `json:"id"`
Status string `json:"status"`
Total float64 `json:"total"`
}
type MobileProduct struct {
ID string `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
var (
userService = "http://user-service:3001"
orderService = "http://order-service:3002"
inventoryService = "http://inventory-service:3003"
)
func dashboardHandler(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-Id")
if userID == "" {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
var wg sync.WaitGroup
var userName string
var orderCount int
var lastOrder *MobileOrder
var suggestions []MobileProduct
// Fetch user name
wg.Add(1)
go func() {
defer wg.Done()
resp, err := http.Get(fmt.Sprintf("%s/users/%s", userService, userID))
if err == nil && resp.StatusCode == 200 {
var user struct{ Name string `json:"name"` }
json.NewDecoder(resp.Body).Decode(&user)
userName = user.Name
}
}()
// Fetch orders summary
wg.Add(1)
go func() {
defer wg.Done()
resp, err := http.Get(fmt.Sprintf("%s/users/%s/orders?limit=1", orderService, userID))
if err == nil && resp.StatusCode == 200 {
var orders struct {
Total int `json:"total"`
Items []MobileOrder `json:"orders"`
}
json.NewDecoder(resp.Body).Decode(&orders)
orderCount = orders.Total
if len(orders.Items) > 0 {
lastOrder = &orders.Items[0]
}
}
}()
// Fetch minimal product suggestions
wg.Add(1)
go func() {
defer wg.Done()
resp, err := http.Get(fmt.Sprintf("%s/recommendations?userId=%s&limit=3", inventoryService, userID))
if err == nil && resp.StatusCode == 200 {
var recs struct{ Products []MobileProduct `json:"products"` }
json.NewDecoder(resp.Body).Decode(&recs)
suggestions = recs.Products
}
}()
wg.Wait()
dashboard := MobileDashboard{
UserName: userName,
OrderCount: orderCount,
LastOrder: lastOrder,
Suggestions: suggestions,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(dashboard)
}
func main() {
http.HandleFunc("/api/dashboard", dashboardHandler)
log.Println("Mobile BFF running on port 5000")
log.Fatal(http.ListenAndServe(":5000", nil))
}
GraphQL BFF
// graphql-bff/server.js — GraphQL BFF for flexible client queries
const { ApolloServer } = require("@apollo/server");
const { startStandaloneServer } = require("@apollo/server/standalone");
const axios = require("axios");
const typeDefs = `#graphql
type User {
id: ID!
name: String!
email: String!
avatarUrl: String
}
type Order {
id: ID!
status: String!
total: Float!
createdAt: String!
items: [OrderItem!]!
}
type OrderItem {
productId: ID!
productName: String!
quantity: Int!
price: Float!
}
type Dashboard {
user: User!
recentOrders: [Order!]!
totalOrders: Int!
}
type Query {
dashboard(userId: ID!): Dashboard!
user(id: ID!): User
orders(userId: ID!, limit: Int): [Order!]!
}
`;
const resolvers = {
Query: {
async dashboard(_, { userId }) {
const [userResp, ordersResp] = await Promise.all([
axios.get(`http://user-service:3001/users/${userId}`),
axios.get(`http://order-service:3002/users/${userId}/orders?limit=5`)
]);
return {
user: userResp.data,
recentOrders: ordersResp.data.orders,
totalOrders: ordersResp.data.total
};
},
async user(_, { id }) {
const resp = await axios.get(`http://user-service:3001/users/${id}`);
return resp.data;
},
async orders(_, { userId, limit = 10 }) {
const resp = await axios.get(
`http://order-service:3002/users/${userId}/orders?limit=${limit}`
);
return resp.data.orders;
}
}
};
const server = new ApolloServer({ typeDefs, resolvers });
startStandaloneServer(server, { listen: { port: 4000 } })
.then(({ url }) => console.log(`GraphQL BFF ready at ${url}`));
Python BFF with Flask
# bff/mobile.py — Python mobile BFF
from flask import Flask, request, jsonify
import requests
from concurrent.futures import ThreadPoolExecutor
app = Flask(__name__)
USER_SERVICE = "http://user-service:3001"
ORDER_SERVICE = "http://order-service:3002"
INVENTORY_SERVICE = "http://inventory-service:3003"
executor = ThreadPoolExecutor(max_workers=10)
@app.route("/api/dashboard")
def mobile_dashboard():
user_id = request.headers.get("X-User-Id")
if not user_id:
return jsonify({"error": "Unauthorized"}), 401
# Fetch minimal data for mobile
def fetch_user():
resp = requests.get(f"{USER_SERVICE}/users/{user_id}", timeout=5)
return resp.json() if resp.status_code == 200 else None
def fetch_orders():
resp = requests.get(
f"{ORDER_SERVICE}/users/{user_id}/orders",
params={"limit": 3, "fields": "id,status,total"},
timeout=5
)
return resp.json() if resp.status_code == 200 else {"orders": []}
def fetch_suggestions():
resp = requests.get(
f"{INVENTORY_SERVICE}/recommendations",
params={"userId": user_id, "limit": 3, "fields": "id,name,price"},
timeout=5
)
return resp.json() if resp.status_code == 200 else {"products": []}
# Parallel fetch
from concurrent.futures import as_completed
futures = {
executor.submit(fetch_user): "user",
executor.submit(fetch_orders): "orders",
executor.submit(fetch_suggestions): "suggestions"
}
results = {}
for future in as_completed(futures):
key = futures[future]
results[key] = future.result()
# Minimal payload for mobile
return jsonify({
"userName": results["user"]["name"] if results["user"] else "",
"orderCount": len(results["orders"].get("orders", [])),
"lastOrder": results["orders"]["orders"][0] if results["orders"]["orders"] else None,
"suggestions": results["suggestions"].get("products", [])
})
@app.route("/api/product/<product_id>")
def mobile_product(product_id):
# Mobile only needs name, price, and availability — no reviews, no related
resp = requests.get(
f"{INVENTORY_SERVICE}/products/{product_id}",
params={"fields": "id,name,price,inStock"},
timeout=5
)
if resp.status_code != 200:
return jsonify({"error": "Not found"}), 404
return jsonify(resp.json())
BFF with caching
// bff/cache.js — BFF with Redis caching for expensive aggregations
const express = require("express");
const redis = require("redis");
const axios = require("axios");
const app = express();
const cache = redis.createClient({ url: process.env.REDIS_URL });
cache.connect();
const CACHE_TTL = 60; // 1 minute
// Cache middleware for BFF endpoints
async function cacheMiddleware(key, ttl, handler) {
return async (req, res) => {
const cacheKey = `${key}:${req.headers["x-user-id"] || "anonymous"}`;
try {
const cached = await cache.get(cacheKey);
if (cached) {
res.setHeader("X-Cache", "HIT");
return res.json(JSON.parse(cached));
}
} catch (err) {
console.error("Cache read failed:", err);
}
const result = await handler(req);
res.setHeader("X-Cache", "MISS");
try {
await cache.setEx(cacheKey, ttl, JSON.stringify(result));
} catch (err) {
console.error("Cache write failed:", err);
}
res.json(result);
};
}
app.get("/api/dashboard", cacheMiddleware("dashboard", CACHE_TTL, async (req) => {
const userId = req.headers["x-user-id"];
const [user, orders] = await Promise.all([
axios.get(`http://user-service:3001/users/${userId}`),
axios.get(`http://order-service:3002/users/${userId}/orders?limit=5`)
]);
return { user: user.data, recentOrders: orders.data.orders };
}));
Variants
Shared BFF with client-specific response shaping
# shared_bff.py — one BFF with response shapers per client type
from flask import Flask, request, jsonify
import requests
app = Flask(__name__)
def shape_for_web(data):
"""Full payload for web with large screens."""
return {
"user": {
"id": data["user"]["id"],
"name": data["user"]["name"],
"email": data["user"]["email"],
"avatarUrl": data["user"].get("avatarUrl"),
"memberSince": data["user"]["createdAt"]
},
"orders": data["orders"],
"recommendations": data.get("recommendations", [])
}
def shape_for_mobile(data):
"""Minimal payload for mobile."""
return {
"userName": data["user"]["name"],
"orderCount": len(data["orders"]),
"lastOrder": data["orders"][0] if data["orders"] else None
}
@app.route("/api/dashboard")
def dashboard():
client_type = request.headers.get("X-Client-Type", "web")
user_id = request.headers.get("X-User-Id")
# Fetch full data once
data = fetch_dashboard_data(user_id)
# Shape based on client
shaper = shape_for_mobile if client_type == "mobile" else shape_for_web
return jsonify(shaper(data))
BFF with WebSocket for real-time updates
// bff/websocket.js — BFF with WebSocket for live updates
const express = require("express");
const http = require("http");
const { Server } = require("socket.io");
const axios = require("axios");
const app = express();
const server = http.createServer(app);
const io = new Server(server);
io.on("connection", (socket) => {
const userId = socket.handshake.auth.userId;
if (!userId) {
socket.disconnect();
return;
}
console.log(`Client connected: ${userId}`);
// Push order status updates
socket.join(`orders:${userId}`);
// Push inventory updates
socket.join("inventory:updates");
});
// Webhook from order service — push to connected clients
app.post("/webhooks/order-updated", (req, res) => {
const { userId, order } = req.body;
io.to(`orders:${userId}`).emit("order:updated", order);
res.json({ ok: true });
});
server.listen(4000, () => console.log("BFF with WebSocket on port 4000"));
Best Practices
-
For a deeper guide, see Gateway Routing Pattern.
-
One BFF per client type, not per device — web BFF serves both desktop and mobile web
-
Keep BFF thin — aggregation and formatting only, no business logic
-
Use parallel calls — fetch from multiple services concurrently to reduce latency
-
Cache aggressively — dashboard data changes slowly; cache for 30-60 seconds
-
Handle partial failures — if one service is down, return partial data with a warning
-
Version the BFF API —
/api/v1/dashboardso you can evolve without breaking clients -
Consider GraphQL for flexible clients — clients request exactly what they need
-
Monitor BFF latency — it’s the user’s critical path; track p50, p95, p99
Common Mistakes
- Business logic in BFF: the BFF should only aggregate and format. Business rules belong in domain services.
- One BFF for all clients: defeats the purpose. Mobile gets web payloads, wasting bandwidth.
- Sequential service calls: calling services one by one. Use
Promise.allor equivalent for parallel calls. - No caching: every request hits all downstream services. Cache expensive aggregations.
- No error handling for partial failures: one service failure breaks the entire response. Return partial data.
FAQ
What is the BFF pattern?
A separate backend service for each frontend client type (web, mobile, desktop). Each BFF aggregates data from multiple microservices and formats responses optimized for its client. The web BFF returns rich data; the mobile BFF returns minimal payloads.
How many BFFs should I have?
One per distinct client type. Typically: web BFF, mobile BFF. If you have a desktop app with very different needs, add a desktop BFF. Don’t create a BFF per device model — one mobile BFF serves all mobile devices.
Should the BFF use GraphQL or REST?
Both work. REST is simpler and well-understood. GraphQL lets clients request exactly the fields they need, which is useful when different screens need different subsets of data. Start with REST; adopt GraphQL if over-fetching becomes a problem.
Can the BFF call other BFFs?
No. BFFs should call domain microservices, not other BFFs. BFF-to-BFF calls create coupling and circular dependencies. If two BFFs need the same data, they both call the same domain service.
Who owns the BFF?
The frontend team owns the BFF. This is a key principle: the team that builds the frontend also builds its BFF. This gives the frontend team end-to-end ownership and lets them iterate without waiting for backend teams.
Related Resources
Modular Monolith: Single Deployable with Module Boundaries
How to build a modular monolith with strict internal module boundaries. Covers module isolation, shared kernel, inter-module communication, and migration to microservices.
PatternAmbassador: Offload Cross-Cutting Concerns to a Proxy
How to offload cross-cutting concerns to a proxy ambassador. Covers connection pooling, retry logic, circuit breaking, monitoring, and TLS termination for client services.
PatternAnti-Corruption Layer: Isolate Legacy with Adapters
How to isolate legacy systems with translation adapters. Covers ACL facade, domain translation, bidirectional mapping, and gradual legacy replacement.
PatternStrangler Fig: Gradually Replace Legacy by Intercepting
How to gradually replace a legacy system by intercepting routes and routing traffic to new services. Covers strangler fig, incremental migration, and cutover.