DEV Community

Cover image for Why I Stopped Writing 15 * 60 * 1000 in Every Project
Ch. Abdul Wahab
Ch. Abdul Wahab

Posted on

Why I Stopped Writing 15 * 60 * 1000 in Every Project

Let me be honest with you.

Every time I start a new Node.js project, I copy-paste this from my last one:

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,
});
Enter fullscreen mode Exit fullscreen mode

That 15 * 60 * 1000 has always bothered me. It's not a big deal. But it's also... not great? I have to do math in my head every time I read it. Is that 15 minutes? Let me check. 15 * 60 = 900. 900 * 1000 = 900000. Yes, 15 minutes.

Why am I doing mental arithmetic in 2026?

That small frustration was the start of @chabdulwahab/flowcap.


The actual problem

express-rate-limit is great — 8.6 million weekly downloads great. But it has two issues that quietly annoy me:

1. It's Express-only. If you switch to Fastify or Koa, you need a different package. fastify-rate-limit, koa-ratelimit — each with its own API, its own quirks, its own docs to read.

2. The window is in milliseconds. Every single time. You always end up writing 15 * 60 * 1000 and adding a comment to explain what it means. The comment is the tell — the code isn't readable without it.

rate-limiter-flexible solves the framework problem, but introduces a different one: complexity. It's powerful, but you're reading docs for 20 minutes before you write a single line.

I wanted something in between. Dead simple. Works anywhere. Zero deps. Human-readable.

That's flowcap.


What flowcap does differently

1. Human-readable time windows

// Before — what does this even mean at a glance?
windowMs: 15 * 60 * 1000

// After — obvious
window: '15m'
Enter fullscreen mode Exit fullscreen mode

Supported formats: '500ms', '30s', '15m', '2h', '1d'. Pass a number and it treats it as milliseconds — backwards compatible if you prefer.

2. Works on any framework

The middleware signature is just (req, res, next). That's the universal contract every Node.js framework follows. So flowcap works on all of them:

// Express
app.use(flowcap({ limit: 100, window: '1m' }));

// Fastify
fastify.addHook('onRequest', flowcap({ limit: 100, window: '1m' }));

// Koa
app.use((ctx, next) => flowcap({ limit: 100, window: '1m' })(ctx.req, ctx.res, next));

// Vanilla http
http.createServer((req, res) => {
  flowcap({ limit: 100, window: '1m' })(req, res, () => {
    // your handler
  });
});
Enter fullscreen mode Exit fullscreen mode

3. Built-in presets for common use cases

The most common rate limiting scenarios are always the same: protect your login endpoint, set a standard API limit, lock down sensitive admin routes. So I built presets:

// Brute-force protection on login (5 requests per 15 minutes)
app.post('/login', flowcap.login(), handler);

// Standard API (100 requests per minute)
app.use('/api', flowcap.api());

// Sensitive endpoint (20 per minute)
app.get('/admin', flowcap.strict(), handler);

// High-traffic public route (500 per minute)
app.get('/feed', flowcap.loose(), handler);
Enter fullscreen mode Exit fullscreen mode

Every preset is just a default config — you can override anything:

// Login preset, but stricter
app.post('/login', flowcap.login({ limit: 3 }), handler);
Enter fullscreen mode Exit fullscreen mode

4. IETF standard headers, automatically

Every response gets the RateLimit header from IETF draft-8:

RateLimit: limit=100, remaining=87, reset=1716300000
Enter fullscreen mode Exit fullscreen mode

Plus legacy headers for clients that expect them:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1716300000
Enter fullscreen mode Exit fullscreen mode

On 429 responses, Retry-After is set automatically so clients know exactly when to retry.


How it works under the hood

flowcap uses a sliding window log algorithm. Here's the core idea:

For each unique key (IP address by default), we store a log of request timestamps in memory:

key: "192.168.1.1"
timestamps: [1716300000, 1716300010, 1716300025, 1716300041]
Enter fullscreen mode Exit fullscreen mode

