Posted on ::

When building an event loop with epoll (or kqueue), you have to choose a notification mode. This decision affects how your event loop behaves and how much code you need to write correctly.

Level-triggered: "this file descriptor IS ready"

Level-triggered is the default and simpler mode.

Behavior: You get notified every time you call epoll_wait() while data is available.

// 100 bytes arrive on socket
epoll_wait() -> socket ready
read(50 bytes)
epoll_wait() -> socket ready (still 50 bytes left)
read(50 bytes)

If you don't read all the data, the next epoll_wait() will notify you again. It's forgiving.

Edge-triggered: "this file descriptor BECAME ready"

Edge-triggered only notifies you on state changes.

Behavior: You get notified only when the state changes from "not ready" to "ready".

// 100 bytes arrive on socket
epoll_wait() -> socket ready
read(50 bytes)
epoll_wait() -> (blocks forever - no notification!)

If you don't read all the data, you won't get notified again until NEW data arrives. This is the gotcha.

The correct edge-triggered pattern

You must drain the socket completely each time:

epoll_wait() -> socket ready
while (read() != EAGAIN) {  // Read until socket is exhausted
    // process data
}

EAGAIN means "would block" - the socket is empty, no more data available. Only then can you safely return to epoll_wait().

Example scenario

100 bytes arrive:

Level-triggered:

epoll_wait() -> socket ready
read(50 bytes)  // Partial read is fine
epoll_wait() -> socket ready  // Notified again
read(50 bytes)

Edge-triggered (wrong):

epoll_wait() -> socket ready
read(50 bytes)  // Partial read
epoll_wait() -> (deadlock - no notification, 50 bytes stuck)

Edge-triggered (correct):

epoll_wait() -> socket ready
while ((n = read(buf, sizeof(buf))) > 0) {
    process(buf, n);
}
// Loop exits when read() returns EAGAIN

Why use edge-triggered?

Efficiency. Level-triggered can cause spurious wakeups - you get notified repeatedly for the same data. At scale (thousands of connections), this matters.

Edge-triggered is more efficient because:

  • Fewer syscalls to epoll_wait()
  • No redundant notifications
  • Better for high-throughput servers

Why use level-triggered?

Simplicity. You can read partial data and come back later. The event loop is more forgiving. For learning or low-scale applications, the performance difference doesn't matter.

The tradeoff

Level-triggered is simpler logically, you can read partial data but at the cost of more syscalls and slightly less efficiency

Edge-triggered you have to drain the socket completely, if not you block. It is a bit more complex on error handling but more efficient. I would guess if you are building your own event loop you do care about efficiency.

What I'm using

For my key-value store learning project, I'm using level-triggered. The performance hit is ~10-20% at massive scale, which I'll never reach. The simplicity is worth it for a toy project.

If I were building a production proxy server handling 100k+ connections, edge-triggered would matter. Not that I would ever build my own event loop for production, I'm not that crazy.

The kqueue note

This all applies to kqueue (macOS/BSD) too, with slightly different terminology. kqueue has EV_CLEAR flag (edge-triggered) vs. default behavior (level-triggered). Same concepts, different API.

Choose based on your use case. Don't cargo-cult edge-triggered just because it's "more efficient" - the complexity cost might not be worth it for your workload.

Table of Contents