Rate limiting
debounce and throttle are two small, framework-agnostic functions
that control how often a callback actually runs, each solving a
different shape of that problem (see "Choosing between them" below).
They matter most paired with a field that has its own loading state,
like eun-searcheun-selecteunchange event.
npm install @eunomia/elements
import { debounce, throttle } from "@eunomia/elements";
Debounce
Delays calling fn until delay milliseconds have passed since the last
call, and every call resets the timer. The classic use is a live search
field: fire one request after the user pauses typing, instead of one per
keystroke.
function debounce<T extends (...args: any[]) => any>(
fn: T,
delay?: number, // default 400
): (...args: Parameters<T>) => void;
| Parameter | Type | Default | Description |
|---|---|---|---|
fn |
Function |
— | The function to delay |
delay |
number |
400 |
Milliseconds to wait after the last call before it fires |
Type in the field below and watch the timer bar: every keystroke snaps it back to empty and restarts it, so it only ever completes, and the call actually fires, once you pause for a full 400ms.
debounce must be called exactly once per field, not rebuilt on every
render: a fresh call creates a fresh timer, so recreating it on every
render resets that timer just as often, and the debounce never
actually gets to complete. Pick a framework in the toolbar above to see
where that "created once" guarantee comes from in each one:
import { debounce } from "@eunomia/elements";
const field = document.querySelector("eun-input");
const runSearch = debounce((query: string) => {
fetchResults(query);
}, 400);
field.addEventListener("eunchange", () => runSearch(field.value));
import { useEffect, useMemo, useRef } from "react";
import { debounce } from "@eunomia/elements";
import type { EunomiaInput } from "@eunomia/elements";
function SearchField() {
const fieldRef = useRef<EunomiaInput>(null);
const runSearch = useMemo(
() => debounce((query: string) => fetchResults(query), 400),
[],
);
useEffect(() => {
const field = fieldRef.current;
const handleChange = () => runSearch(field?.value ?? "");
field?.addEventListener("eunchange", handleChange);
return () => field?.removeEventListener("eunchange", handleChange);
}, [runSearch]);
return <eun-input ref={fieldRef} />;
}
<script setup>
import { debounce } from "@eunomia/elements";
const runSearch = debounce((query) => fetchResults(query), 400);
</script>
<template>
<eun-input @eunchange="runSearch($event.target.value)" />
</template>
import { Component } from "@angular/core";
import { debounce } from "@eunomia/elements";
@Component({
selector: "app-search-field",
template: `<eun-input (eunchange)="runSearch($event.target.value)" />`,
})
export class SearchFieldComponent {
private runSearch = debounce(
(query: string) => this.fetchResults(query),
400,
);
private fetchResults(query: string) {
/* ... */
}
}
See eun-searchloading state and a modal results panel.
Throttle
Runs fn at most once every time milliseconds, no matter how often
it's called, since unlike debounce, it doesn't wait for a pause. Suited
to a continuous stream of events with no natural "settled" moment:
scroll, resize, drag, mousemove.
function throttle<T extends (...args: any[]) => any>(
fn: T,
time?: number, // default 400
options?: { leading?: boolean },
): (...args: Parameters<T>) => void;
| Parameter | Type | Default | Description |
|---|---|---|---|
fn |
Function |
— | The function to rate-limit |
time |
number |
400 |
Minimum delay, in ms, between two calls |
options.leading |
boolean |
false |
Call fn immediately on the first call, instead of only on trailing edge |
Click rapidly and watch the cooldown bar: it starts full, empties the instant a click actually fires, then refills over the throttle window, gating every rapid click underneath it until it's full again.
The same "created exactly once" rule from Debounce above applies here too, plus one more thing to get right in a component: attaching and detaching the listener itself, so it doesn't pile up a new one on every render:
import { throttle } from "@eunomia/elements";
const runOnScroll = throttle(() => {
updateScrollProgress();
}, 200);
window.addEventListener("scroll", runOnScroll);
import { useEffect, useMemo } from "react";
import { throttle } from "@eunomia/elements";
function ScrollProgress() {
const runOnScroll = useMemo(
() => throttle(() => updateScrollProgress(), 200),
[],
);
useEffect(() => {
window.addEventListener("scroll", runOnScroll);
return () => window.removeEventListener("scroll", runOnScroll);
}, [runOnScroll]);
return null;
}
<script setup>
import { onMounted, onUnmounted } from "vue";
import { throttle } from "@eunomia/elements";
const runOnScroll = throttle(() => updateScrollProgress(), 200);
onMounted(() => window.addEventListener("scroll", runOnScroll));
onUnmounted(() => window.removeEventListener("scroll", runOnScroll));
</script>
import { Component, HostListener } from "@angular/core";
import { throttle } from "@eunomia/elements";
@Component({
selector: "app-scroll-progress",
template: ``,
})
export class ScrollProgressComponent {
private runOnScroll = throttle(() => this.updateScrollProgress(), 200);
@HostListener("window:scroll")
onScroll() {
this.runOnScroll();
}
private updateScrollProgress() {
/* ... */
}
}
Choosing between them
Neither is "better": they solve different shapes of problem. The question is whether the events you're reacting to have a natural pause to wait for.
| Reach for… | When… | Typical case |
|---|---|---|
| Debounce | The events have a natural "done, for now" moment worth waiting for | Live search/autocomplete, validate-while-typing, auto-save a draft |
| Throttle | The events are continuous, with no pause to wait for | Scroll, resize, mousemove, drag handlers |
Both default to it, but tune the delay against how fast your own event fires and how expensive the call behind it actually is.
See also
Markdown-lite : the lightweight formatting syntax, this page's siblingSearch : a real debounced search field, wired to a loading state and a results panel