How to Rate-Limit and Throttle API Calls in TypeScript (Node and Edge)
Oleksandr Zhuravlov
You need to rate-limit outbound API calls in TypeScript when an upstream caps how fast you may call it and you'd rather pace under that cap than bounce off it. The first thing to get right is that "rate-limit" is two different controls, and most homegrown limiters only implement one.
Rate is not concurrency
These two answer different questions, and conflating them is the most common bug in a hand-rolled limiter.
- Rate is how fast — calls per unit time. "Five per second" is a pace: roughly one call every 200ms. It governs the gap between successive requests, not how many are open at once.
- Concurrency is how many at once — the in-flight cap. "At most two" means a third call waits until one of the first two returns, no matter how quickly that happens.
They're orthogonal. An upstream that tolerates a steady stream but falls over on fifty simultaneous sockets wants a concurrency cap. One that counts requests in a window and returns 429 past the count wants a rate cap. Most real limits are both, and a limiter that only spaces calls in time will still open a hundred sockets the instant they're all due.
The naive limiter, and where it breaks
The first version everyone writes is a setTimeout between calls or a small promise queue:
let = 0;
async function <>(: () => <>): <> {
const = .(0, + 200 - .());
if ( > 0) await new (() => (, ));
= .();
return ();
}This holds one call site to roughly 5/s. It breaks the moment reality is bigger than one function:
- Multiple call sites. Two modules each keep their own
last, and the upstream sees the sum. Three of them at 5/s apiece is 15/s against a host that allows five — and you're back to meeting429s in production. - No concurrency cap. It spaces starts but never counts in-flight requests. Slow responses pile up, and you breach a concurrency limit the timer never modeled.
- Multiple processes. Scale to three instances behind a load balancer, or a dozen serverless isolates, and each keeps its own
lastin its own memory. Your real upstream rate is N times your cap, where N is however many copies happen to be running.
Every one of these is the same root issue: the budget lives in one variable in one place, and the limit it's protecting is shared somewhere wider.
Pace before you send, don't wait for the 429
There's a second axis underneath the mechanics: when you find the limit. The reactive approach is to fire as fast as your code produces calls, and when one returns 429, back off and honor Retry-After. It works as a backstop, but by the time the 429 lands you've already spent the round trip and earned a rejection. Pacing yourself under the limit before requests leave is the proactive approach — fewer wasted trips, no rejection latency. The full argument for why proactive beats reactive, and how the two compose, is in proactive throttling beats reacting to 429s.
The stitch way: declare the throttle as data
A stitch is one API call turned into a typed function, and its rate limit is a field on that declaration, not a queue you assemble around the call. You name the pace and the cap; the stitch holds you to them before any request leaves.
import { } from 'stitchapi';
const = ({
: 'https://api.example.com',
: '/search',
: { : '5/s', : 2, : 'host' },
});
const = await ({ : { : 'shoes' } }); // paced and boundedrate is a string like '5/s' — a minimum spacing between successive calls, not a token bucket that lets a burst through. concurrency caps simultaneous in-flight calls; set either on its own or both together. When a call has to wait its turn it isn't a silent stall — the stitch emits a progress event with phase throttled, so a queued call shows up on the event stream instead of looking like a hang.
pool is the answer to the multiple-call-sites problem. The default 'stitch' gives each stitch its own budget; 'host' pools one budget across every stitch hitting the same host — which is how a provider actually counts, since a rate limit is per account or per host, almost never per endpoint. Set pool: 'host' and three stitches against api.example.com draw from one 5/s budget no matter which one a caller reaches for. Host-scoped pooling works in-process with no extra setup.
Multiple processes: the distributed story
pool: 'host' shares a budget across stitches in one process; it does nothing across processes, because the counters live in memory. Run more than one instance against the same upstream budget and you're back to N times your cap. The fix isn't a different limiter — it's a different place to keep the counters. Attach a shared store to move them off-box, so every instance pointed at the same store draws from one budget:
import { , } from '@stitchapi/redis';
import from 'ioredis';
import { } from 'stitchapi';
const = ({
: 'https://api.example.com',
: { : '5/s', : 4, : 'host' },
: ((new (process.env.REDIS_URL))),
});The throttle declaration is unchanged from the single-process version — only store is new. The mechanics, which drivers give you an atomic increment (Redis and Deno KV do; Workers KV doesn't), and what that means at the edge are in distributed throttle and shared sessions.
Start with the limiter — reach for throttle when you brush the limit
The setTimeout version above is the honest floor. A handful of calls well under the cap, from one place, in one process, don't need anything more — the in-memory counter is correct, and adding a store would introduce a network hop to solve a problem you don't have.
Reach for throttle when you hit the first trigger:
- You start brushing the cap. Reactive
429handling works until volume approaches the ceiling; proactive pacing keeps you under it. That's the moment a declared rate is worth more than a caught error. - The budget is shared across call sites or processes. A second endpoint that draws from the same provider quota, or a second worker process, means the in-memory counter is silently undercounting.
pool: 'host'and astoreclose that gap — the counter that moves is the one the upstream sees. - You pair pacing with retry or auth. A
throttlealongsideretryon the same stitch is one declaration; threading both through a hand-rolled limiter wrappingfetchis two separate maintenance surfaces.
The line is whether the shared budget has grown past a single variable. While it hasn't, the limiter is fine. When it has — crossing that line is a field on the declaration, not a rewrite. The same call gains throttle applied by the engine rather than by convention.
If you're weighing this against the interceptor stack you'd otherwise hand-write around axios, axios alternatives in 2026 maps where a stitch fits.
Try it
npm install stitchapi@rcDeclare a throttle on a stitch, set pool: 'host' to share the budget the way the provider counts, and pace under the limit instead of bouncing off it. The full field list and pool semantics are in the Throttle guide.