Building an API Gateway
In a microservices system, clients should not call a dozen services directly. An API gateway is a single entry point that sits in front of your services and handles the cross-cutting concerns no individual service should reimplement: authentication, rate limiting, routing, and stitching several backend responses into one. Express is a natural fit for this role — it is just a thin HTTP server with composable middleware, which is exactly what a gateway is. This page builds a working gateway that authenticates a request, throttles abuse, proxies it to the right downstream service, and aggregates responses from multiple services into a single payload.
What a gateway is responsible for
A gateway centralizes concerns so each service stays focused on its own domain. Pushing these into one layer means you secure and observe traffic in one place instead of duplicating logic across every service.
| Concern | What the gateway does |
|---|---|
| Authentication | Verify a JWT/session once, attach identity, reject anonymous traffic |
| Rate limiting | Throttle clients before requests ever reach a service |
| Routing | Map a public path prefix to an internal service URL |
| Aggregation | Fan out to several services and merge their responses |
| Resilience | Apply timeouts, retries, and circuit breakers at the edge |
Tip: Keep the gateway thin. It should route, secure, and aggregate — not contain business logic. Once you put domain rules in the gateway, you have rebuilt the monolith you were trying to escape.
Ordering the middleware
The middleware chain runs top to bottom, so order is the design. Put fast, cheap rejections first: rate limiting blocks abuse before you spend CPU on auth, and auth runs before any proxying so unauthenticated traffic never touches a service.
const express = require('express');
const rateLimit = require('express-rate-limit');
const jwt = require('jsonwebtoken');
const app = express();
// 1. Throttle first — reject floods cheaply
app.use(rateLimit({
windowMs: 60_000, // 1 minute
max: 100, // 100 requests per IP per window
standardHeaders: true, // emit RateLimit-* headers
legacyHeaders: false,
}));
// 2. Authenticate every request that needs identity
function authenticate(req, res, next) {
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) return res.status(401).json({ error: 'missing token' });
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
res.status(401).json({ error: 'invalid token' });
}
}
A throttled client gets a clear 429 without ever reaching a downstream service:
Output:
HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 42
{"error":"Too many requests, please try again later."}
Proxying to downstream services
For pass-through routes, use http-proxy-middleware. You map a public path prefix to an internal service address and let the proxy stream the request and response untouched. Forwarding the verified identity as a trusted header means services can authorize without re-parsing the JWT.
npm install express http-proxy-middleware express-rate-limit jsonwebtoken
const { createProxyMiddleware } = require('http-proxy-middleware');
const services = {
users: 'http://users-service:3001',
orders: 'http://orders-service:3002',
};
function proxyTo(target) {
return createProxyMiddleware({
target,
changeOrigin: true,
// strip the public prefix: /api/users/42 -> /42
pathRewrite: (path, req) => path.replace(req.baseUrl, ''),
onProxyReq: (proxyReq, req) => {
// pass verified identity downstream as a trusted header
if (req.user) proxyReq.setHeader('X-User-Id', req.user.sub);
},
});
}
app.use('/api/users', authenticate, proxyTo(services.users));
app.use('/api/orders', authenticate, proxyTo(services.orders));
Now GET /api/orders/7 hits the gateway, is rate-limited, authenticated, rewritten to GET /7, and forwarded to the orders service with an X-User-Id header it can trust.
Warning: A downstream
X-User-Idheader is only trustworthy if services are unreachable except through the gateway. Lock services to a private network so clients cannot bypass the gateway and forge identity headers.
Aggregating multiple services
Aggregation is the gateway feature that pays for itself. Instead of a mobile client making three round trips, the gateway fans out concurrently with Promise.all, then composes one response. Use Promise.allSettled so a single slow or failing service degrades gracefully instead of failing the whole request.
const SERVICE_TIMEOUT = 2000;
async function fetchJson(url, headers) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), SERVICE_TIMEOUT);
try {
const res = await fetch(url, { headers, signal: controller.signal });
if (!res.ok) throw new Error(`${url} -> ${res.status}`);
return await res.json();
} finally {
clearTimeout(timer);
}
}
app.get('/api/dashboard', authenticate, async (req, res, next) => {
const headers = { 'X-User-Id': req.user.sub };
try {
const [profile, orders, cart] = await Promise.allSettled([
fetchJson(`${services.users}/${req.user.sub}`, headers),
fetchJson(`${services.orders}?user=${req.user.sub}`, headers),
fetchJson(`${services.users}/${req.user.sub}/cart`, headers),
]);
res.json({
profile: profile.status === 'fulfilled' ? profile.value : null,
orders: orders.status === 'fulfilled' ? orders.value : [],
cart: cart.status === 'fulfilled' ? cart.value : null,
degraded: [profile, orders, cart].some((r) => r.status === 'rejected'),
});
} catch (err) {
next(err);
}
});
One gateway call replaces three client round trips:
Output:
GET /api/dashboard
Authorization: Bearer <token>
HTTP/1.1 200 OK
{
"profile": { "id": "u_42", "name": "Ada Lovelace" },
"orders": [ { "id": "o_7", "total": 4200 } ],
"cart": { "items": 3 },
"degraded": false
}
Because the calls run concurrently, the aggregate latency is roughly the slowest single service, not the sum of all three.
Note: In Express 5, route handlers that return a rejected promise are forwarded to your error middleware automatically, so the explicit
try/catchbecomes optional. On Express 4 you still need it (or a wrapper) to avoid an unhandled rejection.
Best Practices
- Order middleware so the cheapest rejections run first: rate limiting, then authentication, then proxying.
- Keep the gateway logic-free — route, secure, and aggregate, but leave business rules inside the services.
- Run every downstream call with a timeout and an
AbortControllerso one stalled service cannot hang the gateway. - Prefer
Promise.allSettledfor aggregation so a partial failure degrades gracefully instead of returning a500. - Isolate services on a private network and pass verified identity as a trusted header rather than re-validating tokens everywhere.
- Add a circuit breaker around flaky dependencies so the gateway fails fast instead of queuing doomed requests.
- Emit a correlation/request ID at the edge and forward it downstream to make distributed tracing possible.