A production-style API Gateway built with Node.js and Express, demonstrating core distributed systems patterns: reverse proxying, JWT authentication, request validation, rate limiting, Redis caching, round-robin load balancing, retry logic, health checks, and observability — all fronting a set of dummy backend microservices.
Built as a hands-on systems design project to go deep on the concepts that come up in backend/infrastructure interviews, rather than another CRUD tutorial project.
Most backend portfolio projects are CRUD apps with a database. This one is different: it's infrastructure. It sits in front of other services and solves problems every real distributed system has to solve — how do you authenticate once instead of in every service? How do you protect backends from being overwhelmed? How do you keep serving traffic when one instance goes down? How do you even know if your system is healthy?
PulseGateway answers each of these with a real, working implementation — not a diagram, actual code you can run.
┌─────────────────────────────┐
│ CLIENT │
└──────────────┬───────────────┘
│ HTTP request
▼
┌─────────────────────────────┐
│ PULSEGATEWAY │
│ │
│ 1. Metrics (timing starts) │
│ 2. Auth Middleware (JWT) │
│ 3. Rate Limiter (Redis) │
│ 4. Cache Check (Redis) │
│ │ cache miss │
│ ▼ │
│ 5. Load Balancer (RR) │
│ 6. Retry Layer (on failure) │
│ 7. Proxy Router │
│ 8. Health Checker (bg loop) │
└──────┬───────────────┬────────┘
│ │
┌─────────▼──────┐ ┌───────▼─────────┐
│ User Service │ │ Product Service │
│ (2 instances) │ │ (1 instance) │
└─────────────────┘ └─────────────────┘
Request flow: Client → Metrics timer starts → JWT verified → Rate limit checked (per-user via Redis) → Cache checked (Redis) → on miss: Load balancer picks a healthy backend instance (round-robin) → Proxy forwards the request → on failure: automatic retry against a different instance (GET only) → response cached and returned → Metrics recorded on completion.
Background process: A health checker pings every backend instance every 10 seconds, marking instances healthy/unhealthy so the load balancer never routes to a known-dead instance.
| Feature | What it does |
|---|---|
| Reverse Proxy | Routes /api/users and /api/products to their respective backend services |
| JWT Authentication | Verifies a signed token on every request before it reaches any backend |
| Zod Validation | Schema-validates request bodies, rejecting malformed data before it wastes a backend call |
| Rate Limiting | Redis-backed, per-authenticated-user, fixed-window (10 req/60s), fails open if Redis is down |
| Redis Caching | Caches successful GET responses for 30s, cutting backend load and latency dramatically |
| Round-Robin Load Balancing | Distributes traffic across multiple instances of the same service |
| Retry Logic | Automatically retries failed GET requests against a different healthy instance |
| Health Checks | Background polling marks unreachable instances unhealthy so they're skipped by the load balancer |
| Metrics Endpoint | /metrics exposes request counts, avg response time, cache hit ratio, status code breakdown |
| Live Dashboard | /dashboard.html — auto-refreshing visual view of gateway performance |
| Dockerized | Full system (gateway + 2 backend service instances + product service + Redis) runs with one command |
- Runtime: Node.js, Express
- Proxying: http-proxy-middleware
- Caching / Rate Limiting: Redis (ioredis)
- Auth: jsonwebtoken
- Validation: Zod
- Retry HTTP calls: axios
- Containerization: Docker, Docker Compose
docker compose up --buildThis starts Redis, the gateway, both User Service instances, and the Product Service together, wired via Docker's internal networking.
Requires Redis running locally (redis-server or Docker: docker run -d -p 6379:6379 redis:7-alpine).
npm install
# In separate terminals:
npm run user-service # port 4001
npm run user-service-2 # port 4003
npm run product-service # port 4002
npm start # gateway, port 3000node src/utils/generateToken.jscurl http://localhost:3000/api/users -H "Authorization: Bearer <token>"
curl http://localhost:3000/metricsThen open http://localhost:3000/dashboard.html in a browser to see live metrics.
pulsegateway/
├── src/
│ ├── server.js # entry point, middleware chain assembly
│ ├── config/
│ │ ├── services.js # backend service registry
│ │ └── redisClient.js
│ ├── middleware/
│ │ ├── auth.js # JWT verification
│ │ ├── validate.js # Zod schema validation
│ │ ├── rateLimiter.js # Redis-backed rate limiting
│ │ └── metrics.js # request metrics collection
│ ├── gateway/
│ │ ├── proxyRouter.js # reverse proxy, caching, retry logic
│ │ ├── loadBalancer.js # round-robin instance selection
│ │ └── healthChecker.js # background instance health polling
│ ├── routes/
│ │ └── metricsRoute.js # /metrics and /health endpoints
│ ├── schemas/
│ │ └── userSchemas.js
│ └── utils/
│ └── generateToken.js # dev helper to mint test JWTs
├── services/ # dummy backend microservices
│ ├── user-service/
│ ├── user-service-2/
│ └── product-service/
├── public/
│ └── dashboard.html # live metrics dashboard
├── Dockerfile
├── docker-compose.yml
└── .env
- Metrics are stored in-memory and reset on restart — in production this would be Prometheus + Grafana.
- Rate limiting uses a fixed window, not sliding window or token bucket — a deliberate simplicity tradeoff.
- Retry logic only handles GET requests, by design — retrying non-idempotent methods risks duplicate side effects.
depends_onin Docker Compose controls start order, not readiness — mitigated by the app's fail-open Redis handling.
These aren't oversights — they're documented, deliberate scope decisions appropriate for a focused portfolio project, with the production-grade alternative noted for each.
Built by Ruturaj Pawar as a systems-design-focused portfolio project.