The core idea: one thread, a queue, and a loop
An event loop is a way to get concurrency — many logically-independent operations making progress "at the same time" — without using multiple OS threads at all. The mechanism is almost embarrassingly simple: a single thread runs a loop that repeatedly (1) pulls the next ready callback/continuation off a queue, (2) runs it to completion, and (3) goes back to step 1. There is no preemption anywhere in this picture — nothing pauses a running callback partway through to give another one a turn. Whatever piece of code currently holds the thread keeps it until it returns.
This is the single most important thing to internalize about event loops, because it flips the intuition you built up from OS threads (Topic 1) upside down:
- OS threads: the scheduler can preempt you at essentially any instruction, so you need locks even for what looks like "a few lines of code" touching shared state.
- Event loop callbacks: nothing else runs concurrently with your callback on that loop. As long as your callback doesn't itself yield (e.g. hit an
await), you have exclusive access to any state you touch — no locks, no races, by construction.
That single property — cooperative scheduling instead of preemptive scheduling — is the whole reason event-loop-based systems (a browser tab's JS engine, Node.js, Python's asyncio) can get away with being single-threaded for so much of their work, and it's also exactly where they bite you (see Pitfalls below).
How you get I/O concurrency without extra threads
The trick isn't magic, it's just moving the waiting out of your thread and into the operating system. The pattern:
- Your code issues a non-blocking I/O call (e.g. "start reading this socket") and immediately returns — it does not sit there waiting for bytes to arrive.
- The runtime registers interest in that file descriptor with an OS-level readiness-notification facility —
epoll on Linux, kqueue on BSD/macOS, IOCP on Windows. These let the kernel track thousands of sockets and tell you, in one call, exactly which ones became ready, instead of you polling each one individually.
- The event loop's job, each iteration, includes asking the OS "which of the things I registered interest in are ready now?" and pushing a callback for each ready one onto its queue.
- Your callback runs, does its (non-blocking) read, and returns control back to the loop.
The upshot: one thread can have thousands of sockets "in flight" simultaneously, because at any instant it's doing real work for at most one of them and the OS is doing the actual waiting for the rest. This is precisely how a single-threaded Node.js process or a single-threaded asyncio program can serve thousands of concurrent connections — there's no per-connection thread, just a per-connection registration with the kernel's readiness API.
Node.js and Python's asyncio as concrete illustrations: both are built on exactly this design, and it's worth naming them explicitly because they're the two systems you'll most often be asked about. Node.js's loop is implemented by libuv; Python's is implemented in the asyncio module itself (pluggable, but conceptually the same shape). Neither one is a different idea — they're the same event-loop-plus-readiness-notification pattern, just in different languages.
The trade-off: great for I/O, useless for CPU
Because there's no preemption and (in the pure single-threaded case) no parallelism, an event loop gives you zero speedup for CPU-bound work. If a callback spends 500ms doing arithmetic, the loop is frozen for 500ms — nothing else on that loop runs, including I/O callbacks whose data has already arrived. This is the origin of the universal event-loop-community mantra "don't block the event loop": any synchronous, CPU-heavy operation dropped into a callback stalls everything else sharing that loop, not just its own logical task.
This is also why real systems built on an event loop are never purely single-threaded in practice:
- Node.js runs your JavaScript on one thread, but
libuv maintains a separate worker thread pool (4 threads by default) to which it offloads things that have no non-blocking OS API — DNS lookups, some filesystem operations, crypto functions like pbkdf2. Your JS callback fires on the main loop when the worker thread finishes, but the actual blocking work happened elsewhere.
- Python's
asyncio event loop itself is genuinely single-threaded — there's no hidden thread pool doing your awaits for you by default. If you need to run blocking or CPU-bound code without freezing the loop, you explicitly hand it off yourself via loop.run_in_executor() to a thread or process pool (see the "Threads vs. Async" subtopic for when to reach for which).
So the honest framing an interviewer wants to hear: an event loop buys you massive I/O concurrency on one thread by overlapping waiting time across many logical tasks, but it buys you no parallelism — for that you still need multiple OS threads or processes doing real simultaneous work, whether that's Node's worker pool, Python's executor hand-off, or your own thread pool underneath the loop.
Pitfalls and interview gotchas
- Confusing "concurrent" with "parallel." An event loop is concurrent (many tasks in flight, interleaved) but not parallel (no two callbacks literally execute at the same instant on that loop). If asked "does Node.js run my callbacks in parallel," the precise answer is no — it interleaves them cooperatively on one thread, and only truly parallelizes the pieces it can hand off (worker pool, OS-level socket I/O).
- Assuming a long callback only hurts its own task. It hurts every pending callback on that loop, since nothing else can run until it returns. This is the mechanism behind real production incidents ("the server stopped responding to health checks because a request handler did a synchronous JSON.parse on a huge payload").
- Forgetting that "non-blocking" and "instant" aren't the same thing. Non-blocking I/O still takes real wall-clock time to complete — it just doesn't occupy your thread while it does. The callback fires later, on a future loop iteration, once the OS says the data is ready.
- Thinking the event loop is a JavaScript-only concept. It's a general concurrency pattern; JavaScript engines (and Node) are simply the most famous implementation, which is why the terminology ("event loop," "callback queue") often gets described in exclusively-JS terms even though Python, and plenty of C/C++ network servers (Redis's
ae, nginx), use the identical readiness-notification design.
- Missing the task-vs-microtask distinction in JS specifically. Browsers and Node schedule Promise callbacks (microtasks) to drain completely between each "macrotask" (timers, I/O callbacks, UI events) — not interleaved with them. This produces ordering that surprises people the first time they see
setTimeout(fn, 0) run after a Promise.resolve().then(fn2) scheduled afterward. It's a scheduling-priority detail on top of the same core loop, not a different model.
Event loop vs. OS thread scheduling
| Event loop (cooperative) | OS threads (preemptive) |
|---|
| Who decides when to switch tasks | The running callback, by returning or await-ing | The OS scheduler, at any time |
| Need for locks around shared state | Usually no, within a single loop, as long as you never mid-task decide to block | Yes, whenever multiple threads touch the same mutable state |
| A long-running task's effect on others | Freezes everything else on that loop | Other threads keep running (the OS just preempts the hog) |
| How it achieves I/O concurrency | Non-blocking calls + OS readiness notification (epoll/kqueue) on one thread | One (or a pooled) blocking thread per concurrent operation |
| Good for | High-volume I/O-bound work | CPU-bound work, or code you can't easily restructure around non-blocking calls |
The next subtopic builds directly on this: async/await is the syntax that lets you write code that runs on top of this exact loop-and-queue machinery while reading like ordinary, sequential, blocking code.