Node.js Microservices: Building an API Gateway with Express Gateway
Architect scalable microservices in Node.js with an API Gateway pattern. Learn routing, service discovery, JWT authentication propagation, and circuit breakers using Express Gateway and http-proxy-middleware.

Microservices promise independent scaling and deployment, but they introduce complexity: client applications need to know the location of 10+ services, authentication must be orchestrated, and rate limiting becomes distributed. The API Gateway pattern solves these challenges by providing a single entry point. This guide shows you how to build a production-ready gateway in Node.js.
Why an API Gateway?
An API Gateway sits between clients and microservices, handling:
- Request routing to appropriate service
- Authentication and authorization
- Rate limiting and throttling
- Response aggregation (multiple services into one response)
- Observability (logging, metrics, tracing)
Building a Gateway with http-proxy-middleware
Basic proxy setup that routes to different services based on URL path:
import express from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
const app = express();
// Route /api/users -> user-service:3001
app.use(‘/api/users’, createProxyMiddleware({
target: ‘http://user-service:3001’,
changeOrigin: true,
pathRewrite: { ‘^/api/users’: ‘’ },
onProxyReq: (proxyReq, req, res) => {
// Forward original user ID from auth header
if (req.userId) {
proxyReq.setHeader(‘X-User-Id’, req.userId);
}
}
}));
// Route /api/orders -> order-service:3002
app.use(‘/api/orders’, createProxyMiddleware({
target: ‘http://order-service:3002’,
changeOrigin: true,
pathRewrite: { ‘^/api/orders’: ‘’ }
}));
// Route /api/products -> product-service:3003
app.use(‘/api/products’, createProxyMiddleware({
target: ‘http://product-service:3003’,
changeOrigin: true
}));
app.listen(8080);
Service Discovery with Consul or etcd
Hardcoded service URLs break in dynamic environments. Use service discovery:
import Consul from 'consul';
const consul = new Consul({ host: ‘consul-server’, port: 8500 });
async function getServiceUrl(serviceName) {
const services = await consul.catalog.service.nodes(serviceName);
if (!services.length) throw new Error(No nodes for ${serviceName});
// Simple round-robin or random selection
const node = services[Math.floor(Math.random() * services.length)];
return http://${node.ServiceAddress}:${node.ServicePort};
}
// Dynamic proxy with service lookup
app.use(‘/api/users’, async (req, res, next) => {
const target = await getServiceUrl(‘user-service’);
const proxy = createProxyMiddleware({ target, changeOrigin: true });
proxy(req, res, next);
});
Authentication at Gateway Level
Centralize JWT verification so individual services don't have to:
import jwt from 'jsonwebtoken';
const authMiddleware = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader) return res.status(401).json({ error: ‘Missing token’ });
const token = authHeader.split(’ ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // Attach to request
next();
} catch (err) {
res.status(403).json({ error: ‘Invalid token’ });
}
};
// Protect all routes under /api
app.use(‘/api’, authMiddleware);
// Now downstream services receive user info via headers
const proxyWithUser = createProxyMiddleware({
target: ‘http://user-service:3001’,
changeOrigin: true,
onProxyReq: (proxyReq, req) => {
proxyReq.setHeader(‘X-User-Id’, req.user.userId);
proxyReq.setHeader(‘X-User-Roles’, JSON.stringify(req.user.roles));
}
});
Rate Limiting per Client and per Service
Use Redis-based rate limiting at gateway to protect downstream services:
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
// Global limit (all endpoints)
const globalLimiter = rateLimit({
store: new RedisStore({ client: redis }),
windowMs: 60 * 1000,
max: 1000, // 1000 requests per minute per IP
keyGenerator: (req) => req.ip
});
// Per-user stricter limit for sensitive endpoints
const authLimiter = rateLimit({
store: new RedisStore({ client: redis }),
windowMs: 15 * 60 * 1000,
max: 20, // 20 login attempts
keyGenerator: (req) => req.body.email || req.ip
});
app.use(‘/api’, globalLimiter);
app.use(‘/api/auth/login’, authLimiter);
Response Aggregation (GraphQL Federation)
Instead of client making 3 round trips, gateway fetches from multiple services and combines:
app.get('/api/user-dashboard/:userId', async (req, res) => {
const userId = req.params.userId;
// Parallel requests to multiple services
const [profile, orders, recommendations] = await Promise.all([
fetch(${USER_SERVICE}/users/${userId}).then(r => r.json()),
fetch(${ORDER_SERVICE}/orders?userId=${userId}).then(r => r.json()),
fetch(${RECOMMENDATION_SERVICE}/recs/${userId}).then(r => r.json())
]);
res.json({
user: profile,
recentOrders: orders.slice(0, 5),
recommendations
});
});
Circuit Breaker Pattern
Prevent cascading failures when a service is down:
import CircuitBreaker from 'opossum';
const options = {
timeout: 3000,
errorThresholdPercentage: 50, // Open circuit if 50% fail
resetTimeout: 30000, // Try again after 30 seconds
};
async function callUserService(req) {
const response = await fetch(‘http://user-service:3001/users/me’, {
headers: { Authorization: req.headers.authorization }
});
if (!response.ok) throw new Error(‘Service error’);
return response.json();
}
const breaker = new CircuitBreaker(callUserService, options);
app.get(‘/api/me’, async (req, res) => {
try {
const user = await breaker.fire(req);
res.json(user);
} catch (err) {
if (breaker.opened) {
res.status(503).json({ error: ‘User service unavailable, try later’ });
} else {
res.status(500).json({ error: ‘Internal error’ });
}
}
});
Observability: Logging and Tracing
Add request ID propagation across services:
import { v4 as uuidv4 } from 'uuid';
app.use((req, res, next) => {
req.requestId = req.headers[‘x-request-id’] || uuidv4();
res.setHeader(‘X-Request-Id’, req.requestId);
const start = Date.now();
res.on(‘finish’, () => {
console.log(JSON.stringify({
requestId: req.requestId,
method: req.method,
url: req.url,
status: res.statusCode,
duration: Date.now() - start
}));
});
next();
});
// Proxy middleware forwards request ID
const proxy = createProxyMiddleware({
target: ‘http://service’,
onProxyReq: (proxyReq, req) => {
proxyReq.setHeader(‘X-Request-Id’, req.requestId);
}
});
Deployment: Docker Compose and Kubernetes
Example docker-compose for local microservices with gateway:
version: '3'
services:
gateway:
build: ./api-gateway
ports:
- "8080:8080"
environment:
- USER_SERVICE=http://user-service:3001
- ORDER_SERVICE=http://order-service:3002
user-service:
build: ./user-service
ports:
- “3001”
order-service:
build: ./order-service
ports:
- “3002”
redis:
image: redis:7-alpine
ports:
- “6379”
consul:
image: consul:latest
ports:
- “8500”
Common Pitfalls
- Gateway becomes bottleneck: Scale gateway horizontally (multiple instances behind load balancer).
- Timeouts misconfiguration: Gateway timeout should be higher than slowest service + retries.
- No fallback for service failures: Implement circuit breakers and stale cache fallbacks.
- Over-aggregation: Returning too much data slows gateway. Consider GraphQL for client-driven fetching.
Alternative Gateway Solutions
- Express Gateway (open source): Full-featured, plugin-based, but less flexible.
- Kong: Nginx-based, high-performance, Lua plugins.
- Traefik: Kubernetes-native with automatic service discovery.
- Envoy + Ambassador: Very high performance but complex.
Conclusion
A Node.js API Gateway is perfectly capable of handling moderate scale (10k-50k req/s) with proper design. Use http-proxy-middleware for simple routing, add JWT authentication, Redis-based rate limiting, and circuit breakers. For service discovery, integrate Consul or etcd. As traffic grows, scale gateways horizontally and consider moving to Nginx/Kong for the edge layer. Start simple — your services will thank you for the unified entry point.
Comments
Join the conversation — sign in to leave a comment.