Skip to main content

Command Palette

Search for a command to run...

Rate Limiter


title: How Rate Limiting Works in a Node.js URL Shortener (Deep Dive) published: false description: A thorough walkthrough of how express-rate-limit is used to protect a URL shortener API — covering dual limiters, proxy trust, in-memory storage trade-offs, and more. tags: node, express, webdev, security cover_image:

Building a public API without rate limiting is like leaving your front door wide open. Anyone can hammer your endpoints, create thousands of spam entries, or simply knock your server offline with a flood of requests. In this article, we'll tear apart the rate-limiting strategy used in a production-style Node.js URL shortener — examining every decision from config files to middleware ordering.


The Stack at a Glance

The project is a URL shortener built with:

  • Node.js + Express for the API
  • express-rate-limit v8.3.0+ for rate limiting
  • Redis for URL caching (not rate limiting — more on that later)
  • .env-based config for tunable limits per environment

Where the Values Come From

Good configuration is never hardcoded in the middleware itself. In this project, rate limit values flow through a clean three-step chain.

Step 1 — Environment Variables (.env)

RATE_LIMIT_WINDOW_MS=900000       # 15 minutes in milliseconds
RATE_LIMIT_MAX_REQUESTS=100       # max 100 requests per window

Keeping these in .env means you can tighten limits in production and relax them in development — without touching code.

Step 2 — src/config/env.js

app: {
  baseUrl: process.env.BASE_URL || 'http://localhost:3000',
  rateLimitWindowMs:    parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10)    || 900000,
  rateLimitMaxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) || 100
}

Two things to notice here:

  1. parseInt(..., 10) — env vars are always strings. Forgetting to parse them is a classic bug that produces NaN comparisons.
  2. Fallback defaults — if the env var is missing, the app still works sensibly. 900000 ms = 15 minutes and 100 requests are reasonable defaults.

This config object becomes the single source of truth for all app settings.

Step 3 — src/middleware/rateLimiter.js

const config = require('../config/env');

const apiLimiter = rateLimit({
  windowMs: config.app.rateLimitWindowMs,
  max:      config.app.rateLimitMaxRequests,
  // ...
});

The middleware consumes config — it never defines magic numbers itself. Clean.


Two Limiters, Two Purposes

One of the more thoughtful design choices in this project is having two separate rate limiters rather than one global one.

apiLimiter — General API Protection

const apiLimiter = rateLimit({
  windowMs: config.app.rateLimitWindowMs,    // 15 min (from env)
  max:      config.app.rateLimitMaxRequests, // 100 requests (from env)
  message: {
    success: false,
    error: 'Too many requests, please try again later'
  },
  standardHeaders: true,   // Sends RateLimit-* headers (RFC 6585)
  legacyHeaders: false,    // Suppresses old X-RateLimit-* headers
});
Setting Value Meaning
windowMs 900,000 ms 15-minute sliding window
max 100 Max 100 requests per window per IP
standardHeaders true Clients get RateLimit-Remaining, RateLimit-Reset etc.
legacyHeaders false Cleaner — no redundant X-RateLimit-* headers

Setting standardHeaders: true is important for API consumers. It lets them inspect headers and back off gracefully before hitting the limit.

createLimiter — Stricter Protection for URL Creation

const createLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, // 1 hour (hardcoded)
  max: 20,                  // 20 URL creations per hour per IP
  message: {
    success: false,
    error: 'Too many URLs created, please try again in an hour'
  },
  standardHeaders: true,
  legacyHeaders: false,
});
Setting Value Meaning
windowMs 3,600,000 ms 1-hour sliding window
max 20 Max 20 URL creations per hour per IP

Why a separate limiter for creation?

Creating short URLs is a write operation. Without a dedicated stricter limit, a bad actor could spend their entire 100-request general budget creating spam links. By adding an independent createLimiter, you enforce a hard cap of 20 new URLs per hour — regardless of how many other API calls that IP makes. The limits operate independently.


Wiring It All Together — Middleware Order Matters

This is where things get interesting. The order of middleware registration in src/app.js is not accidental:

// ① Trust proxy — must come first
app.set('trust proxy', 1);

// ② General rate limit on ALL /api/* routes
app.use('/api', apiLimiter, apiRoutes);

// ③ Additional stricter limit specifically on POST /api/shorten
app.post('/api/shorten', createLimiter);

// ④ Redirect routes — intentionally NO rate limiter
app.use('/', redirectRoutes);

The Double-Limiter on POST /api/shorten

