7 Min. Lesezeit

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:

  1. console.log('1') – Synchron → Sofort ausgeführt
  2. setTimeout – In Task Queue geschoben
  3. Promise.then – In Microtask Queue geschoben
  4. console.log('4') – Synchron → Sofort ausgeführt
  5. Stack leer → Microtask Queue wird geleert → 3
  6. 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:

  1. Synchroner Code
  2. Microtasks (Promises)
  3. requestAnimationFrame Callbacks
  4. Rendering/Paint
  5. 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.