Zum Inhalt springen

Die JavaScript Event Loop verstehen

Die Event Loop ist der Motor hinter jeder asynchronen Operation in JavaScript — so funktioniert sie wirklich, jenseits der oberflächlichen Erklärungen.

3 Min. Lesezeit
Diagramm der JavaScript-Event-Loop mit Call Stack, Task-Queue und Microtask-Queue

Die meisten JavaScript-Entwickler nutzen täglich async/await und Promises, ohne die Mechanik dahinter zu verstehen. Das funktioniert so lange, bis man auf einen Bug stößt, bei dem Callbacks in unerwarteter Reihenfolge feuern, setTimeout(fn, 0) nicht sofort ausgeführt wird oder eine Microtask die Rendering-Pipeline aushungert. Wer die Event Loop versteht, macht aus diesen Rätseln vorhersehbares Verhalten.

Call Stack und Task-Queue

JavaScript ist single-threaded. Ein Call Stack, zu jedem Zeitpunkt läuft nur ein Stück Code. Wenn asynchrone Operationen abschließen, unterbrechen ihre Callbacks nicht die laufende Ausführung — sie reihen sich in eine Queue ein und warten.

jsjavascript
console.log("1");
 
setTimeout(() => {
  console.log("2");
}, 0);
 
console.log("3");
 
// Output: 1, 3, 2
// "2" goes to the task queue, runs after the current stack empties

Selbst mit einem Timeout von 0 wartet der Callback, bis der Call Stack leer ist. Die Aufgabe der Event Loop ist es zu prüfen: "Ist der Stack leer? Wenn ja, die nächste Task aus der Queue holen."

Microtasks vs. Macrotasks

Nicht alle eingereihten Arbeiten sind gleich. Promises und queueMicrotask landen in der Microtask-Queue. setTimeout, setInterval und I/O-Callbacks landen in der Macrotask-Queue. Microtasks werden vollständig abgearbeitet, bevor die nächste Macrotask läuft.

jsjavascript
console.log("start");
 
setTimeout(() => console.log("timeout"), 0);
 
Promise.resolve().then(() => console.log("promise 1"));
 
queueMicrotask(() => {
  console.log("microtask");
  Promise.resolve().then(() => console.log("promise 2"));
});
 
console.log("end");
 
// Output: start, end, promise 1, microtask, promise 2, timeout
tstypescript
// ❌ Assuming setTimeout and Promise.then have the same priority
function processItems(items: string[]) {
  items.forEach((item) => {
    setTimeout(() => sendToAnalytics(item), 0);
  });
  // Bug: if you resolve a Promise after this, it runs
  // BEFORE any analytics calls
}
 
// ✅ Understanding the queue priority
function processItems(items: string[]) {
  // Use the same queue type for ordering guarantees
  items.forEach((item) => {
    queueMicrotask(() => sendToAnalytics(item));
  });
}

Die Prioritätsreihenfolge pro Event-Loop-Tick: alle Microtasks abarbeiten → eine Macrotask ausführen → wieder alle Microtasks abarbeiten → wiederholen.

Die Rendering-Pipeline

In Browsern koordiniert sich die Event Loop mit der Rendering-Engine. Zwischen Macrotasks kann der Browser neu zeichnen — aber nur, wenn die Microtask-Queue leer ist.

jsjavascript
// ❌ Microtask loop blocks rendering indefinitely
function recursiveMicrotask() {
  queueMicrotask(() => {
    // This starves the render pipeline — UI freezes
    doExpensiveWork();
    recursiveMicrotask();
  });
}
 
// ✅ Use macrotasks to yield to the renderer
function chunkedWork(items: unknown[], index = 0) {
  const CHUNK_SIZE = 100;
  const end = Math.min(index + CHUNK_SIZE, items.length);
 
  for (let i = index; i < end; i++) {
    processItem(items[i]);
  }
 
  if (end < items.length) {
    // setTimeout yields to the render pipeline between chunks
    setTimeout(() => chunkedWork(items, end), 0);
  }
}

Wenn dein JavaScript den Main Thread länger als 16ms blockiert, verpasst du einen Frame. Nutzer nehmen das als Ruckeln oder Einfrieren wahr.

Node.js: zusätzliche Phasen

Node.js erweitert die Browser-Event-Loop um zusätzliche Phasen. Die Reihenfolge ist für serverseitigen Code entscheidend.

   ┌───────────────────────────┐
┌─→│        timers              │ ← setTimeout, setInterval
│  └───────────┬───────────────┘
│  ┌───────────┴───────────────┐
│  │     pending callbacks      │ ← I/O callbacks deferred
│  └───────────┬───────────────┘
│  ┌───────────┴───────────────┐
│  │        poll                │ ← I/O events, incoming connections
│  └───────────┬───────────────┘
│  ┌───────────┴───────────────┐
│  │        check               │ ← setImmediate
│  └───────────┬───────────────┘
│  ┌───────────┴───────────────┐
│  │     close callbacks        │ ← socket.on('close')
│  └───────────┘───────────────┘
jsjavascript
// In Node.js, setImmediate fires after I/O, setTimeout fires in timers phase
const fs = require("fs");
 
fs.readFile(__filename, () => {
  // Inside an I/O callback:
  setTimeout(() => console.log("timeout"), 0);
  setImmediate(() => console.log("immediate"));
  // Output: immediate, timeout (always in this order inside I/O)
});

process.nextTick ist ein Sonderfall — er läuft vor jeder anderen Microtask, am Ende der aktuellen Operation. Übermäßiger Gebrauch kann I/O aushungern.

Häufige Fallstricke

Unbeabsichtigtes Blockieren

tstypescript
// ❌ Synchronous file read blocks the event loop
import { readFileSync } from "fs";
 
app.get("/data", (req, res) => {
  const data = readFileSync("/large-file.json", "utf-8");
  res.json(JSON.parse(data));
});
 
// ✅ Async read lets the event loop handle other requests
import { readFile } from "fs/promises";
 
app.get("/data", async (req, res) => {
  const data = await readFile("/large-file.json", "utf-8");
  res.json(JSON.parse(data));
});

Unbegrenzt wachsende Promise-Ketten

tstypescript
// ❌ Creates a microtask chain that delays event processing
async function pollForever() {
  while (true) {
    await checkForUpdates();
    // No yielding to macro tasks — this can delay timers
  }
}
 
// ✅ Yield control between iterations
async function pollForever() {
  while (true) {
    await checkForUpdates();
    await new Promise((resolve) => setTimeout(resolve, 100));
  }
}

Die wichtigsten Punkte

  1. Microtasks laufen immer vor der nächsten Macrotask — Promises feuern vor setTimeout(fn, 0)
  2. Microtask-Queues werden vollständig abgearbeitet, bevor abgegeben wird, was das Rendering blockieren kann
  3. Nutze setTimeout, um bei lang laufender Arbeit an die Rendering-Pipeline des Browsers abzugeben
  4. Node.js hat zusätzliche Event-Loop-Phasen — setImmediate und process.nextTick haben spezifische Reihenfolge-Garantien
  5. Blockiere die Event Loop niemals mit synchronem I/O oder CPU-intensiver Berechnung auf dem Main Thread
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX