Throttling is the mechanism that keeps a high-frequency event from triggering an action on every occurrence. A scroll handler without throttle fires hundreds of times per second. An API client without throttle hits rate limits and gets blocked. The pattern is simple; knowing when to use it over debounce is the skill.
What changed in 2026
- AbortController is universally supported and is the standard way to cancel superseded fetch requests — pair it with debounce or throttle for search inputs.
- The Fetch API accepts signals natively — no library wrapper needed.
- Edge rate limiters (Cloudflare, Vercel) handle server-side throttle transparently; you still need client-side patterns for UX smoothness.
use-debounce and throttle-debounce packages are the standard React ecosystem choices — both tiny and typed.
Plain JavaScript throttle
function throttle(fn, interval) {
let lastCall = 0;
return function (...args) {
const now = Date.now();
if (now - lastCall >= interval) {
lastCall = now;
return fn.apply(this, args);
}
};
}
// Fires at most once every 200ms while scrolling
const onScroll = throttle(() => {
updateScrollIndicator(window.scrollY);
}, 200);
window.addEventListener("scroll", onScroll);
TypeScript version
function throttle<T extends (...args: unknown[]) => void>(
fn: T,
interval: number
): (...args: Parameters<T>) => void {
let lastCall = 0;
return (...args: Parameters<T>) => {
const now = Date.now();
if (now - lastCall >= interval) {
lastCall = now;
fn(...args);
}
};
}
React scroll handler with useRef
import { useRef, useEffect } from "react";
function ScrollTracker() {
const lastCallRef = useRef(0);
useEffect(() => {
const handleScroll = () => {
const now = Date.now();
if (now - lastCallRef.current >= 200) {
lastCallRef.current = now;
console.log("scroll Y:", window.scrollY);
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return null;
}
{ passive: true } tells the browser the handler will not call preventDefault — it can optimize scroll painting accordingly.
Throttling fetch calls with AbortController
When you need the most recent result and want to cancel stale requests:
let abortController: AbortController | null = null;
async function searchWithCancel(query: string): Promise<void> {
// Cancel any in-flight request
abortController?.abort();
abortController = new AbortController();
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: abortController.signal,
});
const data = await res.json();
renderResults(data);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
// Expected — previous request superseded
return;
}
throw err;
}
}
Combine with debounce so the API is only called after 300 ms of silence, and AbortController cleans up any overlap.
Handling 429 Too Many Requests
async function fetchWithRetry(
url: string,
maxRetries = 3
): Promise<Response> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const res = await fetch(url);
if (res.status === 429) {
const retryAfter = res.headers.get("Retry-After");
const waitMs = retryAfter ? parseInt(retryAfter) * 1000 : 1000 * 2 ** attempt;
await new Promise((r) => setTimeout(r, waitMs));
continue;
}
return res;
}
throw new Error(`Failed after ${maxRetries} retries`);
}
Always read Retry-After before deciding how long to wait — the server is telling you the exact back-off period.
Throttle vs debounce — which to use
| Scenario |
Pattern |
Why |
| Scroll position tracking |
Throttle (100–200 ms) |
Want periodic updates while scrolling |
| Search / autocomplete input |
Debounce (250–350 ms) |
Want the final value, not intermediate ones |
| Window resize handler |
Debounce (100–200 ms) |
Care about the end size, not every pixel |
| Analytics event (mousemove) |
Throttle (500 ms) |
Sample regularly, not every pixel |
| Auto-save form |
Debounce (800–1 500 ms) |
Save after typing stops |
| Button spam guard |
Leading debounce |
Fire once immediately, ignore follow-ups |
Server-side rate limiting basics (Node.js)
import rateLimit from "express-rate-limit";
const limiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 60, // 60 requests per minute per IP
standardHeaders: true, // include Retry-After header
legacyHeaders: false,
});
app.use("/api/", limiter);
express-rate-limit + rate-limit-redis backs the store in Redis, making it work across multiple Node processes. Without a shared store, each instance has its own counter and limits are per-process, not global.
Common mistakes
Throttling in the render body. Like debounce, creating a throttled function inside a React component recreates it on every render. Use useRef or a hook.
Using throttle for search inputs. Throttle fires periodically while typing, sending half-finished queries. Debounce is correct — fire only after the user stops.
No server-side enforcement. Client-side throttle is a UX optimization. A determined caller bypasses it instantly. Always enforce rate limits on the server.
Ignoring Retry-After. Retrying a 429 immediately triggers another 429. Read the header.
What to skip
- Throttle for form submission — use a leading debounce (fires once immediately) or a loading state to disable the button.
- Implementing throttle with
setInterval — polling is not the same as throttling; the semantics are different.
- Giant libraries for one utility —
throttle-debounce is 2 kB and covers both patterns without the lodash weight.
FAQ
Does throttle guarantee exactly one call per interval?
No. The simple implementation fires the first call in the interval and drops the rest. A "trailing" throttle also fires the last call after the interval; some libraries offer both modes.
How do I throttle GraphQL subscriptions?
Throttle the state update that triggers a re-render, not the subscription itself. The subscription receives all events; your handler updates state at most once per N ms.
What is the difference between throttle and a rate limiter?
Throttle is a time-based gate on a single caller. A rate limiter tracks calls per unit time across multiple callers (usually identified by IP or API key) and enforces a ceiling.
Can I use the Web API requestAnimationFrame instead of throttle for scroll?
Yes — rAF fires at the display refresh rate (~60–120 Hz), which is roughly 8–16 ms. For scroll animations this is better than a fixed interval; for analytics sampling a fixed throttle is more predictable.
Where to go next