Event Loop & Call Stack – JavaScript unter der Haube
Call Stack, Event Loop und Task Queues erklärt: So verarbeitet JavaScript asynchronen Code unter der Haube. Mit Beispielen und Diagrammen.
JavaScript ist Single-Threaded – es gibt nur einen einzigen Thread, der Code ausführt. Trotzdem können wir asynchrone Operationen wie API-Calls, Timer und Event-Handler parallel verarbeiten, ohne dass die Seite einfriert. Das Geheimnis: der Event Loop. Wer den Event Loop versteht, versteht wie JavaScript tickt.
Die Architektur im Überblick
┌─────────────────────────────────────────────────────────────────┐
│ JavaScript Runtime │
├─────────────────┬───────────────────────────────────────────────┤
│ │ │
│ ┌─────────┐ │ ┌─────────────────────────────────────────┐ │
│ │ Heap │ │ │ Web APIs │ │
│ │(Objekte)│ │ │ setTimeout, fetch, DOM Events, etc. │ │
│ └─────────┘ │ └─────────────────────────────────────────┘ │
│ │ │ │
│ ┌─────────┐ │ ▼ │
│ │ Call │ │ ┌─────────────────────────────────────────┐ │
│ │ Stack │◄──┼───│ Callback Queues │ │
│ │ │ │ │ ┌─────────────┐ ┌──────────────────┐ │ │
│ └─────────┘ │ │ │ Microtasks │ │ Task Queue │ │ │
│ ▲ │ │ │ (Promises) │ │ (setTimeout) │ │ │
│ │ │ │ └─────────────┘ └──────────────────┘ │ │
│ │ │ └─────────────────────────────────────────┘ │
│ │ │ ▲ │
│ ┌────┴────┐ │ │ │
│ │ Event │───┼────────────────────┘ │
│ │ Loop │ │ │
│ └─────────┘ │ │
│ │ │
└─────────────────┴───────────────────────────────────────────────┘
Der Call Stack
Der Call Stack ist der Dreh- und Angelpunkt. Hier werden Funktionen gestapelt und abgearbeitet – nach dem LIFO-Prinzip (Last In, First Out).
function dritte() {
console.log('Dritte Funktion');
}
function zweite() {
dritte();
console.log('Zweite Funktion');
}
function erste() {
zweite();
console.log('Erste Funktion');
}
erste();
So sieht der Call Stack aus:
Schritt 1: Schritt 2: Schritt 3: Schritt 4:
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ │ │ │ │ dritte()│ │ │
│ │ │ zweite()│ │ zweite()│ │ zweite()│
│ erste() │ │ erste() │ │ erste() │ │ erste() │
└─────────┘ └─────────┘ └─────────┘ └─────────┘
▲ Push ▲ Push ▲ Push ▼ Pop
(dritte fertig)
Ausgabe:
Dritte Funktion
Zweite Funktion
Erste Funktion
Stack Overflow
Wenn der Stack zu voll wird, gibt’s einen Fehler:
function rekursiv() {
rekursiv(); // Ruft sich selbst auf – unendlich!
}
rekursiv();
// RangeError: Maximum call stack size exceeded
Web APIs – Die Helfer im Hintergrund
Der Call Stack kann nur eine Sache gleichzeitig tun. Aber was passiert bei setTimeout, fetch oder Event-Listenern? Diese Operationen werden an die Web APIs des Browsers ausgelagert.
console.log('Start');
setTimeout(() => {
console.log('Timeout');
}, 2000);
console.log('Ende');
Ablauf:
1. console.log('Start') → Call Stack → Ausgabe: "Start"
2. setTimeout(...) → Call Stack → Übergabe an Web API
3. console.log('Ende') → Call Stack → Ausgabe: "Ende"
4. [2 Sekunden später]
5. Callback in Task Queue → Event Loop prüft
6. Stack leer? → Ja → Callback auf Stack
7. console.log('Timeout') → Ausgabe: "Timeout"
Ausgabe:
Start
Ende
Timeout
Der Event Loop
Der Event Loop ist eine simple Endlosschleife:
┌──────────────────────────────────────────────┐
│ Event Loop Algorithmus │
├──────────────────────────────────────────────┤
│ │
│ while (true) { │
│ │
│ 1. Führe alle synchronen Tasks aus │
│ (bis Call Stack leer) │
│ │
│ 2. Leere die GESAMTE Microtask Queue │
│ (Promises, queueMicrotask) │
│ │
│ 3. Nimm EINEN Task aus der Task Queue │
│ (setTimeout, setInterval, I/O) │
│ │
│ 4. Rendering (falls nötig) │
│ (requestAnimationFrame, Paint) │
│ │
│ 5. Wiederhole │
│ │
│ } │
│ │
└──────────────────────────────────────────────┘
Wichtig: Microtasks haben immer Vorrang vor Tasks!
Microtasks vs. Tasks
Das ist der häufigste Stolperstein. Es gibt zwei verschiedene Queues:
┌─────────────────────────────────────────────────────────────┐
│ │
│ Microtask Queue (hohe Priorität) │
│ ───────────────────────────────── │
│ • Promise.then() / catch() / finally() │
│ • async/await (nach dem await) │
│ • queueMicrotask() │
│ • MutationObserver │
│ │
│ → Wird KOMPLETT geleert bevor ein Task ausgeführt wird │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ Task Queue / Macrotask Queue (normale Priorität) │
│ ──────────────────────────────────────────────── │
│ • setTimeout / setInterval │
│ • setImmediate (Node.js) │
│ • I/O Callbacks │
│ • UI Rendering Events │
│ • Event Listener Callbacks (click, etc.) │
│ │
│ → Nur EIN Task pro Event Loop Iteration │
│ │
└─────────────────────────────────────────────────────────────┘
Das klassische Rätsel
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
Was ist die Ausgabe?
1
4
3
2
Erklärung:
console.log('1')– Synchron → Sofort ausgeführtsetTimeout– In Task Queue geschobenPromise.then– In Microtask Queue geschobenconsole.log('4')– Synchron → Sofort ausgeführt- Stack leer → Microtask Queue wird geleert →
3 - Ein Task aus Task Queue →
2
Komplexeres Beispiel
console.log('Start');
setTimeout(() => {
console.log('Timeout 1');
Promise.resolve().then(() => console.log('Promise in Timeout'));
}, 0);
Promise.resolve()
.then(() => {
console.log('Promise 1');
return Promise.resolve();
})
.then(() => console.log('Promise 2'));
setTimeout(() => console.log('Timeout 2'), 0);
console.log('Ende');
Ausgabe:
Start
Ende
Promise 1
Promise 2
Timeout 1
Promise in Timeout
Timeout 2
Ablauf:
┌─ Synchroner Code ──────────────────────────────────────────┐
│ 1. "Start" │
│ 2. setTimeout 1 → Task Queue │
│ 3. Promise → Microtask Queue │
│ 4. setTimeout 2 → Task Queue │
│ 5. "Ende" │
└────────────────────────────────────────────────────────────┘
▼
┌─ Microtask Queue leeren ───────────────────────────────────┐
│ 6. "Promise 1" → neuer Microtask in Queue │
│ 7. "Promise 2" │
└────────────────────────────────────────────────────────────┘
▼
┌─ Ein Task aus Task Queue ──────────────────────────────────┐
│ 8. "Timeout 1" → neuer Microtask in Queue │
└────────────────────────────────────────────────────────────┘
▼
┌─ Microtask Queue leeren ───────────────────────────────────┐
│ 9. "Promise in Timeout" │
└────────────────────────────────────────────────────────────┘
▼
┌─ Ein Task aus Task Queue ──────────────────────────────────┐
│ 10. "Timeout 2" │
└────────────────────────────────────────────────────────────┘
Warum ist das wichtig?
1. UI Blocking vermeiden
Lange synchrone Operationen blockieren den gesamten Thread:
// SCHLECHT – blockiert alles für ~5 Sekunden
function processHugeArray(arr) {
for (let i = 0; i < arr.length; i++) {
heavyComputation(arr[i]);
}
}
// BESSER – gibt dem Event Loop Luft
async function processHugeArrayAsync(arr) {
for (let i = 0; i < arr.length; i++) {
heavyComputation(arr[i]);
// Alle 100 Items dem Event Loop eine Pause gönnen
if (i % 100 === 0) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
}
2. Race Conditions verstehen
let value = 0;
// Das hier ist NICHT parallel!
Promise.resolve().then(() => value++);
Promise.resolve().then(() => console.log(value));
// Ausgabe: 1 (nicht 0!)
// Beide Microtasks werden nacheinander ausgeführt
3. DOM-Updates batchen
Der Browser batcht DOM-Updates zwischen Tasks:
const el = document.getElementById('box');
// Alle drei Änderungen werden gesammelt
el.style.width = '100px';
el.style.height = '100px';
el.style.background = 'red';
// Erst NACH diesem synchronen Code wird gerendert
requestAnimationFrame
Für Animationen gibt es eine spezielle Queue:
function animate() {
// Wird VOR dem nächsten Repaint ausgeführt
element.style.transform = `translateX(${x}px)`;
x += 2;
if (x < 500) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
Reihenfolge im Event Loop:
- Synchroner Code
- Microtasks (Promises)
- requestAnimationFrame Callbacks
- Rendering/Paint
- Tasks (setTimeout, etc.)
Node.js: Zusätzliche Phasen
Node.js hat einen erweiterten Event Loop mit mehr Phasen:
┌───────────────────────────┐
┌─▶│ timers │ setTimeout, setInterval
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ pending callbacks │ I/O Callbacks
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ idle, prepare │ Intern
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ poll │ Neue I/O Events
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ check │ setImmediate
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
└──┤ close callbacks │ socket.on('close')
└───────────────────────────┘
↻ process.nextTick() wird nach JEDER Phase ausgeführt
↻ Microtasks werden nach nextTick ausgeführt
// Node.js spezifisch
setImmediate(() => console.log('setImmediate'));
setTimeout(() => console.log('setTimeout'), 0);
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('Promise'));
// Ausgabe:
// nextTick
// Promise
// setTimeout (oder setImmediate – Reihenfolge hier nicht garantiert)
// setImmediate (oder setTimeout)
Debugging-Tools
Performance Timeline (Chrome DevTools)
console.time('Operation');
// ... Code ...
console.timeEnd('Operation');
// Performance Markers setzen
performance.mark('start');
// ... Code ...
performance.mark('end');
performance.measure('Dauer', 'start', 'end');
Long Task Detection
// Long Tasks (>50ms) erkennen
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn('Long Task detected:', entry.duration, 'ms');
}
});
observer.observe({ entryTypes: ['longtask'] });
Zusammenfassung
┌────────────────────────────────────────────────────────────┐
│ Event Loop Cheat Sheet │
├────────────────────────────────────────────────────────────┤
│ │
│ Ausführungsreihenfolge: │
│ 1. Synchroner Code (Call Stack) │
│ 2. Microtasks (Promise.then, async/await, queueMicrotask) │
│ 3. requestAnimationFrame (Browser) │
│ 4. Rendering/Paint (Browser) │
│ 5. Ein Task (setTimeout, Events, I/O) │
│ 6. → Zurück zu 2. │
│ │
├────────────────────────────────────────────────────────────┤
│ │
│ Merke: │
│ • Microtasks > Tasks (Priorität) │
│ • Microtask Queue wird KOMPLETT geleert │
│ • Task Queue: nur EIN Task pro Iteration │
│ • setTimeout(fn, 0) ≠ sofort (mindestens 1 Task-Cycle) │
│ • Langer synchroner Code = blockierte UI │
│ │
└────────────────────────────────────────────────────────────┘
Der Event Loop ist das Herzstück von JavaScript. Mit diesem Wissen kannst du Performance-Probleme debuggen, Race Conditions vermeiden und verstehen, warum Code nicht in der erwarteten Reihenfolge läuft.