WierdX — Programming Reference All tutorials →
Developer reference · Practical tutorials · CS fundamentals
Concepts & Theory

Async/Await and the Event Loop Explained

Async/await is syntactic sugar over a mechanism called the event loop. Understanding the event loop — what it is, what it cannot do, and what blocks it — turns async programming from a source of mysterious bugs into a tool you can reason about clearly.

Published June 25, 2026

Web servers spend most of their time waiting. A request handler queries a database, waits for the response, calls an external API, waits for the response, reads a file from disk, waits. On a traditional thread-per-request server, each of those waits holds a thread idle. Threads consume memory (typically 1–8 MB of stack each) and context-switching overhead. Scale to ten thousand concurrent connections and you need ten thousand threads — before any of them has done meaningful work.

The event loop is a different model: a single thread runs a loop that continuously checks for I/O events that have completed and dispatches callbacks to handle them. While waiting for a database response, the thread handles other requests. No thread is ever idle waiting for a network byte.

The event loop, step by step

At its core, the event loop is a tight loop around a system call that asks the operating system "which of these I/O operations are ready?" (epoll on Linux, kqueue on macOS, IOCP on Windows). The OS parks the process until at least one operation is ready, then returns a list of ready file descriptors. The loop picks up the associated callback and runs it to completion before checking for more ready events.

# Simplified model of an event loop
def run_event_loop(initial_tasks):
    ready_queue = deque(initial_tasks)
    io_registry = {}          # fd -> callback

    while ready_queue or io_registry:
        # Run all currently ready callbacks
        while ready_queue:
            callback = ready_queue.popleft()
            callback()        # runs to completion, may register new I/O

        # Block until OS reports an I/O event
        if io_registry:
            ready_fds = os_wait_for_io(io_registry.keys())
            for fd in ready_fds:
                ready_queue.append(io_registry.pop(fd))

The key property: each callback runs to completion. The loop does not preempt a running callback. This is cooperative concurrency — each task yields control voluntarily when it needs to wait for I/O. If a callback runs for 500 ms without yielding, every other task waits 500 ms. This is what "blocking the event loop" means, and it is the central hazard of the model.

What async/await compiles to

In JavaScript and Python, async/await is syntax that the runtime transforms into a state machine. An async function is rewritten as a function that returns a promise (JS) or a coroutine object (Python). Each await expression becomes a suspension point: the state machine pauses here, hands control back to the event loop, and resumes at this exact line when the awaited value is ready.

// This async function...
async function fetchUser(id) {
    const row = await db.query("SELECT * FROM users WHERE id = ?", [id]);
    const profile = await profileService.get(row.username);
    return { ...row, profile };
}

// ...behaves roughly like this state machine:
function fetchUser(id) {
    return new Promise((resolve, reject) => {
        db.query("SELECT * FROM users WHERE id = ?", [id])
            .then(row => profileService.get(row.username)
                .then(profile => resolve({ ...row, profile }))
                .catch(reject)
            )
            .catch(reject);
    });
}

The async/await syntax is strictly more readable. The state machine behavior is identical. Understanding that await suspends the current function without blocking the thread is the key insight: other callbacks can run while your function is suspended waiting for the database.

Non-blocking I/O vs threads: the tradeoff

Threads and the event loop are both valid concurrency models with different cost profiles.

Threads have natural backpressure: if you create a thread pool of 200 threads and 201 requests arrive simultaneously, the 201st waits in a queue. Memory usage is bounded by pool size. But context switching between threads has overhead, shared mutable state requires locks (and locks introduce deadlocks, priority inversion, and contention), and a stuck thread (blocking on a slow disk) reduces pool capacity.

The event loop scales to far more concurrent I/O operations because waiting is free: a suspended coroutine costs a few hundred bytes of state, not a full thread stack. Node.js handles tens of thousands of concurrent WebSocket connections on a single process for this reason. The cost is that CPU-bound work must be explicitly pushed off the event loop thread to a worker pool — otherwise it blocks every other connection.

# Python: CPU-bound work blocks the event loop -- do not do this
import asyncio

async def handle_request():
    result = slow_cpu_computation()    # blocks for 2 seconds; all other requests wait
    return result

# Correct: run CPU work in a thread pool executor
async def handle_request():
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(None, slow_cpu_computation)
    return result

Common mistakes that block the event loop

The event loop model breaks when any callback takes too long. The mistakes that cause this:

Synchronous I/O calls in async code. Using requests.get() instead of httpx.AsyncClient.get() in a Python async function, or reading a file with fs.readFileSync in a Node.js request handler. The synchronous call blocks the OS thread, which blocks the entire event loop, for the full duration of the I/O operation.

# Wrong: synchronous HTTP inside async function
async def fetch_prices(symbols):
    results = []
    for symbol in symbols:
        r = requests.get(f"https://api.example.com/price/{symbol}")  # BLOCKS
        results.append(r.json())
    return results

# Correct: async HTTP, and concurrent
async def fetch_prices(symbols):
    async with httpx.AsyncClient() as client:
        tasks = [client.get(f"https://api.example.com/price/{s}") for s in symbols]
        responses = await asyncio.gather(*tasks)
    return [r.json() for r in responses]

CPU-intensive loops without yielding. Parsing a 50 MB JSON blob, running a complex computation, or a tight loop over millions of records will monopolize the thread. Push it to a thread pool or a subprocess, or break it into chunks with periodic await asyncio.sleep(0) to yield control.

Forgotten awaits. Calling an async function without await returns a coroutine object without executing it. JavaScript Promises without .catch silently swallow exceptions. Both are silent failures that produce hard-to-debug behavior.

// JavaScript: missing await -- save() never runs
async function updateRecord(id, data) {
    const record = await db.find(id);
    record.update(data);
    db.save(record);      // forgot await: returns Promise, doesn't execute
}

// Python: calling coroutine without await -- emits a RuntimeWarning
async def process():
    send_notification()   # forgot await: coroutine object created, then garbage collected

Structured concurrency with gather and TaskGroup

Running multiple async operations concurrently requires explicit orchestration. The naive sequential approach defeats the purpose of async:

# Sequential: total time = sum of all wait times
async def load_dashboard(user_id):
    user    = await get_user(user_id)        # 50ms
    orders  = await get_orders(user_id)      # 80ms
    stats   = await get_stats(user_id)       # 60ms
    return user, orders, stats               # 190ms total

# Concurrent: total time = longest single wait
async def load_dashboard(user_id):
    user, orders, stats = await asyncio.gather(
        get_user(user_id),
        get_orders(user_id),
        get_stats(user_id),
    )
    return user, orders, stats               # ~80ms total

Python 3.11 added asyncio.TaskGroup, which provides structured concurrency with better error propagation: if any task raises an exception, all remaining tasks are cancelled and the exception is re-raised at the group boundary. This prevents orphaned tasks from running past the point where the caller has given up.

async def load_dashboard(user_id):
    async with asyncio.TaskGroup() as tg:
        t_user   = tg.create_task(get_user(user_id))
        t_orders = tg.create_task(get_orders(user_id))
        t_stats  = tg.create_task(get_stats(user_id))
    # All tasks complete (or all are cancelled) by the time we exit the block
    return t_user.result(), t_orders.result(), t_stats.result()

Understanding the event loop as a single-threaded cooperative scheduler makes async code predictable: one thing runs at a time, await is where control transfers, and anything that does not await holds the thread exclusively. With that mental model, the bugs — the missing awaits, the synchronous calls, the CPU hogs — become obvious rather than mysterious.