An event loop is code that waits for events, then runs the right piece of work for each event.
An event loop is code that waits for events, then runs the right piece of work for each event.
An event can be many things:
| Event | Meaning |
|---|---|
| A socket is ready | Network data can be read or written |
| A timer expired | A scheduled time has arrived |
| A file operation finished | The result is ready |
| A process exited | A child process has ended |
| A signal arrived | The operating system reported something |
The main idea is simple:
while program is running:
wait for something to happen
handle what happenedThat is an event loop.
Why Event Loops Exist
A server may have thousands of connections.
A simple threaded design might create one thread per connection. That can work for small systems, but thousands of threads can become expensive. Each thread needs memory, scheduling, and coordination.
An event loop uses a different model. Instead of blocking one thread per connection, it asks the operating system:
Tell me when something is ready.Then the program handles only the connections that can make progress.
This is useful for I/O-heavy programs.
Blocking Code
Blocking code waits until an operation finishes.
const n = try socket.read(buffer[0..]);If no data is ready, the call may wait.
In a simple program, that is fine. In a server with many connections, blocking on one connection can prevent the program from serving others.
Evented Code
Evented code waits for readiness.
Instead of saying:
read now, even if it blocksit says:
wake me when this socket can be readThen the event loop waits for many possible events at once.
When one event is ready, the loop handles it.
A Very Small Event Loop
Here is a toy event loop. It does not use real operating-system I/O. It only shows the structure.
const std = @import("std");
const Event = enum {
timer,
input,
shutdown,
};
fn getNextEvent() Event {
return .shutdown;
}
pub fn main() void {
var running = true;
while (running) {
const event = getNextEvent();
switch (event) {
.timer => {
std.debug.print("timer fired\n", .{});
},
.input => {
std.debug.print("input ready\n", .{});
},
.shutdown => {
std.debug.print("shutdown\n", .{});
running = false;
},
}
}
}The pattern is:
while (running) {
const event = getNextEvent();
handle(event);
}Real event loops use operating-system APIs instead of getNextEvent.
The Operating System Does the Waiting
Different operating systems provide different event mechanisms.
| System | Common mechanism |
|---|---|
| Linux | epoll, io_uring |
| macOS, BSD | kqueue |
| Windows | IOCP |
| Portable libraries | wrap these platform APIs |
You do not need to understand all of these at the beginning. The key idea is that the operating system can watch many things at once and report which ones are ready.
Zig’s standard library can build higher-level I/O interfaces on top of these lower-level mechanisms.
Event Loops and Async
Async code often needs an event loop.
When async work cannot finish immediately, something must remember it and resume it later.
That “something” is usually an event loop or an I/O backend.
The flow looks like this:
start async read
register interest in socket readiness
return to event loop
event loop waits
socket becomes ready
event loop resumes the read
future completes
await receives resultThe programmer sees futures and await.
The runtime or I/O backend handles the waiting and resuming.
Event Loop vs Thread Pool
An event loop and a thread pool solve different problems.
| Tool | Best for |
|---|---|
| Event loop | Many waiting I/O operations |
| Thread pool | CPU work or blocking operations |
| One thread per task | Simple concurrency with fewer tasks |
An event loop is good when most tasks are waiting.
A thread pool is good when tasks need CPU time or must use blocking APIs.
Many real systems use both. An event loop handles sockets and timers. A thread pool handles CPU-heavy work or blocking calls that cannot be made evented.
Callbacks
Older event-loop code often uses callbacks.
A callback is a function passed to another function to be called later.
fn onReadable() void {
std.debug.print("socket is readable\n", .{});
}The event loop stores onReadable and calls it when the socket is ready.
The problem with heavy callback code is that control flow becomes harder to read. The program’s logic is split across many small functions.
Async and futures try to make evented code look closer to ordinary sequential code.
Futures
A future is a handle to a result that may arrive later.
In event-loop code, a future often represents work registered with the loop.
future = start operation
event loop waits
operation finishes
future becomes ready
await returns resultThis lets you write:
const future = io.async(readFile, .{path});
defer future.cancel(io) catch {};
const contents = try future.await(io);The event loop may be involved behind the scenes, but the code remains readable.
Do Not Block the Event Loop
This is the most important event-loop rule.
If code inside the event loop blocks for a long time, the whole loop stops handling other events.
Bad:
fn handleRequest() void {
expensiveCpuWork();
blockingFileRead();
}While this function runs, the event loop cannot process other ready events.
Better:
handle small event
start async operation or send work to thread pool
return to event loopEvent-loop handlers should usually be short.
Long CPU Work Belongs Elsewhere
Suppose a request needs to compress a large file.
Compression is CPU-heavy. If the event loop performs the compression directly, other clients may wait.
A better design is:
event loop receives request
send compression job to worker thread
event loop continues handling other events
worker completes job
event loop sends responseThis keeps the event loop responsive.
State Machines
Event loops often turn programs into state machines.
A connection may move through states:
waiting for request
reading headers
reading body
processing request
writing response
closedEach event moves the connection forward.
const ConnectionState = enum {
waiting_for_request,
reading_headers,
reading_body,
processing,
writing_response,
closed,
};The event loop receives readiness events, then updates the connection state.
This is one reason evented systems can feel more complex than simple blocking code. The program must remember where each operation paused.
Async and futures help hide some of this state machine, but the state still exists.
Timers
Timers are a common event-loop feature.
A timer says:
wake this task after this amount of timeTimers are useful for timeouts, retries, scheduled cleanup, heartbeats, and periodic jobs.
A server might use timers like this:
| Timer use | Example |
|---|---|
| Request timeout | Close connection if no request arrives |
| Retry delay | Try again after 500 ms |
| Heartbeat | Send ping every 30 seconds |
| Cleanup | Remove idle sessions every minute |
Timers are events too. When time passes, the event loop wakes the waiting task.
Event Loops Need Explicit Lifetimes
Event-loop code often stores handles, buffers, callbacks, futures, and state objects.
That means lifetime rules matter.
If the event loop may use an object later, that object must stay alive.
Bad shape:
fn registerRead(loop: *Loop) void {
var buffer: [1024]u8 = undefined;
loop.readLater(&buffer);
}The buffer disappears when the function returns.
Good shape:
const Connection = struct {
buffer: [1024]u8 = undefined,
};Store the buffer inside an object that lives as long as the connection.
The same rule appeared with threads and async futures: do not pass a pointer to data that may disappear too early.
Shutdown
A good event loop needs a shutdown path.
A simple loop has a running flag:
var running = true;
while (running) {
const event = waitForEvent();
switch (event) {
.shutdown => running = false,
else => handle(event),
}
}A real system also needs to cancel pending work, close sockets, release memory, join worker threads, and flush logs.
Shutdown should be designed early, not added as an afterthought.
Error Handling
Event-loop errors need clear ownership.
Ask:
Who owns this connection?
Who closes it on error?
Who frees its buffers?
Who reports the error?
Who decides whether the whole loop stops?
For example, a single bad client connection should usually close only that connection. But a serious error in the listening socket may stop the server.
Make that distinction explicit in code.
Beginner Mental Model
An event loop is a traffic controller.
It does not do all the work itself. It watches many possible events, chooses the next ready one, and dispatches work.
Good event-loop code has these properties:
| Property | Meaning |
|---|---|
| Handlers are short | The loop stays responsive |
| Blocking work is avoided | One task does not freeze all tasks |
| State is explicit | Each connection or task knows where it is |
| Lifetimes are clear | Registered data remains valid |
| Shutdown is planned | Pending work can be cleaned up |
Event loops are the foundation of many high-concurrency programs. They are especially useful when a program handles many I/O operations that spend most of their time waiting.