Concurrency Roadmap/Async & Event-Driven Concurrency

Event Loops & the Single-Threaded Model

One thread, a callback queue, and cooperative (not preemptive) scheduling — how a single-threaded runtime achieves massive I/O concurrency without spawning threads per connection.

!!3/5Theory: 40m1 problems
Language-specific mechanics: Concurrency Language Manual — Async/Await, Futures & Non-Blocking I/O

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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 tasksThe running callback, by returning or await-ingThe OS scheduler, at any time
Need for locks around shared stateUsually no, within a single loop, as long as you never mid-task decide to blockYes, whenever multiple threads touch the same mutable state
A long-running task's effect on othersFreezes everything else on that loopOther threads keep running (the OS just preempts the hog)
How it achieves I/O concurrencyNon-blocking calls + OS readiness notification (epoll/kqueue) on one threadOne (or a pooled) blocking thread per concurrent operation
Good forHigh-volume I/O-bound workCPU-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.

Reference implementations in:

Concurrent I/O on One Thread (No Extra OS Threads)

Three "fetches" that each take time, run concurrently on a single thread by yielding control while they wait instead of blocking it. Total time is close to the slowest fetch, not the sum of all three.

// No event-loop API and no async/await. Blocking I/O parks THIS // goroutine; the runtime netpoller (epoll/kqueue) unblocks it when // the socket is ready — other goroutines keep running. func fetch(name string, d time.Duration) <-chan string { ch := make(chan string, 1) go func() { time.Sleep(d) // stand-in for a blocking read the netpoller wakes ch <- name + " done" }() return ch } users := fetch("users", 100*time.Millisecond) orders := fetch("orders", 250*time.Millisecond) inv := fetch("inventory", 150*time.Millisecond) for i := 0; i < 3; i++ { // ~250ms total, not 500ms select { case <-users: case <-orders: case <-inv: } }

Go is not a single-threaded event loop — don't map Node's model onto it. The netpoller under the scheduler is the event loop: a blocked Read does not pin an OS thread the way a blocking call pins Node's thread. select multiplexes ready channels; there is no async def and inventing one is the wrong answer.

Further Resources (Optional)

Practice Problems

Interview relevancy:!!!Critical — must-have!!Important — highly recommended!Good to have~Niche — rarely asked

Optional Practice (Extra Reps)

For once you've cleared the main set above and want more reps on this pattern. These don't count toward the roadmap's progress stats — solve them purely for your own benefit.