stale Closures als Fallstrick verstehen
Eine Schleife registriert fünf Buttons, jeder Klick zeigt dieselbe Zahl statt der erwarteten Reihenfolge. Dieses Muster heißt stale Closure und trifft nicht nur klassische var-Schleifen, sondern auch moderne Event Handler und asynchronen Code. Dieser Artikel zeigt, wo stale Closures entstehen, warum let das Problem oft löst und wie man verbleibende Fälle systematisch debuggt.
Inhaltsverzeichnis
- 1. Was eine Closure ist und wie eine stale Closure entsteht
- 2. Der var-Klassiker: warum Schleifen die falsche Zahl ausgeben
- 3. let statt var: wie Block-Scoping das Problem löst
- 4. Event Handler in Schleifen: derselbe Fehler, andere Verpackung
- 5. Stale Closures in asynchronem Code: Race Conditions bei fetch
- 6. Das IIFE-Pattern: die klassische Lösung vor let
- 7. Closures und Zustand: absichtliche vs. versehentliche Kapselung
- 8. Debugging-Techniken: stale Closures im Code erkennen
- 9. Closure-Patterns im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Was eine Closure ist und wie eine stale Closure entsteht
Eine Closure entsteht immer dann, wenn eine Funktion auf Variablen aus ihrem umgebenden Gültigkeitsbereich zugreift und diese Variablen über das Ende der Ausführung des äußeren Codes hinaus im Speicher hält. Das ist eines der mächtigsten Konzepte in JavaScript, weil es Funktionen erlaubt, eigenen privaten Zustand zu tragen, ohne globale Variablen zu benötigen. Genau dieselbe Eigenschaft, die Closures so nützlich macht, wird jedoch zur Falle, sobald mehrere Funktionen dieselbe Variable referenzieren, während sich der Wert dieser Variable zwischenzeitlich ändert.
Eine stale Closure liegt vor, wenn eine Funktion zu einem späteren Zeitpunkt ausgeführt wird und dabei nicht den Wert erhält, den man bei der Definition der Funktion erwartet hätte, sondern einen bereits veränderten Wert derselben Variable. Der Begriff stale Closure beschreibt also nicht einen Fehler im Sprachmechanismus selbst, sondern eine falsche Erwartung des Entwicklers darüber, welchen Wert die Closure zum Ausführungszeitpunkt tatsächlich sieht. Die Closure funktioniert technisch korrekt, sie liest nur einen anderen Wert als gedacht.
Die folgenden Abschnitte zeigen die häufigsten Situationen, in denen stale Closures in der Praxis entstehen: klassische Schleifen mit var, Event Handler, die in einer Schleife registriert werden, und asynchroner Code mit verzögerten Antworten. Jeder Abschnitt liefert ein konkretes Beispiel und eine robuste Lösung, damit stale Closures nicht länger zu stundenlangem Debugging führen.
2. Der var-Klassiker: warum Schleifen die falsche Zahl ausgeben
Das bekannteste Beispiel für eine stale Closure ist eine for-Schleife mit var, in der pro Iteration ein setTimeout-Callback registriert wird. Weil var funktionsweit gescoped ist und nicht blockweit, existiert während der gesamten Schleife nur eine einzige Variable, nicht eine neue Instanz pro Durchlauf. Alle registrierten Callbacks referenzieren also dieselbe Speicherstelle, und wenn die Timer schließlich feuern, hat die Schleife längst geendet und die Variable trägt ihren letzten zugewiesenen Wert.
Das Ergebnis überrascht viele Entwickler beim ersten Kontakt: Statt der erwarteten Zahlen null bis vier erscheint fünfmal dieselbe Zahl fünf. Die Closure ist technisch fehlerfrei, sie liest exakt den Wert, den die Variable zum Zeitpunkt der Ausführung trägt, das ist genau die Definition einer stale Closure. Dieses Muster tritt nicht nur bei setTimeout auf, sondern überall dort, wo eine Funktion später als in der Iteration selbst ausgeführt wird, etwa bei asynchronen Callbacks oder verzögerten UI-Updates.
// Classic stale closure with var
for (var i = 0; i < 5; i++) {
setTimeout(() => {
console.log(i); // prints 5, five times — not 0, 1, 2, 3, 4
}, 100);
}
// Reason: var is function-scoped, only one "i" exists for the whole loop.
// By the time the timers fire, the loop has already finished with i === 5.
3. let statt var: wie Block-Scoping das Problem löst
Mit der Einführung von let in ES2015 wurde genau dieses Problem sprachseitig gelöst, allerdings auf eine subtilere Weise, als viele Entwickler annehmen. let ist blockgescoped, aber der entscheidende Punkt ist nicht nur der Scope selbst, sondern dass die Spezifikation für for-Schleifen mit let pro Iteration eine neue Bindung erzeugt. Jede Iteration erhält also eine frische Kopie der Schleifenvariable, und die in dieser Iteration erzeugte Closure referenziert genau diese Kopie, nicht eine gemeinsame, geteilte Variable.
Dadurch verschwindet die stale Closure beim einfachen Austausch von var gegen let vollständig, ohne dass zusätzlicher Code nötig wäre. Das macht let zur einfachsten und heute empfohlenen Lösung für dieses Klasse von Fehlern. Wichtig zu verstehen ist trotzdem, dass dies eine Besonderheit der for-Schleifen-Syntax ist, keine allgemeine Eigenschaft von let. Wird dieselbe Variable außerhalb einer Schleife wiederverwendet und anschließend mehrfach in Closures eingefangen, kann eine stale Closure auch mit let wieder auftreten, wenn die Variable zwischen den Closure-Erstellungen mutiert wird.
// Fixed with let: a fresh binding per iteration
for (let i = 0; i < 5; i++) {
setTimeout(() => {
console.log(i); // prints 0, 1, 2, 3, 4 — each closure sees its own iteration
}, 100);
}
// The fresh-binding-per-iteration rule is specific to for-loops.
// Reusing one mutable "let" variable across manually created closures
// still produces a stale closure:
let counter = 0;
const callbacks = [];
for (let n = 0; n < 3; n++) {
callbacks.push(() => console.log(counter)); // all three read the SAME "counter"
}
counter = 99;
callbacks.forEach(cb => cb()); // 99, 99, 99 — stale closure is back
4. Event Handler in Schleifen: derselbe Fehler, andere Verpackung
Wird in einer Schleife für jedes Element einer Liste ein Event Handler registriert, tritt exakt dasselbe Muster wie beim setTimeout-Beispiel auf, nur mit einem Klick statt eines Timers als Auslöser. Wer eine Reihe von Buttons dynamisch erzeugt und in derselben Schleife pro Button einen Klick-Handler registriert, der auf die Schleifenvariable zugreift, produziert bei Verwendung von var eine stale Closure, die bei jedem Klick denselben, falschen Index ausgibt, unabhängig davon, welcher Button tatsächlich angeklickt wurde.
Der Unterschied zum Timer-Beispiel ist rein zeitlich: Der Klick kann Sekunden oder Minuten nach der Schleife erfolgen, während der Timer meist Millisekunden später feuert. Das Grundproblem der stale Closure bleibt identisch, weil der Auslöser in beiden Fällen erst nach dem vollständigen Durchlauf der Schleife ausgeführt wird. Auch hier löst der Wechsel zu let das Problem zuverlässig, alternativ kann der aktuelle Index explizit als Attribut am Element gespeichert und im Handler über event.currentTarget ausgelesen werden, was zusätzlich unabhängig von der gewählten Variablendeklaration funktioniert.
const buttons = document.querySelectorAll('.item-button');
// WRONG: stale closure, every handler logs the same, final index
for (var i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', () => {
console.log(`Button ${i} clicked`); // always the last index
});
}
// RIGHT #1: let creates a fresh binding per iteration
for (let i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', () => {
console.log(`Button ${i} clicked`); // correct index every time
});
}
// RIGHT #2: read the index from the DOM instead of relying on the closure
buttons.forEach((btn, index) => {
btn.dataset.index = String(index);
btn.addEventListener('click', (event) => {
console.log(`Button ${event.currentTarget.dataset.index} clicked`);
});
});
5. Stale Closures in asynchronem Code: Race Conditions bei fetch
Eine besonders tückische Variante der stale Closure entsteht in asynchronem Code, wenn eine Komponente mehrere Anfragen kurz hintereinander auslöst, etwa bei einer Live-Suche, die bei jedem Tastendruck eine neue Anfrage startet. Jede dieser Anfragen kapselt in ihrer Antwort-Callback-Funktion den zum Auslösezeitpunkt gültigen Suchbegriff. Antworten treffen aber nicht garantiert in der Reihenfolge ein, in der die Anfragen gestartet wurden, sodass eine ältere, langsamere Antwort eine neuere, bereits aktuellere Anzeige überschreiben kann.
Das ist im engeren Sinn keine stale Closure der Variable selbst, sondern eine stale Closure des gesamten Anfragekontexts: Der Callback trägt korrekt den Suchbegriff seiner eigenen Anfrage, aber dieser Kontext ist zum Zeitpunkt der Antwort bereits veraltet, weil der Nutzer inzwischen weitergetippt hat. Die robuste Lösung ist entweder ein Vergleich des im Callback gespeicherten Suchbegriffs mit dem aktuell gültigen Wert vor dem Aktualisieren der Oberfläche, oder ein AbortController, der veraltete Anfragen aktiv abbricht, bevor ihre stale Closure überhaupt zur Ausführung kommt.
let currentQuery = '';
async function search(query) {
currentQuery = query;
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const results = await response.json();
// Guard against a stale closure: only render if this is still the latest query
if (query !== currentQuery) {
return; // an older, slower response arrived after a newer one
}
renderResults(results);
}
// Even more robust: cancel stale requests with AbortController
let controller = null;
async function searchWithAbort(query) {
controller?.abort(); // cancel any in-flight previous request
controller = new AbortController();
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal
});
const results = await response.json();
renderResults(results);
}
6. Das IIFE-Pattern: die klassische Lösung vor let
Vor der Einführung von let war das immediately invoked function expression, kurz IIFE, die Standardlösung, um stale Closures in Schleifen zu vermeiden. Der Trick besteht darin, in jeder Iteration eine sofort ausgeführte Funktion aufzurufen und die aktuelle Schleifenvariable als Parameter zu übergeben. Weil Funktionsparameter bei jedem Aufruf einer neuen Bindung entsprechen, entsteht innerhalb des IIFE eine eigene, unveränderliche Kopie der Variable, unabhängig davon, wie oft die äußere Schleifenvariable später mutiert wird.
Historisch relevant bleibt das IIFE-Pattern vor allem beim Lesen und Warten älterer Codebasen, die noch auf var und ES5-Syntax setzen und aus organisatorischen Gründen nicht ohne Weiteres migriert werden können. In neuem Code ist das Pattern heute nur noch in Ausnahmefällen sinnvoll, etwa wenn eine Bibliothek eine feste API-Signatur ohne Blockscoping vorschreibt. Für alle neuen Projekte bleibt let die einfachere, lesbarere und ebenso zuverlässige Lösung gegen dieselbe Klasse von stale Closures.
// Pre-ES2015 fix using an IIFE to capture the current value of "i"
for (var i = 0; i < 5; i++) {
(function (capturedIndex) {
setTimeout(() => {
console.log(capturedIndex); // 0, 1, 2, 3, 4 — each IIFE gets its own copy
}, 100);
})(i);
}
// Equivalent modern code, no IIFE needed:
for (let i = 0; i < 5; i++) {
setTimeout(() => console.log(i), 100);
}
7. Closures und Zustand: absichtliche vs. versehentliche Kapselung
Nicht jede Closure, die einen veralteten Wert liest, ist automatisch ein Fehler. Das Modul-Pattern nutzt Closures ganz bewusst, um privaten Zustand zu kapseln, der über mehrere Funktionsaufrufe hinweg erhalten bleibt, etwa in einem Zähler, der bei jedem Aufruf um eins erhöht wird. In diesem Fall ist es genau das gewünschte Verhalten, dass alle zurückgegebenen Funktionen dieselbe, geteilte Variable referenzieren, weil sie gemeinsam denselben Zustand verwalten sollen.
Der Unterschied zwischen absichtlicher Kapselung und einer stale Closure liegt einzig in der Erwartungshaltung des Entwicklers: Beim Modul-Pattern erwartet man geteilten, veränderlichen Zustand über mehrere Aufrufe hinweg, bei einer Schleife erwartet man dagegen isolierte, unabhängige Werte pro Iteration. Wer diese beiden Fälle bewusst auseinanderhält, erkennt beim Code-Review sofort, ob eine geteilte Variable in einer Closure ein Feature oder ein Bug ist, statt jede Closure pauschal zu verdächtigen.
// Intentional shared state via closure — this is a feature, not a stale closure
function createCounter() {
let count = 0; // private state, shared across all returned functions
return {
increment: () => ++count,
decrement: () => --count,
value: () => count
};
}
const counter = createCounter();
counter.increment();
counter.increment();
console.log(counter.value()); // 2 — shared closure state, intentional
// Contrast: a loop where each callback should see its OWN value, not shared state
const counters = [];
for (let i = 0; i < 3; i++) {
counters.push(createCounter()); // one independent closure instance per iteration
}
8. Debugging-Techniken: stale Closures im Code erkennen
Der schnellste Weg, eine stale Closure zu bestätigen, ist ein Breakpoint direkt im Callback und ein Blick auf das Closure-Panel in Chrome DevTools, das im Scope-Bereich alle eingefangenen Variablen mit ihrem aktuellen Wert zum Ausführungszeitpunkt anzeigt. Zeigt sich dort ein Wert, der offensichtlich nicht zur erwarteten Iteration passt, etwa immer der letzte statt des jeweils aktuellen Index, ist die Ursache fast immer eine geteilte Variable, die per var oder durch manuelle Wiederverwendung mehrfach eingefangen wurde.
ESLint-Regeln wie no-loop-func markieren die Definition von Funktionen innerhalb von Schleifen bereits im Editor und weisen präventiv auf potenzielle stale Closures hin, lange bevor der Code überhaupt ausgeführt wird. Ergänzend hilft ein einfacher Test: Wird der fragliche Callback mit einer künstlichen Verzögerung ausgeführt, etwa über einen zusätzlichen setTimeout mit größerem Delay, und ändert sich das beobachtete Verhalten gegenüber der sofortigen Ausführung, ist das ein starkes Indiz für eine stale Closure, weil die Variable zwischen Definition und Ausführung der Closure offensichtlich mutiert wurde.
9. Closure-Patterns im direkten Vergleich
Je nach Situation unterscheidet sich, ob eine geteilte Variable in einer Closure gewünscht ist oder eine stale Closure verursacht, und die Wahl des passenden Patterns hängt direkt davon ab, ob jede Iteration einen eigenen, isolierten Wert benötigt.
| Situation | Risiko einer stale Closure | Empfohlenes Pattern | Warum es funktioniert |
|---|---|---|---|
| for-Schleife mit Timer | Hoch bei var | let statt var | Neue Bindung pro Iteration |
| Event Handler in Schleife | Hoch bei var | let oder dataset-Attribut | Wert unabhängig von Deklarationsart lesbar |
| Live-Suche mit fetch | Hoch, unabhängig von var/let | AbortController oder Query-Vergleich | Veraltete Antworten werden verworfen |
| Modul-Pattern / Counter | Kein Risiko, gewollter Zustand | Geteilte Closure-Variable beibehalten | Zustand soll über Aufrufe hinweg bestehen |
| Legacy ES5-Code | Hoch bei var | IIFE mit Parameterübergabe | Parameter erzeugt neue Bindung pro Aufruf |
Die Tabelle zeigt, dass es nicht die Closure selbst ist, die zum Problem wird, sondern die Frage, ob eine geteilte oder eine isolierte Variable gebraucht wird. Wer diese Unterscheidung von Anfang an trifft, vermeidet die meisten stale Closures, statt sie erst im Fehlerfall über Breakpoints und Konsolenausgaben aufzuspüren.
Mironsoft
JavaScript-Debugging, Code-Reviews und Frontend-Architektur
Stale Closures kosten euch immer wieder Debugging-Zeit?
Wir prüfen bestehenden JavaScript-Code auf riskante Closure-Muster in Schleifen, Event Handlern und asynchronem Code und ersetzen sie durch robuste, klar getestete Patterns.
Code-Review
Gezielte Suche nach var-Schleifen und riskanten Closure-Mustern
Refactoring
let-Migration, AbortController-Integration, saubere Kapselung
Linting-Setup
ESLint-Regeln gegen Funktionsdefinitionen in Schleifen
10. Zusammenfassung
Stale Closures entstehen immer dann, wenn eine Funktion später als erwartet ausgeführt wird und dabei eine Variable liest, die sich zwischen Definition und Ausführung bereits verändert hat. Der klassische Fall ist eine for-Schleife mit var, bei der alle registrierten Callbacks dieselbe, funktionsweit gescopte Variable referenzieren und am Ende denselben, letzten Wert ausgeben. Der Wechsel zu let löst dieses Problem in den meisten Fällen automatisch, weil jede Iteration einer for-Schleife mit let eine eigene, frische Bindung erhält.
Dasselbe Grundmuster taucht in Event Handlern und asynchronem Code wieder auf, dort oft in Form von race conditions, bei denen eine veraltete Antwort eine aktuellere überschreibt. AbortController und explizite Query-Vergleiche schützen zuverlässig davor. Wichtig bleibt die Unterscheidung zwischen einer echten stale Closure und einer absichtlich geteilten Zustandsvariable im Modul-Pattern, denn nicht jede geteilte Closure-Variable ist ein Fehler. Breakpoints im Closure-Panel der DevTools und die ESLint-Regel no-loop-func helfen, verbleibende Fälle schnell zu identifizieren.
Stale Closures in JavaScript, das Wichtigste auf einen Blick
Ursache
Eine Closure liest eine Variable, die sich zwischen Definition und späterer Ausführung bereits verändert hat.
Klassischer Fix
let statt var in Schleifen, jede Iteration erhält automatisch eine eigene Bindung.
Async-Variante
AbortController oder Query-Vergleich verhindern, dass veraltete Antworten aktuelle überschreiben.
Debugging
Closure-Panel in DevTools prüfen, ESLint-Regel no-loop-func aktivieren.