DEV Community

Cover image for Six frameworks. Four storage backends. One import. Zero dependencies.
Shayan M Hussain
Shayan M Hussain

Posted on • Originally published at hitlimit.jointops.dev

Six frameworks. Four storage backends. One import. Zero dependencies.

app.use(hitlimit({ limit: 100, window: '1m' }))
Enter fullscreen mode Exit fullscreen mode

Same API everywhere. Express, Fastify, Hono, NestJS, Bun.serve, Elysia. No adapters to install. No framework-specific config. Just one package and you are done.

Every Framework, Same Code

// Express
import { hitlimit } from '@joint-ops/hitlimit'
app.use(hitlimit({ limit: 100, window: '1m' }))

// Fastify
import { hitlimit } from '@joint-ops/hitlimit/fastify'
await app.register(hitlimit, { limit: 100, window: '1m' })

// Hono
import { hitlimit } from '@joint-ops/hitlimit/hono'
app.use(hitlimit({ limit: 100, window: '1m' }))

// NestJS
import { HitLimitModule, HitLimitGuard } from '@joint-ops/hitlimit/nest'
@Module({
  imports: [HitLimitModule.register({ limit: 100, window: '1m' })],
  providers: [{ provide: APP_GUARD, useClass: HitLimitGuard }]
})
export class AppModule {}

// Bun.serve
import { hitlimit } from '@joint-ops/hitlimit-bun'
Bun.serve({ fetch: hitlimit({ limit: 100, window: '1m' }, handler) })

// Elysia
import { hitlimit } from '@joint-ops/hitlimit-bun/elysia'
new Elysia().use(hitlimit({ limit: 100, window: '1m' })).listen(3000)
Enter fullscreen mode Exit fullscreen mode

Switch frameworks tomorrow. Your rate limiting code stays the same.

4 Storage Backends Built In

No extra packages. No separate installs. Pick the right store for your deployment and swap it in one line.

// Memory (default) - fastest, no setup
app.use(hitlimit({ limit: 100, window: '1m' }))

// SQLite - survives restarts
app.use(hitlimit({ store: sqliteStore({ path: './limits.db' }), ... }))

// Redis - distributed across instances
app.use(hitlimit({ store: redisStore({ url: process.env.REDIS_URL }), ... }))

// Postgres - use your existing database
app.use(hitlimit({ store: postgresStore({ pool }), ... }))
Enter fullscreen mode Exit fullscreen mode

Start with memory on day one. Move to Postgres or Redis when you scale. Nothing else changes.

New in v1.2.0: PostgreSQL Store

Most teams already have Postgres running. Why add Redis just for rate limiting?

import { postgresStore } from '@joint-ops/hitlimit/stores/postgres'
import { Pool } from 'pg'

const pool = new Pool({ connectionString: process.env.DATABASE_URL })

app.use(hitlimit({
  limit: 100,
  window: '1m',
  store: postgresStore({ pool })
}))
Enter fullscreen mode Exit fullscreen mode

One atomic query per request. Zero race conditions. Tables created automatically on first run. Named prepared statements for 30 to 40 percent lower latency. No new infrastructure to manage.

Fast

Node.js (10K unique IPs)
Memory       3.16M ops/s      316ns
SQLite       352K ops/s       2.8us
Redis        6.7K ops/s       149us
Postgres     3.0K ops/s       336us

Bun (10K unique IPs)
Memory       8.32M ops/s      120ns
bun:sqlite   325K ops/s       3.1us
Redis        6.7K ops/s       148us
Postgres     3.7K ops/s       273us
Enter fullscreen mode Exit fullscreen mode

Peak: 12.38M ops/s on Bun. 4.83M ops/s on Node.js.

All benchmarks are open source. Clone the repo and run them on your hardware.

More Than a Counter

Tiered limits for free, pro and enterprise plans.
Auto ban repeat offenders after N violations.
Group limits for per-team or per-org quotas.
Skip rules for health checks, admins, internal routes.
Human readable windows like '15m', '1h', '1d' instead of milliseconds.

All built in. All zero dependencies.

Get Started

npm install @joint-ops/hitlimit    # Node.js
bun add @joint-ops/hitlimit-bun    # Bun
Enter fullscreen mode Exit fullscreen mode

Docs | npm | Release notes

If it saves you time give it a star.

Top comments (0)