npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@erox/rate-limiter

v0.2.0

Published

Handles Discord API rate limits so you don't have to think about it

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-limiter

Quick 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:

  1. 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.
  2. If you ignore those headers and just fire requests as fast as your code can go, eventually you'll get a 429 Too Many Requests back. 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.
  3. 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. Use METHOD path with 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: a Response object with .headers.get(name), .json(), and .clone(). If you're using node-fetch, undici, or the built-in global fetch, you're fine. Axios users: wrap the response so it matches this shape, or use fetch instead 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:

  1. The response body is read for retry_after (seconds) and whether it's a global limit or scoped to this bucket.
  2. If global, the shared GlobalBucket gets locked for that duration — every route pauses.
  3. If scoped, only that specific bucket gets locked.
  4. The request is automatically re-queued and retried.
  5. If it 429s again more than maxRetries times 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/messages and POST /channels/456/messages are different buckets — that's correct, don't try to collapse them into one key.
  • Not sharing one RateLimitManager instance. 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 requestFn doesn't return a real Response-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 inside requestFn beyond the response shape.

Changelog

0.2.0

  • Reads X-RateLimit-Scope and skips counting shared-scoped 429s against the invalid-request tracker.
  • Falls back to the Retry-After header when a 429 response has no usable JSON body.
  • Added onRateLimit and onInvalidRequestWarning hooks.
  • 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.