On every incoming request:

  1. Prune — remove timestamps older than now - windowMs
  2. Count — how many timestamps remain?
  3. Allow or block — if count >= limit, return 429. Otherwise, add current timestamp and call next()

This is more accurate than the fixed window algorithm (which express-rate-limit uses by default) because it doesn't have the "boundary burst" problem — where a user can make 2x limit requests by hammering the window boundary.

The store is a simple Map:

class Store {
  constructor() {
    this.records = new Map(); // key → number[]
  }

  prune(key, windowMs) {
    const cutoff = Date.now() - windowMs;
    const timestamps = this.records.get(key) || [];
    const fresh = timestamps.filter(t => t > cutoff);
    fresh.length === 0
      ? this.records.delete(key)
      : this.records.set(key, fresh);
    return fresh;
  }

  count(key, windowMs) {
    return this.prune(key, windowMs).length;
  }

  add(key, windowMs) {
    const fresh = this.prune(key, windowMs);
    fresh.push(Date.now());
    this.records.set(key, fresh);
  }
}
Enter fullscreen mode Exit fullscreen mode

Zero dependencies. Pure Node.js. The Map handles memory, pruning keeps it clean on every request — no separate cleanup timer needed.


The full API

flowcap({
  // Core
  limit: 100,          // max requests per window
  window: '1m',        // '30s', '15m', '2h', '1d', or ms number

  // Key extraction (default: IP address)
  keyBy: (req) => req.ip,

  // Skip certain requests entirely
  skip: (req) => req.path === '/health',

  // Custom 429 response
  onLimit: (req, res, next) => {
    res.status(429).json({ message: 'Slow down.' });
  },

  // Header control
  legacyHeaders: true,   // set false to disable X-RateLimit-* headers

  // Advanced: bring your own store
  store: myCustomStore,
})
Enter fullscreen mode Exit fullscreen mode

The keyBy option is where things get interesting. Instead of IP-based limiting, you can limit per user:

// Rate limit by authenticated user ID
app.use(flowcap({
  limit: 1000,
  window: '1h',
  keyBy: (req) => req.user?.id || req.ip,
}));

// Rate limit by API key
app.use(flowcap({
  limit: 500,
  window: '1m',
  keyBy: (req) => req.headers['x-api-key'] || req.ip,
}));
Enter fullscreen mode Exit fullscreen mode

Quick comparison

flowcap express-rate-limit rate-limiter-flexible
Framework agnostic ❌ Express only
Human-readable window '15m' 900000 ❌ ms only
Zero dependencies
Built-in presets
TypeScript types
Redis support ❌ v2
Algorithm Sliding window Fixed window Both
Setup time ~30 seconds ~2 minutes ~10 minutes

flowcap is intentionally not trying to replace rate-limiter-flexible for complex distributed use cases. If you need Redis, Mongo, or cluster support — use that. It's excellent. flowcap is for the 80% case: single-process apps that just need rate limiting to work without thinking about it.


Install and try it

npm install @chabdulwahab/flowcap
Enter fullscreen mode Exit fullscreen mode
const flowcap = require('@chabdulwahab/flowcap');

// That's it. Protect your whole API in one line.
app.use(flowcap({ limit: 100, window: '15m' }));
Enter fullscreen mode Exit fullscreen mode

The package is 3.7 KB on disk. No setup. No config files. No external services.


What's next

v1 is deliberately minimal — in-memory, single-process. The roadmap for v2:

  • Redis adapter for multi-instance deployments
  • Fastify plugin wrapper for native fastify.register() support
  • flowcap.byRoute() helper for different limits per route pattern

If you try it and something doesn't work the way you expect, open an issue. I built this because I wanted it to exist — feedback from real usage is what makes it actually good.


@chabdulwahab/flowcap on npm → npmjs.com/package/@chabdulwahab/flowcap

This is my third indie npm package — the others are @chabdulwahab/env-ok (zero-dep env validator) and @chabdulwahab/api-spy (terminal metrics dashboard). I build small, focused tools that solve one thing well. If that's your kind of software, follow along.

Top comments (0)