@erox/rate-limiter
v0.2.0
Published
Handles Discord API rate limits so you don't have to think about it
Maintainers
Readme
@discord-toolkit/rate-limiter
Handles Discord's rate limits automatically. Wraps your requests, tracks the buckets, queues stuff when it needs to, retries on 429s. You don't have to think about any of it.
Current with Discord API v10 rate limit behavior as of mid-2026,
including X-RateLimit-Scope, the shared-bucket 429 exemption, and the
invalid-request ban threshold.
Why
Discord splits rate limits per-route (via the X-RateLimit-Bucket
header) and also has a global cap on top of that (50 req/sec by
default). Most people either ignore this until they get hit with 429s
in production, or they write some half-working delay logic that breaks
the moment they add a second route. This handles both layers properly.
Install
npm install @discord-toolkit/rate-limiterQuick start
const { RateLimitManager } = require('@discord-toolkit/rate-limiter');
const limiter = new RateLimitManager();
async function sendMessage(channelId, content) {
const routeKey = `POST /channels/${channelId}/messages`;
return limiter.schedule(routeKey, () =>
fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
method: 'POST',
headers: {
Authorization: `Bot ${TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ content }),
})
);
}Call sendMessage as many times as you want, from wherever — it queues
and paces itself against whatever Discord tells it in the response
headers. That's the whole pitch.
Beginner guide: what's actually going on
If you're new to rate limiting on Discord, here's the short version:
- Every time you hit an endpoint (send a message, edit a role, whatever), Discord's response includes headers telling you how many more requests you can make before you get cut off, and when that count resets.
- If you ignore those headers and just fire requests as fast as your
code can go, eventually you'll get a
429 Too Many Requestsback. Do that too often and Discord can temporarily ban your bot from the API entirely (a "Cloudflare ban"), which is much worse than a slow bot. - There isn't just one limit — there's a limit per route (e.g. sending messages in one channel doesn't affect your ability to edit roles) and a global limit across everything combined.
This library reads those headers for you after every request and holds
back future requests to the same bucket until it's safe, instead of you
having to write setTimeout guesses everywhere.
You don't need to understand buckets deeply to use this — just wrap your
requests in limiter.schedule(...) like the example above and it's handled.
Read on if you want the details.
API reference
new RateLimitManager(options)
Creates a manager. You'll usually want exactly one of these per bot, shared across all your commands/handlers.
| option | default | what it does |
|---|---|---|
| globalLimit | 50 | requests/sec allowed across every route combined |
| maxRetries | 3 | how many times a 429 gets retried before the call throws |
| onRateLimit | undefined | (info) => void, called every time a 429 is hit |
| onInvalidRequestWarning | undefined | (count) => void, called once the invalid-request count crosses invalidRequestWarningThreshold in a 10-minute window |
| invalidRequestWarningThreshold | 8000 | how many 401/403/non-shared-429 responses in 10 minutes before onInvalidRequestWarning fires (Discord's own cutoff is 10,000) |
const limiter = new RateLimitManager({
globalLimit: 50,
maxRetries: 5,
onRateLimit: (info) => {
console.warn(`rate limited on ${info.routeKey}, retrying in ${info.retryAfterMs}ms`);
},
onInvalidRequestWarning: (count) => {
console.error(`hit ${count} invalid requests in the last 10 minutes - approaching an IP ban`);
},
});onRateLimit payload
{
routeKey: 'POST /channels/123/messages',
global: false, // whether this was the global limit or a per-route one
scope: 'user', // 'user' | 'shared' | null, from X-RateLimit-Scope
retryAfterMs: 1200,
attempt: 1, // which retry attempt this is
}scope: 'shared' means the 429 came from contention on a resource other
apps also hit (common on things like reaction endpoints), not from your
own bot overusing it - these don't count toward the invalid-request
tracker since they're not really "your" mistake.
limiter.schedule(routeKey, requestFn)
The main method. Queues requestFn behind whatever rate limit state
exists for routeKey, waits if needed, runs it, reads the response
headers, and retries automatically on 429.
routeKey— a string identifying the endpoint. UseMETHOD pathwith actual IDs, e.g."POST /channels/123/messages". Different IDs for the same route type should get different keys so they don't block each other unnecessarily (they'll get merged automatically if Discord says they share a bucket anyway).requestFn— a function() => Promise<Response>that actually performs the request. Must return something fetch-shaped: aResponseobject with.headers.get(name),.json(), and.clone(). If you're usingnode-fetch, undici, or the built-in globalfetch, you're fine. Axios users: wrap the response so it matches this shape, or usefetchinstead for the parts that go through the limiter.
Returns whatever requestFn resolves to (the Response), after any
needed retries.
Throws if maxRetries is exceeded on repeated 429s.
limiter.buckets
A Map of bucket id → Bucket instance, in case you want to inspect
state directly (mostly useful for debugging/logging).
limiter.global
The shared GlobalBucket instance tracking the overall request cap.
Which action maps to which route key
This library doesn't hardcode Discord's routes for you — you pass the route key yourself, since it's transport-agnostic (works with any HTTP client). Here's a cheat sheet for common actions so you're not guessing the format:
| action | route key example |
|---|---|
| send message | POST /channels/{channel.id}/messages |
| edit message | PATCH /channels/{channel.id}/messages/{message.id} |
| delete message | DELETE /channels/{channel.id}/messages/{message.id} |
| add reaction | PUT /channels/{channel.id}/messages/{message.id}/reactions/{emoji}/@me |
| create channel | POST /guilds/{guild.id}/channels |
| edit channel | PATCH /channels/{channel.id} |
| ban member | PUT /guilds/{guild.id}/bans/{user.id} |
| kick member | DELETE /guilds/{guild.id}/members/{user.id} |
| edit role | PATCH /guilds/{guild.id}/roles/{role.id} |
| respond to interaction | POST /interactions/{interaction.id}/{interaction.token}/callback |
| edit interaction reply | PATCH /webhooks/{application.id}/{interaction.token}/messages/@original |
The exact string doesn't have to match Discord's docs word for word — what matters is that requests to the same actual endpoint with the same major params use the same route key, so they queue together correctly. Discord's own bucket id (returned in headers) gets merged in automatically, so even if your key naming is a little off, correctness won't break — you just might get slightly less optimal queuing until the real bucket id kicks in after the first request.
How retries work
When a request comes back 429:
- The response body is read for
retry_after(seconds) and whether it's agloballimit or scoped to this bucket. - If global, the shared
GlobalBucketgets locked for that duration — every route pauses. - If scoped, only that specific bucket gets locked.
- The request is automatically re-queued and retried.
- If it 429s again more than
maxRetriestimes in a row, it throws instead of retrying forever.
You don't need to catch 429s yourself in normal use — just be ready to
catch the eventual error if maxRetries is exceeded (usually means
something's wrong, like clock drift or way too much concurrent traffic).
Common mistakes
- Using a new route key with the ID baked in wrong.
POST /channels/123/messagesandPOST /channels/456/messagesare different buckets — that's correct, don't try to collapse them into one key. - Not sharing one
RateLimitManagerinstance. If you create a new one per command, none of them know about each other's rate limit state. Create one at startup and pass it around (or use a module-level singleton). - Wrapping something that isn't fetch-shaped. If
requestFndoesn't return a realResponse-like object, header reading will throw. Check your HTTP client's return shape first.
The invalid-request ban (and how this protects you)
Separately from per-route and global rate limits, Discord tracks 401/403/429 responses per IP over a rolling 10-minute window and temporarily bans the IP once that count crosses roughly 10,000. This is easy to trip accidentally: a bug that keeps hitting a 403 in a loop, or overly aggressive retries, will get you there fast.
This library tracks that count internally (excluding scope: 'shared'
429s, which aren't your bot's fault) and calls onInvalidRequestWarning
once you cross invalidRequestWarningThreshold (default 8,000), giving
you headroom to fix whatever's looping before you actually get banned.
Notes
- This doesn't make the actual HTTP calls for you — it just paces them. Bring your own client.
- Works with any Discord API wrapper, or raw
fetch, since it doesn't care what's insiderequestFnbeyond the response shape.
Changelog
0.2.0
- Reads
X-RateLimit-Scopeand skips countingshared-scoped 429s against the invalid-request tracker. - Falls back to the
Retry-Afterheader when a 429 response has no usable JSON body. - Added
onRateLimitandonInvalidRequestWarninghooks. - Fixed a deadlock where a retried request could get stuck forever if the retry was scheduled while the same bucket's queue was still awaiting the original attempt.
0.1.0
- Initial release: per-route bucket tracking, global bucket, automatic 429 retry.
