<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[URL-Shortener]]></title><description><![CDATA[URL-Shortener]]></description><link>https://url-shortener.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>URL-Shortener</title><link>https://url-shortener.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 14 Sep 2026 14:10:53 GMT</lastBuildDate><atom:link href="https://url-shortener.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How Rate Limiting Works in a Node.js URL Shortener (Deep Dive)]]></title><description><![CDATA[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 fl]]></description><link>https://url-shortener.hashnode.dev/how-rate-limiting-works-in-a-node-js-url-shortener-deep-dive</link><guid isPermaLink="true">https://url-shortener.hashnode.dev/how-rate-limiting-works-in-a-node-js-url-shortener-deep-dive</guid><category><![CDATA[node]]></category><category><![CDATA[Node.js]]></category><category><![CDATA[rate limiter api]]></category><category><![CDATA[Express]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Security]]></category><dc:creator><![CDATA[Niraj Maharjan]]></dc:creator><pubDate>Tue, 07 Apr 2026 15:38:48 GMT</pubDate><content:encoded><![CDATA[<p>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.</p>
<hr />
<h2>The Stack at a Glance</h2>
<p>The project is a URL shortener built with:</p>
<ul>
<li><strong>Node.js + Express</strong> for the API</li>
<li><strong><code>express-rate-limit</code> v8.3.0+</strong> for rate limiting</li>
<li><strong>Redis</strong> for URL caching (not rate limiting — more on that later)</li>
<li><strong><code>.env</code>-based config</strong> for tunable limits per environment</li>
</ul>
<hr />
<h2>Where the Values Come From</h2>
<p>Good configuration is never hardcoded in the middleware itself. In this project, rate limit values flow through a clean three-step chain.</p>
<h3>Step 1 — Environment Variables (<code>.env</code>)</h3>
<pre><code>RATE_LIMIT_WINDOW_MS=900000       # 15 minutes in milliseconds
RATE_LIMIT_MAX_REQUESTS=100       # max 100 requests per window
</code></pre>
<p>Keeping these in <code>.env</code> means you can tighten limits in production and relax them in development — without touching code.</p>
<h3>Step 2 — <code>src/config/env.js</code></h3>
<pre><code class="language-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
}
</code></pre>
<p>Two things to notice here:</p>
<ol>
<li><strong><code>parseInt(..., 10)</code></strong> — env vars are always strings. Forgetting to parse them is a classic bug that produces <code>NaN</code> comparisons.</li>
<li><strong>Fallback defaults</strong> — if the env var is missing, the app still works sensibly. <code>900000</code> ms = 15 minutes and <code>100</code> requests are reasonable defaults.</li>
</ol>
<p>This config object becomes the <strong>single source of truth</strong> for all app settings.</p>
<h3>Step 3 — <code>src/middleware/rateLimiter.js</code></h3>
<pre><code class="language-js">const config = require('../config/env');

