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.

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.
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 emptiesSelbst 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.
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// ❌ 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.
// ❌ 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')
│ └───────────┘───────────────┘
// 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
// ❌ 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
// ❌ 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
- Microtasks laufen immer vor der nächsten Macrotask — Promises feuern vor
setTimeout(fn, 0) - Microtask-Queues werden vollständig abgearbeitet, bevor abgegeben wird, was das Rendering blockieren kann
- Nutze
setTimeout, um bei lang laufender Arbeit an die Rendering-Pipeline des Browsers abzugeben - Node.js hat zusätzliche Event-Loop-Phasen —
setImmediateundprocess.nextTickhaben spezifische Reihenfolge-Garantien - Blockiere die Event Loop niemals mit synchronem I/O oder CPU-intensiver Berechnung auf dem Main Thread


