Debouncing und Throttling: Hochfrequente Events in JavaScript zähmen
Früher oder später läuft jeder Frontend-Entwickler gegen dieselbe Wand: ein Scroll-Handler, der hunderte Male pro Sekunde feuert, ein Suchfeld, das bei jedem Tastendruck eine Netzwerkanfrage auslöst, oder ein Resize-Listener, der das Layout so oft neu berechnet, dass die Seite zu ruckeln beginnt. Debouncing und Throttling sind die beiden Techniken, die das beheben, und sobald man den Unterschied kennt, wird die Wahl der richtigen automatisch.
Das Problem: Events feuern schneller, als du sie verarbeiten kannst
Browser drosseln DOM-Events nicht für dich. scroll, resize, mousemove und input können alle dutzende Male pro Sekunde feuern. Tut der daran angehängte Handler etwas Nichttriviales – einen Fetch, eine DOM-Messung, ein Re-Rendering –, erledigt man diese teure Arbeit weit öfter, als der Nutzer sie tatsächlich braucht.
window.addEventListener('resize', () => {
recalculateLayout(); // runs on every single pixel of resize
});
Debouncing und Throttling reduzieren beide, wie oft der Handler tatsächlich läuft – sie tun es nur mit unterschiedlichen Garantien.
Debounce: auf die Stille warten
Debouncing verzögert die Ausführung, bis die Events für einen bestimmten Zeitraum aufhören. Jeder neue Aufruf setzt den Timer zurück. Es ist das richtige Werkzeug, wenn nur der Endzustand nach einer Aktivitätsphase zählt – der Wert in einem Suchfeld, sobald der Nutzer aufhört zu tippen, nicht jeder Zwischenanschlag.
function debounce(fn, delayMs) {
let timeoutId;
return function debounced(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), delayMs);
};
}
Verwendung: ein Suchfeld mit Suche beim Tippen, das nicht bei jedem Tastendruck das Netzwerk belasten soll.
const searchInput = document.querySelector('#search');
const runSearch = debounce((event) => {
fetchResults(event.target.value);
}, 300);
searchInput.addEventListener('input', runSearch);
Tippt man „angular", feuert fetchResults einmal, 300 ms nach dem letzten Tastendruck – nicht siebenmal.
Throttle: einen gleichmäßigen Rhythmus garantieren
Throttling begrenzt die Ausführung auf höchstens einmal pro Intervall, egal wie viele Events in diesem Fenster eintreffen. Es ist das richtige Werkzeug für kontinuierliche Ströme, bei denen man durchgehend periodische Updates braucht, nicht nur am Ende – ein Scroll-Positions-Tracker, ein Drag-Handler, ein von mousemove gesteuerter Cursor-Effekt.
function throttle(fn, intervalMs) {
let lastCall = 0;
let timeoutId = null;
return function throttled(...args) {
const now = Date.now();
const remaining = intervalMs - (now - lastCall);
if (remaining <= 0) {
clearTimeout(timeoutId);
timeoutId = null;
lastCall = now;
fn.apply(this, args);
} else if (!timeoutId) {
timeoutId = setTimeout(() => {
lastCall = Date.now();
timeoutId = null;
fn.apply(this, args);
}, remaining);
}
};
}
Verwendung: Layout beim Resize neu berechnen, höchstens alle 200 ms.
window.addEventListener(
'resize',
throttle(() => {
recalculateLayout();
}, 200),
);
Beachte den else if (!timeoutId)-Zweig: Er plant einen abschließenden Aufruf, damit das letzte Resize-Event einer Serie trotzdem verarbeitet wird, statt stillschweigend verloren zu gehen, weil es in das Abkühlfenster fiel.
Zwischen beiden wählen
- Debounce, wenn nur der Endzustand zählt: Sucheingabe, Formularvalidierung, Auto-Save, Resize-Handler, die erst laufen müssen, wenn alles zur Ruhe gekommen ist.
- Throttle, wenn man während der Aktivität kontinuierliches Feedback braucht: Infinite-Scroll-Trigger, Fortschrittsbalken für die Scrollposition, Drag-and-Drop, Live-Cursor-Tracking.
Eine Faustregel: Debounce beantwortet „was haben sie am Ende getan?"; Throttle beantwortet „was tun sie gerade, regelmäßig abgetastet?"
Häufige Fallstricke
thisverlieren. Beide Implementierungen oben verwendenfn.apply(this, args), funktionieren also korrekt als Objektmethoden oder normal gebundene Event-Handler. Wickelt man eine Arrow-Funktion ein, die bereits das falschethiseingefangen hat, repariert kein.apply()der Welt das – der Fehler passiert früher.- Beim Unmount nicht aufräumen. In einem komponentenbasierten Framework (React, Angular, Vue) kann ein debouncter/gethrottelter Handler noch in einem
setTimeoutanstehen, nachdem die Komponente, die ihn erzeugt hat, längst weg ist, und sein Feuern kann dann abstürzen oder in veralteten State schreiben. Brich ihn ab: Halte eine Referenz auftimeoutIdund lösche sie im Cleanup-Pfad (ngOnDestroy, die Cleanup-Funktion vonuseEffectusw.). - Zu lange Debounce-Verzögerung. 300 ms sind ein gängiger Standard für Sucheingaben, aber bei etwas wie Inline-Formularvalidierung können sich selbst 300 ms träge anfühlen. Stimme die Verzögerung auf die Interaktion ab, nicht auf eine Zahl, die du aus einem Blogpost kopiert hast.
- Throttle ohne abschließenden Aufruf. Ein naives Throttle, das nur prüft „ist genug Zeit vergangen?" und sonst nichts tut, verliert stillschweigend das letzte Event einer Serie. Zählt der letzte Zustand (und das tut er meistens), stelle sicher, dass deine Implementierung einen abschließenden Aufruf plant wie die oben.
Sollte man es selbst schreiben?
Die Implementierungen hier sind bewusst minimal, um den eigentlichen Mechanismus zu zeigen – Closures, die zwischen Aufrufen Timer-Zustand halten. Im Produktivcode greift man besser zu einem gepflegten Hilfsmittel: debounce und throttle aus Lodash behandeln Randfälle wie die Konfiguration von Leading-/Trailing-Edge und die Methoden .cancel()/.flush(), die man von Hand leicht subtil falsch macht. Eigene zu schreiben ist eine gute Übung, um zu verstehen, was unter der Haube passiert – zu einer erprobten zu greifen ist die richtige Entscheidung, sobald es darauf ankommt.
Fazit
Debounce verdichtet eine Ereignisserie nach ihrem Abklingen zu einem Aufruf. Throttle verteilt die Aufrufe gleichmäßig, während die Serie läuft. Beide existieren aus demselben Grund: Der Browser weiß nicht, wie schnell dein Code laufen darf, also musst du es ihm sagen.