πͺ API Gateway Implementation
Once you have a dozen services, you don't want clients calling each one directly β that leaks your architecture, duplicates auth everywhere, and multiplies round trips. An API Gateway is the single front door: it routes, aggregates, secures, throttles, and observes all traffic on your services' behalf. In this lesson you'll build one with Express and see when to reach for a managed product instead.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain what an API Gateway does and the cross-cutting concerns it centralizes
- Implement routing, request aggregation, and JWT auth in an Express gateway
- Add rate limiting, caching, and circuit breaking to protect backend services
- Choose between the single-gateway, gateway-per-service, and Backend-for-Frontend patterns
- Compare building a gateway vs. using managed products (Kong, AWS API Gateway, NGINX)
Estimated Time: 40β50 minutes β’ Difficulty: Advanced
Hands-on: Build a working Express gateway with auth, routing, aggregation, and caching.
In This Lesson
What Is an API Gateway?
An API Gateway is a single entry point that sits between clients and your backend services. Every request comes in through the gateway, which decides where it goes, applies shared policies, and can combine several service responses into one before replying to the client.
π‘ Hotel concierge analogy: Guests don't call housekeeping, the kitchen, and the box office separately. They ask the concierge, who knows which department handles what (routing), coordinates a multi-part request like "dinner, a show, and a taxi" (aggregation), speaks each department's language (protocol translation), and checks you're actually a guest before acting (security).
π Why not let clients call services directly?
Direct-to-service calls force each client to know your topology, duplicate auth and retry logic, suffer many round trips, and break whenever you restructure services. The gateway hides all of that behind one stable interface.
Core Functions
1. Request routing
The gateway maps incoming paths to backend services. With http-proxy-middleware in Express, each route proxies to a service URL and rewrites the path:
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const app = express();
const services = {
user: 'http://user-service:3001',
product: 'http://product-service:3002',
order: 'http://order-service:3003',
};
// /api/users/42 -> http://user-service:3001/42
app.use('/api/users', createProxyMiddleware({
target: services.user,
changeOrigin: true,
pathRewrite: { '^/api/users': '' },
}));
app.use('/api/products', createProxyMiddleware({
target: services.product,
changeOrigin: true,
pathRewrite: { '^/api/products': '' },
}));
app.listen(8000, () => console.log('API Gateway on :8000'));
2. Request aggregation
The gateway can fan out to several services and stitch the results into one response β the mobile client makes a single call instead of three. Fire the independent calls in parallel:
// GET /api/product-details/:id β combine product, reviews, inventory
app.get('/api/product-details/:id', async (req, res) => {
const { id } = req.params;
try {
const [product, reviews, inventory] = await Promise.all([
fetch(`http://product-service/products/${id}`).then(r => r.json()),
fetch(`http://review-service/reviews?productId=${id}`).then(r => r.json()),
fetch(`http://inventory-service/inventory/${id}`).then(r => r.json()),
]);
res.json({
product,
reviews,
inventory,
inStock: inventory.quantity > 0,
});
} catch (err) {
console.error('Aggregation failed:', err);
res.status(502).json({ error: 'Failed to load product details' });
}
});
β Why aggregate at the gateway
- Fewer round trips β one client request instead of many (huge on mobile networks).
- Simpler clients β the coordination logic lives server-side.
- Backend freedom β you can restructure services without changing client code.
3. Protocol translation
Clients often speak HTTP/JSON while internal services use gRPC or messaging. The gateway translates between them, so clients stay simple and services use whatever is fastest internally.
Security at the Gateway
Centralizing authentication and authorization is one of the biggest wins of a gateway: verify the caller once at the edge, then forward trusted identity to services instead of every service re-implementing token checks.
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET;
// Authentication: verify the JWT, attach identity for downstream services
function authenticate(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(header.slice(7), JWT_SECRET);
req.user = decoded;
// Pass trusted identity to backend services as headers
req.headers['x-user-id'] = decoded.userId;
req.headers['x-user-role'] = decoded.role;
next();
} catch {
return res.status(401).json({ error: 'Invalid token' });
}
}
// Authorization: gate routes by role
function authorize(...allowedRoles) {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// Public β no auth
app.use('/api/auth', createProxyMiddleware({ target: 'http://auth-service:3001', changeOrigin: true }));
// Protected β must be logged in
app.use('/api/users', authenticate, createProxyMiddleware({ target: 'http://user-service:3002', changeOrigin: true }));
// Admin only
app.use('/api/admin', authenticate, authorize('ADMIN'),
createProxyMiddleware({ target: 'http://admin-service:3003', changeOrigin: true }));
β οΈ Don't fully trust the edge β defense in depth
The gateway is the first line, not the only one. Backend services should still validate the forwarded identity headers and never be reachable directly from the public internet (put them on a private network). A single check at the edge is a single point of bypass if a service is ever exposed.
π‘ Security responsibilities the gateway centralizes
- Authentication β tokens, API keys, or mTLS certificates
- Authorization β role/scope checks per route
- Rate limiting β protect against abuse and DoS
- TLS termination β handle HTTPS at the edge
- Input validation & IP filtering β block malformed or suspicious traffic
Gateway Patterns
Single Gateway
One gateway for the whole system. Simplest to run and gives consistent policy enforcement, but it's a potential bottleneck and single point of failure (mitigate by running multiple replicas behind a load balancer).
Backend for Frontend (BFF)
The most popular pattern at scale: a dedicated gateway per client type, each tailored to that client's needs. This avoids one bloated gateway trying to serve wildly different clients.
π Real-world: Netflix
Netflix runs different BFFs per platform: a TV BFF optimized for big screens and playback, a Mobile BFF tuned for data efficiency and battery, a Web BFF with richer account and social features, and a Partner BFF with strict rate limits. Each exposes exactly what its client needs while sharing the same backend services.
| Pattern | Best when⦠| Watch out for⦠|
|---|---|---|
| Single Gateway | Smallβmid systems, one main client | Bottleneck; can get bloated |
| Gateway per Service | Teams own their full stack; specialized policies | Duplicated infra & config |
| Backend for Frontend | Several client types with different needs | More gateways to maintain |
Build vs. Buy
You rarely need to write a gateway from scratch. For production, a mature product handles the hard edge cases (connection pooling, hot config reloads, observability) for you.
| Product | Type | Best for |
|---|---|---|
| Kong | Open source / commercial | Cloud-native, plugin-driven, Kubernetes |
| Amazon API Gateway | Managed cloud | AWS + Lambda, serverless, pay-per-use |
| Azure API Management | Managed cloud | Azure systems, developer portals |
| Apigee | Commercial / cloud | Enterprise API programs, monetization |
| NGINX / OpenResty | Open source | Raw performance, Lua scripting |
| Spring Cloud Gateway | Open source (JVM) | Reactive JVM stacks |
π‘ When a custom gateway makes sense
Build your own (with Express, Fastify, or Spring Cloud Gateway) when you need unusual aggregation logic, tight control, or you're learning. Buy/adopt a product when you want battle-tested rate limiting, plugins, and observability without maintaining them yourself. The Express code in this lesson is perfect for understanding how gateways work β but reach for Kong or a cloud gateway in production.
Rate Limiting & Caching
Rate limiting
Protect backends from abuse and traffic spikes by capping how many requests a client can make. A shared store like Redis lets the limit hold across multiple gateway replicas:
const { RateLimiterRedis } = require('rate-limiter-flexible');
const Redis = require('ioredis');
const redis = new Redis({ host: 'redis-server', port: 6379 });
// 100 requests / minute for general traffic
const generalLimiter = new RateLimiterRedis({
storeClient: redis, keyPrefix: 'general', points: 100, duration: 60,
});
// Stricter: 20 / minute for payments
const paymentLimiter = new RateLimiterRedis({
storeClient: redis, keyPrefix: 'payment', points: 20, duration: 60,
});
function limit(limiter) {
return async (req, res, next) => {
const key = req.user?.userId || req.ip; // per-user if known, else per-IP
try {
await limiter.consume(key);
next();
} catch (rej) {
res.set('Retry-After', String(Math.ceil(rej.msBeforeNext / 1000)));
res.status(429).json({ error: 'Too Many Requests' });
}
};
}
app.use(limit(generalLimiter)); // global cap
app.use('/api/payments', limit(paymentLimiter)); // stricter on payments
π Common rate-limit algorithms
Fixed window: N per clock period (simple, but bursty at boundaries). Sliding window: N over a rolling period (smoother). Token bucket: tokens refill at a steady rate; each request spends one (allows short bursts). Leaky bucket: requests drain at a constant rate; overflow is dropped.
Response caching
Cache responses to slow-changing, read-heavy endpoints so repeat requests skip the backend entirely. Cache only idempotent GETs and never user-specific data:
const cache = new Redis({ host: 'redis-server', port: 6379 });
function cacheFor(seconds) {
return async (req, res, next) => {
if (req.method !== 'GET') return next(); // never cache writes
const key = `cache:${req.originalUrl}`;
const hit = await cache.get(key);
if (hit) {
res.set('X-Cache', 'HIT');
return res.type('application/json').send(hit);
}
// Intercept the response body to store it
const originalSend = res.send.bind(res);
res.send = (body) => {
if (res.statusCode >= 200 && res.statusCode < 300) {
cache.setex(key, seconds, body).catch(console.error);
}
res.set('X-Cache', 'MISS');
return originalSend(body);
};
next();
};
}
// Product listings change slowly β cache 5 min
app.use('/api/products', cacheFor(300),
createProxyMiddleware({ target: 'http://product-service:3002', changeOrigin: true }));
Observability
The gateway sees every request, which makes it the ideal place to measure and trace your system. Three pillars: metrics, logging, and distributed tracing.
Metrics & logging
Track request rate, latency percentiles (p50/p90/p99), error rate, cache-hit ratio, and rate-limit rejections. Attach a request ID to every call so you can follow one request through the logs:
const { randomUUID } = require('crypto');
// Give every request a traceable ID + structured access log
app.use((req, res, next) => {
req.id = req.headers['x-request-id'] || randomUUID();
res.setHeader('X-Request-ID', req.id);
const start = Date.now();
res.on('finish', () => {
console.log(JSON.stringify({
requestId: req.id,
method: req.method,
url: req.originalUrl,
status: res.statusCode,
durationMs: Date.now() - start,
userId: req.user?.userId ?? null,
}));
});
next();
});
Distributed tracing
A single client request may touch many services. Distributed tracing (OpenTelemetry β Jaeger/Zipkin) propagates a shared trace ID across every hop so you can see the whole call tree and find the slow step:
The gateway starts the trace and injects the trace headers into every outgoing call, so each service continues the same trace instead of starting a fresh one.
Hands-on Exercise
ποΈ Build a Mini API Gateway
Objective: Assemble a working Express gateway for three services with the core features from this lesson.
Services: User (auth, profiles) Β· Product (catalog, inventory) Β· Order (processing, history).
Requirements:
- Routing β proxy
/api/users,/api/products,/api/ordersto the right service. - Auth β JWT middleware; leave
/api/productspublic but protect users and orders. - Aggregation β a
/api/product-details/:idendpoint combining product + inventory. - Caching β cache product listings for a few minutes.
- Logging β a request-ID + access-log middleware.
π‘ Hint
Register middleware in the right order: request-ID/logging first, then global rate limit, then per-route authenticate and cacheFor(...) before the proxy. Put the aggregation route above the catch-all proxies so it isn't swallowed. Use an in-memory Map (or node-cache) if you don't want to run Redis locally.
β Starter skeleton
const express = require('express');
const jwt = require('jsonwebtoken');
const { createProxyMiddleware } = require('http-proxy-middleware');
const app = express();
app.use(express.json());
const SERVICES = {
user: 'http://localhost:3001',
product: 'http://localhost:3002',
order: 'http://localhost:3003',
};
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret';
const cache = new Map(); // demo cache: key -> { body, expires }
// 1) request id + logging
app.use((req, res, next) => {
req.id = req.headers['x-request-id'] || crypto.randomUUID();
const start = Date.now();
res.on('finish', () =>
console.log(`[${req.id}] ${req.method} ${req.originalUrl} ${res.statusCode} ${Date.now() - start}ms`));
next();
});
// 2) auth
function authenticate(req, res, next) {
const h = req.headers.authorization;
if (!h?.startsWith('Bearer ')) return res.status(401).json({ error: 'No token' });
try { req.user = jwt.verify(h.slice(7), JWT_SECRET); next(); }
catch { res.status(401).json({ error: 'Invalid token' }); }
}
// 3) aggregation (register BEFORE the product proxy)
app.get('/api/product-details/:id', async (req, res) => {
const { id } = req.params;
const [product, inventory] = await Promise.all([
fetch(`${SERVICES.product}/products/${id}`).then(r => r.json()),
fetch(`${SERVICES.product}/inventory/${id}`).then(r => r.json()),
]);
res.json({ product, inStock: inventory.quantity > 0 });
});
// 4) caching for product listings
function cacheFor(seconds) {
return (req, res, next) => {
if (req.method !== 'GET') return next();
const key = req.originalUrl, now = Date.now(), hit = cache.get(key);
if (hit && hit.expires > now) { res.set('X-Cache', 'HIT'); return res.json(hit.body); }
const send = res.json.bind(res);
res.json = (body) => { cache.set(key, { body, expires: now + seconds * 1000 }); return send(body); };
next();
};
}
// 5) routes
app.use('/api/products', cacheFor(300),
createProxyMiddleware({ target: SERVICES.product, changeOrigin: true, pathRewrite: { '^/api/products': '' } }));
app.use('/api/users', authenticate,
createProxyMiddleware({ target: SERVICES.user, changeOrigin: true, pathRewrite: { '^/api/users': '' } }));
app.use('/api/orders', authenticate,
createProxyMiddleware({ target: SERVICES.order, changeOrigin: true, pathRewrite: { '^/api/orders': '' } }));
app.listen(8000, () => console.log('API Gateway on :8000'));
π― Quick Quiz
Question 1: What is the main advantage of handling authentication at the API Gateway?
Question 2: Which requests are safe to cache at the gateway?
Question 3: In the Backend-for-Frontend (BFF) pattern, you create a separate gateway for eachβ¦
Summary & Quiz
π Key Takeaways
- An API Gateway is the single front door to your services β routing, aggregating, securing, throttling, and observing all traffic.
- Centralized auth verifies callers once at the edge and forwards trusted identity β but keep defense in depth in the services.
- Rate limiting and caching protect backends and cut latency; cache only idempotent, non-personalized GETs.
- Choose a topology: single gateway, gateway-per-service, or Backend-for-Frontend (per client type).
- Build a custom gateway to learn and for special logic; adopt Kong, AWS API Gateway, or NGINX in production.
π Further Reading
- microservices.io β API Gateway Pattern
- Kong Gateway Documentation
- AWS API Gateway Documentation
- Microsoft β Gateway Design Patterns
π What's Next?
You've centralized the front door for your services. Next we move beyond always-on servers entirely with Serverless Computing Principles β running code as functions that scale to zero and bill per invocation.
π Excellent work!
You can now design and build the gateway that fronts an entire microservices system.