Because apiLimiter is applied to all /api/* routes and createLimiter is registered specifically for POST /api/shorten, that single endpoint is protected by both limiters in sequence:

Incoming: POST /api/shorten
          │
          ▼
    apiLimiter
    (100 req / 15 min per IP)
          │ pass
          ▼
    createLimiter
    (20 req / 1 hour per IP)
          │ pass
          ▼
    Route handler runs

A request must clear both checks. This is a composable, layered approach — adding protection at creation without dismantling the general API guard.

Why Redirects Have No Rate Limiter

// Redirect routes (no rate limiting on redirects - we want them fast)
app.use('/', redirectRoutes);

This is a deliberate design decision and a good one. The /:shortCode → original URL redirect is the hot path — every click on a short link hits it. Adding a rate-limit middleware check here would introduce latency on every redirect. Since redirects are read-only and don't mutate state, the risk/benefit trade-off favors keeping them unrestricted.


The trust proxy Setting — Critical for Correctness

app.set('trust proxy', 1);

This tiny line is critical. Here's why.

express-rate-limit identifies clients by their IP address. By default, Express reads req.socket.remoteAddress. But in nearly any real deployment — behind Nginx, a load balancer, Docker networking, or a cloud provider's reverse proxy — that address is always the proxy's IP, not the real client's.

Setting trust proxy = 1 instructs Express to trust the X-Forwarded-For header injected by the first upstream proxy, so the rate limiter tracks the actual user's IP.

Without this:

  • Everyone hitting your API appears to come from the same IP address (the proxy)
  • All users share a single rate limit counter
  • The limiter becomes effectively useless

The 1 means "trust one proxy hop." If you have multiple proxy layers, adjust accordingly.


What Happens When the Limit Is Hit

When a client exceeds a limit, express-rate-limit short-circuits the middleware chain and immediately responds:

HTTP 429 Too Many Requests

{
  "success": false,
  "error": "Too many requests, please try again later"
}

Or, specifically for URL creation:

{
  "success": false,
  "error": "Too many URLs created, please try again in an hour"
}

Because standardHeaders: true is set, the response also includes:

RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: <unix timestamp>

Well-behaved clients can read these headers and implement exponential backoff or a cooldown timer.


Storage: In-Memory (and Its Trade-offs)

The default store for express-rate-limit is an in-memory Map inside the Node process:

{
  "203.0.113.42": { count: 47, resetTime: 1712519200000 },
  "198.51.100.1":  { count: 99, resetTime: 1712519200000 },
}

Pros:

  • Zero configuration
  • Zero external dependency
  • Near-zero latency

Cons:

  • State is lost on server restart — all counters reset
  • Not shared across multiple instances — if you run 3 Node processes behind a load balancer, a client can make 100 × 3 = 300 requests by hitting each instance

Note: This project has Redis configured in src/config/redis.js and redis in its dependencies — but Redis is used exclusively for URL caching, not rate limiting. To make rate limiting work correctly across multiple instances, you'd integrate a package like rate-limit-redis and pass it as the store option to rateLimit(). For a single-instance deployment, the in-memory default is perfectly fine.


Complete Request Flow

Here's the full picture from client request to response:

Client Request
      │
      ▼
trust proxy → Real IP extracted from X-Forwarded-For
      │
      ▼
Is path /api/* ?
  ├── YES → apiLimiter
  │           IP count < 100 in last 15 min? → pass
  │           IP count ≥ 100?               → HTTP 429
  │                │ (passed)
  │                ▼
  │          Is it POST /api/shorten?
  │            ├── YES → createLimiter
  │            │           IP count < 20 in last 1 hour? → pass
  │            │           IP count ≥ 20?                → HTTP 429
  │            │                │ (passed)
  │            │                ▼
  │            │           Route handler runs
  │            └── NO  → Route handler runs
  └── NO → Is path /:shortCode?
             └── No rate limiting → Redirect handler runs

Summary Table

Limiter Applied To Window Max Requests Storage Configurable?
apiLimiter All /api/* routes 15 min 100 In-memory ✅ via .env
createLimiter POST /api/shorten only 1 hour 20 In-memory ❌ hardcoded
(none) /:shortCode redirects Unlimited

Key Takeaways

  • Use environment variables for rate limit values so you can tune them per environment without code changes.
  • Don't rely on a single global limiter — write operations deserve their own stricter, independent limits.
  • trust proxy is not optional in any real deployment; without it, your limiter is measuring the wrong IP.
  • standardHeaders: true gives API clients the information they need to back off gracefully.
  • In-memory storage works for single-instance apps, but switch to a shared store (Redis) before scaling horizontally.
  • Deliberate omissions matter — choosing not to rate-limit redirects is a valid performance decision, not an oversight.

Have questions about rate limiting strategies or scaling beyond a single Node instance? Drop a comment below!