A first-class, from-scratch, 2026 rate limit for Bun + Elysia. GCRA by default — the algorithm Stripe uses — with token bucket and sliding-window also bundled. IETF draft-09 RateLimit-* headers, IPv6 grouping by /64, X-Forwarded-For trust modes, BigInt nanosecond TAT, and a lazy-TTL LRU memory store with no background sweeper.
rateLimit() is an Elysia plugin — wire it with .use() and every request is gated before it reaches your handlers. The default key is the requester's IP; the default algorithm is GCRA.
TS
import { Elysia } from 'elysia';import { rateLimit, gcra } from '@absolutejs/rate-limit';new Elysia() .use(rateLimit({ // GCRA — the algorithm Stripe uses. Exact, O(1) memory per key, // no boundary effects. Burst of 5, sustained 10 req/s. algorithm: gcra({ requestsPerPeriod: 10, periodMs: 1000, burst: 5 }), // 'ip' uses extractIp() with the trustedProxies + ipv6Prefix below. key: 'ip', trustedProxies: 1, // honor one CDN/LB hop of X-Forwarded-For })) .get('/', () => 'ok') .listen(3000);
Over the cap, the plugin returns 429 with Retry-After + the IETF RateLimit-* headers. The remaining count stays current on the 200 responses too — so clients can throttle themselves before they get refused.
Pick GCRA unless you have a reason not to. It's exact, O(1) memory per key (one BigInt — the TAT), no boundary effects, no float drift. Token bucket and sliding-window are bundled for when you specifically want their semantics (burst-then-throttle or N-in-the-last-M-seconds, respectively). Stack any of them with combined.
TS
// GCRA — default. Exact, BigInt nanosecond TAT, no float drift.import { gcra } from '@absolutejs/rate-limit';gcra({ requestsPerPeriod: 100, periodMs: 60_000, burst: 20 });// Token bucket — classic. Allows refill-boundary bursts up to capacity.import { tokenBucket } from '@absolutejs/rate-limit';tokenBucket({ capacity: 100, refillPerSecond: 1.667 });// Sliding-window counter — approximation; intuitive on a status page.import { slidingWindow } from '@absolutejs/rate-limit';slidingWindow({ requestsPerPeriod: 100, periodMs: 60_000 });// Combined — passes only when every component passes. Stack limits:// 100 / minute per IP AND 10,000 / day per user-idimport { combined } from '@absolutejs/rate-limit';combined({ algorithms: [ gcra({ requestsPerPeriod: 100, periodMs: 60_000, burst: 20 }), gcra({ requestsPerPeriod: 10_000, periodMs: 86_400_000 }), ],});
GCRA — exact, BigInt nanosecond TAT, one number of state per key, no boundary effects. The default.
Token bucket — classic. Allows brief bursts at refill boundaries. Pick this when you want "fill the bucket, fire it all at once" semantics.
Sliding window — counter approximation. Easy to explain on a status page ("you have N requests left in the last M seconds").
Combined — composes any number of algorithms; passes only when every component passes. Stack a per-IP minute cap with a per-user-id daily cap in one plugin.
Heavy endpoints charge more than cheap ones with a cost function. The bucket goes into overdraft on a cost-N hit (you wait it off — same as Stripe's metered approach). Key derivation is a string preset ('ip' or 'authorization') or a function. skip bypasses the limit entirely; onAllow and onLimit are symmetric hooks for metrics + custom responses.
TS
new Elysia().use(rateLimit({ algorithm: gcra({ requestsPerPeriod: 100, periodMs: 60_000, burst: 20 }), // Per-route cost — heavy endpoints charge more. cost: (ctx) => { if (ctx.request.url.includes('/upload')) return 5; if (ctx.request.url.includes('/admin')) return 0; // free for admin return 1; }, // Custom key derivation — per-tenant, falling back to IP. key: (ctx) => ctx.request.headers.get('x-tenant') ?? extractIp({ connectionIp: ctx.server?.requestIP?.(ctx.request)?.address ?? null, headers: ctx.request.headers, trustedProxies: 1, }), // Skip for admin tokens (sync — async work belongs in key). skip: (ctx) => ctx.request.headers.get('authorization') === ADMIN, // Fire a billing event on every allowed request. onAllow: (_ctx, info) => meter.record({ type: 'handler', tenant: info.key, durationMs: 0, cpuMs: 0, ok: true, }), // Customize the 429 response. onLimit: (_ctx, info) => new Response(JSON.stringify({ ok: false, retryAfterSec: info.decision.retryAfterSec, }), { status: 429, headers: { 'Content-Type': 'application/json' } }),}));
The default header set is IETF draft-09 — a combined RateLimit header carrying limit, remaining, and reset together, plus RateLimit-Policy describing the policy. Set headers: 'legacy' for the older X-RateLimit-* form (GitHub circa 2014), 'both' for a transition period, or false to suppress everything except Retry-After.
BASH
# IETF draft-09 (default — headers: 'standard')RateLimit: limit=20, remaining=18, reset=15RateLimit-Policy: 100;w=60;burst=20Retry-After: 7 # only on 429# Legacy GitHub-style (headers: 'legacy')X-RateLimit-Limit: 20X-RateLimit-Remaining: 18X-RateLimit-Reset: 15Retry-After: 7# Or 'both' to emit both — useful during a transition.# 'false' suppresses everything except Retry-After.
Most rate-limit libraries either trust X-Forwarded-For blindly (spoofable) or ignore it entirely (broken behind any CDN). trustedProxies: N honors only the last N hops — anything to the left is attacker-supplied and ignored. 0 disables XFF; 1 is the right default behind a single CDN/LB.
IPv6 addresses are grouped by their /64 prefix by default — one user's RIR allocation gets one quota, not one quota per device. Configurable via ipv6Prefix. CDN headers (cf-connecting-ip, fly-client-ip, true-client-ip, x-real-ip) are honored when XFF is missing and a proxy is trusted.
TS
// trustedProxies — IPv6 /64 — CDN-awareimport { extractIp } from '@absolutejs/rate-limit/core';// 0: ignore X-Forwarded-For entirely. Use the raw connection IP only.extractIp({ trustedProxies: 0, ... });// 1: trust ONE proxy hop. Take XFF[length - 1] (the originator). This is// the right default behind ONE CDN. Anything to the left is attacker-set.extractIp({ trustedProxies: 1, ... });// 2: trust TWO hops (CDN + load balancer). Same idea, take XFF[length-2].extractIp({ trustedProxies: 2, ... });// IPv6 grouping: by default IPv6 is reduced to its /64 prefix — one user// allocation per RIR convention. 2001:db8::1 and 2001:db8::dead:beef both// hash to '2001:db8:0:0:0:0:0:0/64'. Configurable via ipv6Prefix.extractIp({ ipv6Prefix: 128, ... }); // disable grouping// Honors cf-connecting-ip, fly-client-ip, true-client-ip, x-real-ip when// XFF is missing and a proxy is trusted.
The algorithms + store are exported separately at @absolutejs/rate-limit/core with no Elysia dependency. Use them directly for WebSocket-message rate-limiting, queue-consumer throttling, AI-call quotas — anywhere you need exact rate enforcement without HTTP. Each algorithm exposes peek() for status-page read-only inspection and reset() for admin clears.
TS
// Use the algorithms directly outside HTTP — WebSocket message rate-limit,// queue consumer throttle, AI call quotas. Import from /core to skip the// Elysia dependency entirely.import { gcra, memoryStore } from '@absolutejs/rate-limit/core';const aiLimiter = gcra({ requestsPerPeriod: 60, periodMs: 60_000, burst: 10 });const store = memoryStore();async function callOpenAi(userId: string, prompt: string) { const decision = aiLimiter.check(store, userId, Date.now()); if (!decision.allowed) { throw new Error(`Slow down — retry in ${decision.retryAfterSec}s`); } return openai.chat.completions.create({ ... });}// peek() = read-only inspection (no token consumed).const remaining = aiLimiter.peek(store, userId, Date.now()).remaining;// reset() = admin clear.aiLimiter.reset(store, userId);
Continue toward an outcome
These playbooks show where this package fits, how to verify the combined system, and what changes before production.
First-class, from-scratch, 2026 rate limit for Bun + Elysia. GCRA by default (the algorithm Stripe uses — exact, O(1) memory per key, no boundary effects), IETF draft-09 RateLimit- headers, IPv6 /64 grouping, X-Forwarded-For trust modes, BigInt nanosecond TAT (no float drift), pluggable store (LRU in-memory bundled).
Why not just use the existing libraries
The leader in the Elysia ecosystem is rayriffy/elysia-rate-limit. It uses a token bucket over a Map with a setInterval cleanup. That's fine, but every choice in it is from 2020. The 2026 from-scratch answers:
Surface
Elysia plugin (@absolutejs/rate-limit)
Hardening checklist
Production guidance
Make every external boundary explicitPin the deployed @absolutejs/rate-limit version, replace example or memory-backed dependencies with durable implementations, bound external calls, protect credentials, and emit enough evidence to retry or recover safely.
Follow in order
Troubleshooting path
1
Trace from the first failed boundary
Reproduce the smallest canonical @absolutejs/rate-limit example, confirm the supported entry point and version in the API explorer, then inspect the first boundary that did not produce its documented result.