API Gateway Patterns That Scale — From Simple Proxy to Platform
The Gateway Evolution
Every API gateway follows the same evolution: reverse proxy → authentication layer → rate limiter → request transformer → orchestration layer → unmaintainable monolith. Understanding this trajectory helps you make better decisions at each stage.
A fintech startup started with nginx as a reverse proxy. Eighteen months later, their nginx config was 3,000 lines with embedded Lua scripts handling auth, rate limiting, request transformation, and circuit breaking. Deployments required a networking expert. Nobody else could touch it.
Pattern 1: Simple Reverse Proxy
Start here. Route requests to the right service:
# Good enough for < 5 services
upstream order-service {
server order-service:3000;
}
upstream product-service {
server product-service:3001;
}
server {
location /api/orders {
proxy_pass http://order-service;
}
location /api/products {
proxy_pass http://product-service;
}
}When to use: You have fewer than 5 services and simple routing needs. Don't over-engineer this.
Pattern 2: Authentication Gateway
The first real responsibility your gateway takes on:
// Express middleware as gateway
import express from "express";
import { createProxyMiddleware } from "http-proxy-middleware";
const app = express();
// Authentication middleware — runs before all routes
app.use("/api/*", async (req, res, next) => {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) return res.status(401).json({ error: "Unauthorized" });
try {
const user = await verifyJWT(token);
req.headers["x-user-id"] = user.id;
req.headers["x-user-role"] = user.role;
next();
} catch {
return res.status(401).json({ error: "Invalid token" });
}
});
// Route to services — they trust the gateway's auth headers
app.use("/api/orders", createProxyMiddleware({
target: "http://order-service:3000",
pathRewrite: { "^/api/orders": "" },
}));Key principle: Services behind the gateway trust the gateway's authentication headers. They don't re-verify JWTs. This keeps authentication logic in one place.
Pattern 3: Rate Limiting
Protect your services from abuse and cascading failures:
import { RateLimiterRedis } from "rate-limiter-flexible";
const rateLimiters = {
// Different limits for different endpoints
default: new RateLimiterRedis({
storeClient: redis,
keyPrefix: "rl:default",
points: 100, // requests
duration: 60, // per 60 seconds
}),
auth: new RateLimiterRedis({
storeClient: redis,
keyPrefix: "rl:auth",
points: 5, // 5 login attempts
duration: 300, // per 5 minutes
}),
webhook: new RateLimiterRedis({
storeClient: redis,
keyPrefix: "rl:webhook",
points: 1000, // webhooks need higher limits
duration: 60,
}),
};
app.use("/api/*", async (req, res, next) => {
const limiter = selectLimiter(req.path);
const key = req.headers["x-user-id"] || req.ip;
try {
const result = await limiter.consume(key);
// Set rate limit headers
res.set("X-RateLimit-Remaining", String(result.remainingPoints));
res.set("X-RateLimit-Reset", String(Math.ceil(result.msBeforeNext / 1000)));
next();
} catch {
res.status(429).json({ error: "Too many requests" });
}
});Pattern 4: Backend for Frontend (BFF)
When different clients need different API shapes:
// Mobile BFF — aggregates data, minimizes round trips
app.get("/mobile/api/home", async (req, res) => {
const userId = req.headers["x-user-id"];
// Parallel fetch from multiple services
const [user, recommendations, cart, notifications] = await Promise.all([
fetch(`http://user-service/users/${userId}`).then(r => r.json()),
fetch(`http://recommendation-service/for/${userId}?limit=10`).then(r => r.json()),
fetch(`http://cart-service/carts/${userId}`).then(r => r.json()),
fetch(`http://notification-service/unread/${userId}`).then(r => r.json()),
]);
// Single response shaped for the mobile home screen
res.json({
greeting: `Welcome back, ${user.firstName}`,
recommendedProducts: recommendations.map(r => ({
id: r.id,
name: r.name,
image: r.images[0]?.thumbnail, // Mobile-optimized image
price: r.price,
})),
cartItemCount: cart.items.length,
unreadNotifications: notifications.count,
});
});Gateway Selection Guide
| Need | Solution |
|-------------------------------|-------------------------------|
| Simple routing (< 5 services) | nginx / Caddy |
| Auth + rate limiting | Kong / Express gateway |
| GraphQL federation | Apollo Router / Cosmo |
| Full platform (enterprise) | AWS API Gateway / Apigee |
| Kubernetes-native | Istio / Envoy / Traefik |
Decision criteria:
→ Team under 20 engineers? Kong or Express-based gateway
→ Already on Kubernetes? Istio or Traefik ingress
→ Need managed? AWS API Gateway + Lambda authorizer
→ GraphQL? Apollo Router for federation
Anti-Patterns to Avoid
1. Business logic in the gateway
✗ Order validation, pricing calculations, inventory checks
✓ Keep the gateway thin: auth, rate limiting, routing
2. Synchronous orchestration
✗ Gateway calls 5 services sequentially to build a response
✓ Use BFF pattern for aggregation, async events for orchestration
3. Single point of failure
✗ One gateway instance handling all traffic
✓ Multiple instances behind a load balancer, health checks
4. Gateway as integration layer
✗ Request/response transformation between incompatible services
✓ Anti-corruption layer in the consuming service instead
5. Shared gateway for internal and external APIs
✗ Same gateway, same rules for partners and internal services
✓ Separate gateways: external (strict) and internal (fast)
Your API gateway should be boring infrastructure, not a clever engineering project. It should do authentication, rate limiting, and routing. Everything else belongs in the services themselves. The moment your gateway becomes "smart," it becomes a bottleneck — for both traffic and team velocity.