const apiLimiter = rateLimit({
  windowMs: config.app.rateLimitWindowMs,
  max:      config.app.rateLimitMaxRequests,
  // ...
});
</code></pre>
<p>The middleware consumes config — it never defines magic numbers itself. Clean.</p>
<hr />
<h2>Two Limiters, Two Purposes</h2>
<p>One of the more thoughtful design choices in this project is having <strong>two separate rate limiters</strong> rather than one global one.</p>
<h3><code>apiLimiter</code> — General API Protection</h3>
<pre><code class="language-js">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
});
</code></pre>
<table>
<thead>
<tr>
<th>Setting</th>
<th>Value</th>
<th>Meaning</th>
</tr>
</thead>
<tbody><tr>
<td><code>windowMs</code></td>
<td>900,000 ms</td>
<td>15-minute sliding window</td>
</tr>
<tr>
<td><code>max</code></td>
<td>100</td>
<td>Max 100 requests per window per IP</td>
</tr>
<tr>
<td><code>standardHeaders</code></td>
<td><code>true</code></td>
<td>Clients get <code>RateLimit-Remaining</code>, <code>RateLimit-Reset</code> etc.</td>
</tr>
<tr>
<td><code>legacyHeaders</code></td>
<td><code>false</code></td>
<td>Cleaner — no redundant <code>X-RateLimit-*</code> headers</td>
</tr>
</tbody></table>
<p>Setting <code>standardHeaders: true</code> is important for API consumers. It lets them inspect headers and back off gracefully before hitting the limit.</p>
<h3><code>createLimiter</code> — Stricter Protection for URL Creation</h3>
<pre><code class="language-js">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,
});
</code></pre>
<table>
<thead>
<tr>
<th>Setting</th>
<th>Value</th>
<th>Meaning</th>
</tr>
</thead>
<tbody><tr>
<td><code>windowMs</code></td>
<td>3,600,000 ms</td>
<td>1-hour sliding window</td>
</tr>
<tr>
<td><code>max</code></td>
<td>20</td>
<td>Max 20 URL creations per hour per IP</td>
</tr>
</tbody></table>
<p><strong>Why a separate limiter for creation?</strong></p>
<p>Creating short URLs is a <em>write</em> operation. Without a dedicated stricter limit, a bad actor could spend their entire 100-request general budget creating spam links. By adding an independent <code>createLimiter</code>, 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.</p>
<hr />
<h2>Wiring It All Together — Middleware Order Matters</h2>
<p>This is where things get interesting. The order of middleware registration in <code>src/app.js</code> is not accidental:</p>
<pre><code class="language-js">// ① 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);
</code></pre>
<h3>The Double-Limiter on <code>POST /api/shorten</code></h3>
<p>Because <code>apiLimiter</code> is applied to all <code>/api/*</code> routes and <code>createLimiter</code> is registered specifically for <code>POST /api/shorten</code>, that single endpoint is protected by <strong>both limiters in sequence</strong>:</p>
<pre><code>Incoming: POST /api/shorten
          │
          ▼
    apiLimiter
    (100 req / 15 min per IP)
          │ pass
          ▼
    createLimiter
    (20 req / 1 hour per IP)
          │ pass
          ▼
    Route handler runs
</code></pre>
<p>A request must clear <strong>both</strong> checks. This is a composable, layered approach — adding protection at creation without dismantling the general API guard.</p>
<h3>Why Redirects Have No Rate Limiter</h3>
<pre><code class="language-js">// Redirect routes (no rate limiting on redirects - we want them fast)
app.use('/', redirectRoutes);
</code></pre>
<p>This is a deliberate design decision and a good one. The <code>/:shortCode → original URL</code> 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.</p>
<hr />
<h2>The <code>trust proxy</code> Setting — Critical for Correctness</h2>
<pre><code class="language-js">app.set('trust proxy', 1);
</code></pre>
<p>This tiny line is critical. Here's why.</p>
<p><code>express-rate-limit</code> identifies clients by their IP address. By default, Express reads <code>req.socket.remoteAddress</code>. 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 <strong>proxy's IP</strong>, not the real client's.</p>
<p>Setting <code>trust proxy = 1</code> instructs Express to trust the <code>X-Forwarded-For</code> header injected by the first upstream proxy, so the rate limiter tracks the <strong>actual user's IP</strong>.</p>
<p>Without this:</p>
<ul>
<li>Everyone hitting your API appears to come from the same IP address (the proxy)</li>
<li>All users share a single rate limit counter</li>
<li>The limiter becomes effectively useless</li>
</ul>
<p>The <code>1</code> means "trust one proxy hop." If you have multiple proxy layers, adjust accordingly.</p>
<hr />
<h2>What Happens When the Limit Is Hit</h2>
<p>When a client exceeds a limit, <code>express-rate-limit</code> short-circuits the middleware chain and immediately responds:</p>
<p><strong>HTTP 429 Too Many Requests</strong></p>
<pre><code class="language-json">{
  "success": false,
  "error": "Too many requests, please try again later"
}
</code></pre>
<p>Or, specifically for URL creation:</p>
<pre><code class="language-json">{
  "success": false,
  "error": "Too many URLs created, please try again in an hour"
}
</code></pre>
<p>Because <code>standardHeaders: true</code> is set, the response also includes:</p>
<pre><code>RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: &lt;unix timestamp&gt;
</code></pre>
<p>Well-behaved clients can read these headers and implement exponential backoff or a cooldown timer.</p>
<hr />
<h2>Storage: In-Memory (and Its Trade-offs)</h2>
<p>The default store for <code>express-rate-limit</code> is an <strong>in-memory <code>Map</code></strong> inside the Node process:</p>
<pre><code>{
  "203.0.113.42": { count: 47, resetTime: 1712519200000 },
  "198.51.100.1":  { count: 99, resetTime: 1712519200000 },
}
</code></pre>
<p><strong>Pros:</strong></p>
<ul>
<li>Zero configuration</li>
<li>Zero external dependency</li>
<li>Near-zero latency</li>
</ul>
<p><strong>Cons:</strong></p>
<ul>
<li>State is <strong>lost on server restart</strong> — all counters reset</li>
<li><strong>Not shared across multiple instances</strong> — if you run 3 Node processes behind a load balancer, a client can make 100 × 3 = 300 requests by hitting each instance</li>
</ul>
<blockquote>
<p><strong>Note:</strong> This project has Redis configured in <code>src/config/redis.js</code> and <code>redis</code> in its dependencies — but Redis is used exclusively for <strong>URL caching</strong>, not rate limiting. To make rate limiting work correctly across multiple instances, you'd integrate a package like <a href="https://www.npmjs.com/package/rate-limit-redis"><code>rate-limit-redis</code></a> and pass it as the <code>store</code> option to <code>rateLimit()</code>. For a single-instance deployment, the in-memory default is perfectly fine.</p>
</blockquote>
<hr />
<h2>Complete Request Flow</h2>
<p>Here's the full picture from client request to response:</p>
<pre><code>Client Request
      │
      ▼
trust proxy → Real IP extracted from X-Forwarded-For
      │
      ▼
Is path /api/* ?
  ├── YES → apiLimiter
  │           IP count &lt; 100 in last 15 min? → pass
  │           IP count ≥ 100?               → HTTP 429
  │                │ (passed)
  │                ▼
  │          Is it POST /api/shorten?
  │            ├── YES → createLimiter
  │            │           IP count &lt; 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
</code></pre>
<hr />
<h2>Summary Table</h2>
<table>
<thead>
<tr>
<th>Limiter</th>
<th>Applied To</th>
<th>Window</th>
<th>Max Requests</th>
<th>Storage</th>
<th>Configurable?</th>
</tr>
</thead>
<tbody><tr>
<td><code>apiLimiter</code></td>
<td>All <code>/api/*</code> routes</td>
<td>15 min</td>
<td>100</td>
<td>In-memory</td>
<td>✅ via <code>.env</code></td>
</tr>
<tr>
<td><code>createLimiter</code></td>
<td><code>POST /api/shorten</code> only</td>
<td>1 hour</td>
<td>20</td>
<td>In-memory</td>
<td>❌ hardcoded</td>
</tr>
<tr>
<td><em>(none)</em></td>
<td><code>/:shortCode</code> redirects</td>
<td>—</td>
<td>Unlimited</td>
<td>—</td>
<td>—</td>
</tr>
</tbody></table>
<hr />
<h2>Key Takeaways</h2>
<ul>
<li><strong>Use environment variables for rate limit values</strong> so you can tune them per environment without code changes.</li>
<li><strong>Don't rely on a single global limiter</strong> — write operations deserve their own stricter, independent limits.</li>
<li><strong><code>trust proxy</code> is not optional</strong> in any real deployment; without it, your limiter is measuring the wrong IP.</li>
<li><strong><code>standardHeaders: true</code></strong> gives API clients the information they need to back off gracefully.</li>
<li><strong>In-memory storage works for single-instance apps</strong>, but switch to a shared store (Redis) before scaling horizontally.</li>
<li><strong>Deliberate omissions matter</strong> — choosing <em>not</em> to rate-limit redirects is a valid performance decision, not an oversight.</li>
</ul>
<hr />
<p><em>Have questions about rate limiting strategies or scaling beyond a single Node instance? Drop a comment below!</em></p>
]]></content:encoded></item></channel></rss>