The event loop is the scheduling mechanism that powers every async runtime: Node.js, Python's asyncio, browser JavaScript, Rust's Tokio, and Deno. It enables a single OS thread to handle thousands of concurrent I/O operations without spawning a thread per connection. Understanding how it works resolves most async debugging confusion — why callbacks fire in a certain order, why a CPU-heavy function freezes your server, and why await does not mean "runs right now."
What changed in 2026
- Runtimes now expose event loop metrics. Node.js 20+ and asyncio 3.12+ expose event loop lag as a built-in metric. Production observability tools (Datadog, Honeycomb) alert on loop lag > 50 ms automatically.
- Tokio (Rust) event loop handles 10M+ events/sec per thread on modern hardware — the practical ceiling for I/O event throughput has moved far beyond what most applications need.
- Deno 2 stabilized its event loop API, and Bun's Zig-based loop showed that the underlying syscall interface (epoll/kqueue/io_uring) is more important than the language layer.
- io_uring on Linux 6.x provides a submit-and-poll interface that reduces syscall overhead by batching I/O completions, and Node.js, Bun, and async Rust runtimes all use it in 2026.
The core loop
Stripped to its essence, an event loop does one thing repeatedly:
while (there_are_tasks) {
1. Run all ready microtasks (Promise callbacks, await continuations)
2. Pick the next macrotask (setTimeout, I/O callback, etc.)
3. Run it to completion (sync code, no yielding mid-task)
4. Poll the OS for new I/O events (epoll_wait / kevent / IOCP)
5. Queue callbacks for any ready events
6. Repeat
}
"Run to completion" is the key property: a callback runs entirely before the loop picks the next one. There are no preemptive interruptions.
How non-blocking I/O works
When you call await fetch(url) in JavaScript or await aiohttp.get(url) in Python:
- The socket is opened in non-blocking mode.
- The connect/write is initiated; the OS kernel begins the operation.
- The coroutine suspends and returns control to the event loop.
- The loop calls
epoll_wait() (Linux), kevent() (macOS/BSD), or IOCP (Windows) — a syscall that blocks the OS thread until at least one I/O event is ready.
- When the socket has data, the OS returns the event; the loop resumes the coroutine.
import asyncio
import aiohttp
async def fetch(url: str) -> str:
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
return await resp.text() # yields here while waiting for bytes
async def main():
# These run concurrently on ONE thread — no threads spawned
a, b, c = await asyncio.gather(
fetch("https://api.example.com/users"),
fetch("https://api.example.com/products"),
fetch("https://api.example.com/orders"),
)
Node.js event loop phases
Node.js has a specific phase order per loop iteration:
timers → execute setTimeout / setInterval callbacks
pending → I/O callbacks deferred from previous iteration
idle/prepare → internal use
poll → retrieve new I/O events; execute I/O callbacks
check → setImmediate callbacks
close → close event callbacks (e.g., socket.on('close'))
Microtasks (Promise.then, queueMicrotask) drain completely between each phase — they are not a phase themselves.
setImmediate(() => console.log("setImmediate"));
setTimeout(() => console.log("setTimeout 0"), 0);
Promise.resolve().then(() => console.log("microtask"));
console.log("sync");
// Output:
// sync
// microtask ← microtask queue drains before next macrotask
// setTimeout 0 ← or setImmediate; order between these two is not guaranteed
// setImmediate
Microtasks vs macrotasks
| Type |
Queue |
Examples |
Priority |
| Microtask |
Microtask queue |
Promise.then, queueMicrotask, MutationObserver |
High — runs after each task |
| Macrotask |
Task queue |
setTimeout, setInterval, I/O callback, setImmediate |
Normal — one per loop iteration |
A cascade of microtasks (a Promise chain that never yields to macrotasks) can starve I/O callbacks indefinitely — a real problem in CPU-intensive recursive promise chains.
Blocking the event loop
The single biggest event loop mistake:
// BAD: synchronous CPU work blocks all other tasks for duration
app.get("/compute", (req, res) => {
const result = expensiveSyncComputation(); // 500 ms of CPU
res.json({ result });
// Every other pending request is frozen for 500 ms
});
// GOOD: offload to a worker thread
const { Worker } = require("worker_threads");
app.get("/compute", async (req, res) => {
const result = await runInWorker(expensiveSyncComputation);
res.json({ result });
});
In Python asyncio, the equivalent is calling a blocking library function without asyncio.to_thread:
# BAD
@app.get("/data")
async def handler():
data = requests.get("https://api.example.com/data") # blocks the loop!
return data.json()
# GOOD
@app.get("/data")
async def handler():
async with httpx.AsyncClient() as client:
r = await client.get("https://api.example.com/data")
return r.json()
Measuring event loop lag
// Node.js: measure loop lag with perf_hooks
const { monitorEventLoopDelay } = require("perf_hooks");
const h = monitorEventLoopDelay({ resolution: 20 });
h.start();
setInterval(() => {
console.log(`Loop lag p99: ${h.percentile(99) / 1e6} ms`);
}, 5000);
# asyncio: log slow callbacks (built-in since 3.2)
import asyncio
loop = asyncio.get_event_loop()
loop.slow_callback_duration = 0.05 # warn if callback takes > 50 ms
How to design around the event loop
- Never perform synchronous I/O in async code — use async-native libraries.
- Offload CPU work to threads —
worker_threads (Node.js), asyncio.to_thread (Python), tokio::task::spawn_blocking (Rust).
- Keep individual callbacks short — yield with
await asyncio.sleep(0) or setImmediate in long loops.
- Monitor loop lag — alert at 50 ms, investigate at 10 ms.
- Use
Promise.all / asyncio.gather for concurrent I/O — sequential awaits serialize work unnecessarily.
Common mistakes
Sequential awaits that should be parallel. Two independent await fetch(urlA) then await fetch(urlB) calls run sequentially. Use Promise.all([fetch(urlA), fetch(urlB)]) to run them concurrently.
Mixing sync and async APIs. fs.readFileSync inside an async Express handler blocks the loop for the duration of the read.
Unhandled promise rejections. In Node.js 15+, unhandled rejections crash the process. Attach .catch() or use a top-level try/catch in async/await.
Forgetting to await a promise. The function returns immediately with a pending Promise; the work runs later, and any errors are silently swallowed.
Using setTimeout(fn, 0) to yield. This delays by at least one macrotask cycle (~4 ms minimum in browsers). Use queueMicrotask for immediate scheduling or setImmediate (Node.js) for after-I/O scheduling.
What to skip
- Async for CPU-bound tasks without worker threads — async does not help; it still runs on one thread.
- Deeply nested callback pyramids — async/await has been the standard since 2017; there is no reason to write callback-based code in new projects.
- Rolling your own event loop — Node.js, asyncio, Tokio, and libuv are battle-tested; custom loops have edge cases that take years to discover.
FAQ
Why does Node.js use a single thread if CPUs have many cores?
The event loop itself is single-threaded, but Node.js uses libuv's thread pool (default 4 threads) for file I/O and crypto, and worker_threads for user CPU work. The single-threaded event loop is a choice for simplicity of programming model, not a technical constraint.
What is the difference between process.nextTick and Promise.then in Node.js?
Both are microtasks, but process.nextTick callbacks drain first (before Promise callbacks), regardless of registration order. In practice, prefer Promise.then / queueMicrotask for predictable ordering.
Does Python asyncio use epoll?
Yes, on Linux. On macOS it uses kqueue. On Windows it uses ProactorEventLoop (IOCP). Python 3.12 switched the default Windows event loop to ProactorEventLoop to match the platform's native async I/O model.
Can two event loops run on different threads?
Yes. Node.js worker threads each have their own event loop. Python allows running separate asyncio event loops in different threads. Communication between loops goes through queues or thread-safe callbacks.
Where to go next