When people talk about web server performance, Nginx is almost always one of the first names that comes to mind. It powers online stores, streaming services, API platforms, CDNs, enterprise systems, and millions of ordinary websites. And it’s not just about Nginx’s architecture - it’s also about how effectively it leverages the capabilities of Linux.
One such mechanism is epoll. This mechanism allows a process to simultaneously monitor a huge number of network connections and receive notifications only when something actually happens on any of them. This is precisely why Nginx is capable of handling tens, and sometimes hundreds of thousands, of open connections without creating a separate thread for each client.
In this article, we’ll explore how epoll works, how it differs from select() and poll(), how Nginx uses it, and why epoll alone doesn’t necessarily make an application faster.
Why handling thousands of connections is a challenging task
Imagine a server with 50,000 clients connected to it simultaneously. It sounds daunting, but in reality, most of them aren’t doing anything. Some have already received a page and are waiting for the user’s next action; some have a WebSocket open; others are slowly downloading a file. And only a small fraction of clients are currently sending data or waiting for a response.
Let’s say that out of 50,000 open TCP connections, only about 500 require processing at any given moment. This means that if the server constantly checks the status of all 50,000 sockets, the processor will waste most of its time - since there are almost no useful events, yet every connection must be checked.
Early network servers solved this problem in a fairly straightforward way: a separate process or thread was created for each new client. Of course, with a small number of connections, this model worked perfectly. Each thread served only its own client, so the code was simple and easy to understand.
But as the load increased, the limitations of this method began to surface. Each thread consumes memory, and the operating system has to constantly switch the processor between thousands of threads. At a certain point, a significant portion of resources begins to be spent not on directly processing requests, but on maintaining the parallelism model itself.
That’s when a different approach was needed - a single process had to learn to handle a large number of connections at once. That’s how the select() system call came about, which the application used to pass a list of file descriptors to the kernel and ask which ones were ready for reading or writing.
For its time, this was a major step forward. Now a single process could serve many clients. However, with each call, the entire list of descriptors had to be passed to the kernel anew, and then the kernel had to scan through it completely. In addition, there was the FD_SETSIZElimit, which was ill-suited for a large number of connections.
Later, the poll() system call appeared, which eliminated the FD_SETSIZE limitation, but its operating principle remained virtually unchanged compared to select() - the application still passed an array of descriptors to the kernel and received their status back.
When the number of connections reaches tens of thousands, this approach also begins to put a noticeable load on the processor. And this is precisely where the main challenge for a high-load server comes into play: to quickly determine which connections are actually ready for use and avoid wasting resources checking all the others.
The solution to this problem was the epoll mechanism, which radically changed the approach to handling large numbers of connections.
What Is epoll and How Does It Differ from select() and poll()
epoll is a built-in Linux mechanism for monitoring events on file descriptors. In the case of web servers, this almost always refers specifically to network sockets. And the main difference between epoll and the earlier mechanisms select() and poll() has nothing to do with network speed, but rather with how the application interacts with the operating system kernel.
When using select() and poll(), the application passes the kernel a complete list of descriptors to monitor on every call, and then checks the results again.
epoll works on a different principle: the application registers the sockets of interest once and then simply waits for events. When a socket becomes ready for reading or writing, or another registered event occurs, the kernel notifies the application itself. But if nothing happens, the process waits patiently and does not scan through thousands of inactive connections. This is precisely why epoll is particularly effective in situations where there are a large number of open connections, but only a small fraction of them are active at any given time.
In the table below, we’ve summarized the main differences between all three mechanisms mentioned to make this information clear.
| Mechanism | How descriptors are handled | Limitations | Behavior with a large number of connections |
|---|---|---|---|
select() |
The descriptor set is passed on every call | Limited by FD_SETSIZE |
Scales poorly |
poll() |
The descriptor array is passed on every call | No FD_SETSIZE limitation |
Requires processing a large array on every call |
epoll |
Descriptors are registered once | Mainly limited by operating system resources | Well suited for tens of thousands of connections |
Sometimes their differences are summarized in a single sentence: select() and poll() run in O(n) time, while epoll runs in O(1) time. This explanation helps convey the general idea, although the kernel’s actual inner workings are, of course, much more complex.
For us, as users and administrators, what’s more important is that epoll frees the application from having to repeatedly pass the same list of connections to the kernel and check them one by one. It receives notifications only when an event has actually occurred on a particular socket.
How epoll Works Under the Hood in Linux
To work with epoll, an application typically uses three system calls. First, an epoll instance is created using the system call:
epoll_create1()
Then, the application registers the necessary file descriptors:
epoll_ctl()
For example, you can tell the kernel that you want to be notified when a specific socket becomes ready for reading.
After registration, the application waits for events:
epoll_wait()
This call blocks until an event occurs and returns only those descriptors that require processing.
In simplified terms, the cycle looks like this:

The main idea here is that connections are registered separately, and then the application simply waits for events; there is no longer a need to resubmit the entire list of sockets to the kernel on every cycle.
What events does epoll monitor
An application can subscribe to various types of events. There are many of them, but to understand how a network server works, a few basic ones are sufficient:
| Event | Meaning |
|---|---|
EPOLLIN |
Socket is ready for reading |
EPOLLOUT |
Socket is ready for writing |
EPOLLERR |
An error has occurred |
EPOLLHUP |
The connection has been closed or can no longer be used normally |
For example, a client sent an HTTP request. Data arrived in the socket, and as a result, the kernel signals a read event. The application reads the request and begins to formulate a response.
If the socket is not yet ready to accept the entire amount of data, the server is not required to wait and can freely switch to other connections, after which it can return to this client later when the socket becomes available for writing again. Thanks to this, a single process is capable of efficiently serving a huge number of clients.
Level-triggered and edge-triggered modes
Epoll has two main notification models: level-triggered and edge-triggered. By default, level-triggered is used, meaning that as long as data remains in the socket, the application continues to receive notifications.

Edge-triggered works differently. A notification is sent only when the state changes, so the application must use non-blocking sockets and read or write data until the operation returns, for example, EAGAIN.
| Mode | Behavior | Characteristic |
|---|---|---|
| Level-triggered | Notifications continue while the condition remains true | Easier to implement |
| Edge-triggered | A notification is sent only when the state changes | Requires more careful implementation |
How Nginx Uses epoll and Handles Thousands of Connections
The Nginx architecture is built around an event-driven model, where, instead of creating a separate process or thread for each client, Nginx runs a limited number of worker processes.
A simplified diagram of this model looks like this:

The master process manages the worker processes, loads the configuration, and performs administrative tasks. Client connections are handled by worker processes, each of which is capable of handling a large number of sockets simultaneously.
When a client connects to the server, its connection enters the worker process’s event loop. As long as the client isn’t sending anything, the worker doesn’t spend any CPU time on it. Of course, an open socket consumes memory, a file descriptor, and other system resources, but a separate thread isn’t created for it.
But as soon as an event occurs on the socket, the worker processes it and moves on to the next one.
Suppose Nginx supports 50,000 connections. This does not mean at all that the server is processing 50,000 HTTP requests simultaneously. The actual situation might look something like this: at any given moment, out of those 50,000 connections, 47,000 are waiting for data, 2,500 are periodically active, and only about 500 require immediate processing. In this case, the worker primarily handles those 500 connections that actually require attention, while the rest - though they may remain open - consume almost no processor time.
This model is well-suited for keep-alive, WebSocket, reverse proxy, API gateways, and other scenarios where connections may remain open for a long time.
Therefore, 50,000 open connections and 50,000 concurrently executed requests represent completely different workloads. It is precisely the event-driven architecture, combined with epoll, that allows a single Nginx worker process to serve thousands or tens of thousands of clients.
What Is 'worker_connections' and What Determines the Number of Connections
The maximum number of connections per worker is set by the worker_connectionsparameter:
events {
worker_connections 4096;
}
If there are multiple worker processes, the theoretical limit increases. For example:
worker_processes 4;
events {
worker_connections 4096;
}
This gives us:
4 × 4096 = 16384
However, this number should not be interpreted as the maximum number of simultaneously connected clients, because when Nginx operates as a reverse proxy, it establishes connections not only with clients but also with upstream servers. Therefore, a single request can occupy at least two connections: an incoming client connection and an outgoing connection to the backend.
There are also limitations imposed by the operating system itself, since each open socket uses a file descriptor, and the number of file descriptors available to a process is limited. You can check the current limit using the command:
ulimit -n
If a process is allowed to open only 1,024 file descriptors, simply increasing the 'worker_connections' value - for example, to 50,000 - will not allow a worker to handle 50,000 connections.
The actual limit depends on several parameters:
worker_processes;worker_connections;- file descriptor limits;
- the amount of RAM;
- the number of connections to upstream servers;
- and the nature of the load.
This means that epoll allows Nginx to work efficiently with a large number of descriptors, but it does not remove the limitations imposed by Linux and the server itself.
Why epoll Is Especially Important Today and Where It Is Used
When the Web first emerged, most requests were short - that is, the browser would retrieve a page, and the connection would close shortly thereafter.
Today, however, things are much more complex, as a browser can simultaneously load dozens of resources, make API calls, and maintain long-lived connections. Servers work with WebSocket, Server-Sent Events, streaming, MQTT, reverse proxies, and other technologies. A good example is WebSocket, where a connection can remain open for hours, even though nothing is transmitted through it for most of that time.
Allocating a separate thread that simply waits for the next message is too resource-intensive, whereas the event-driven model allows the connection to remain among thousands of others and be resumed only when data becomes available. That is why epoll has long been used far beyond the scope of Nginx. Tools such as Redis, HAProxy, DNS servers, proxies, message brokers, and other network software on Linux work with it either directly or through libraries. Moreover, developers often do not interact with epoll directly at all.
For example, Node.js uses libuv, which selects the appropriate I/O mechanism for a specific operating system. In Python, this work is abstracted by asyncio; in Java, by Netty; and in Rust, by Tokio. The Go Runtime’s network poller on Linux also uses epoll.
As a result, developers can write asynchronous network code without having to call epoll_wait() themselves; instead, a library or runtime handles the interaction with Linux system mechanisms.
Analogs of epoll and the modern io_uring
Specifically, epoll works only on Linux, while on other operating systems, similar tasks are handled by their own mechanisms:
- kqueue - on BSD and macOS;
- I/O Completion Ports (IOCP) - on Windows;
- event ports - on Solaris.
The design of these mechanisms varies, but they share a common goal: to efficiently handle a large number of I/O operations without creating a separate thread for each connection.
Cross-platform libraries abstract these differences. For example, a Node.js application runs via libuv, and the library automatically selects the appropriate mechanism based on the operating system.
Linux itself has since introduced another interesting mechanism - io_uring - which uses ring queues through which an application submits operations to the kernel and receives information about their completion. This approach reduces the number of system calls and covers a broader range of I/O operations.
io_uring is sometimes called the “new epoll”, although they work differently. Specifically, epoll primarily notifies the application when file descriptors are ready, while io_uring allows you to send I/O operations directly to the kernel and receive information about their completion.
Obviously, their capabilities partially overlap, but io_uring cannot be considered a direct replacement for epoll. The latter is still widely used in network software under Linux, and the event-driven architecture based on it hasn’t gone anywhere.
Does epoll Always Make a Program Faster?
Alas, no. Sometimes you’ll hear the opinion that simply replacing poll() with epoll is enough to make an application run faster immediately. But in reality, it all depends on where the bottleneck occurs. epoll itself does not speed up the processor, reduce network latency, or make SQL queries faster; therefore, if a server spends two seconds performing heavy computations after receiving a request, those two seconds will not disappear.
There’s another issue: if a worker performs a long-running, blocking operation and doesn’t return to the event-handling loop, the other ready connections will also start waiting.
It turns out that Nginx’s high performance is the result of several solutions working together, such as its event-driven architecture, non-blocking I/O, a small number of worker processes, efficient memory management, and the capabilities of the Linux kernel - in other words, epoll itself is just one part of this system.
How to Check If Nginx Uses epoll
On modern Linux systems, there is usually no need to enable anything manually, as this mechanism works “out of the box”. Nginx automatically selects the most suitable event-handling mechanism.
However, if desired, you can specify it explicitly using the following directive:
events {
use epoll;
}
In most cases, this isn’t necessary. The automatic selection is almost always correct.
To view the Nginx version, build parameters, and enabled modules, run the command:
nginx -V
This will show whether the module is enabled, but it won’t confirm that the worker process is currently running via epoll.
If you need a definitive check, however, you can examine the system calls.
First, find the PID of one of the worker processes using the command:
ps aux | grep “nginx: worker”
Then attach to it using strace:
strace -e epoll_create1,epoll_ctl,epoll_wait -p PID
If Nginx is using epoll, the corresponding system calls will appear in the output. It’s also worth noting that the list of calls may vary slightly depending on the versions of the kernel, libc, and Nginx itself. You should be careful when attaching strace to a production server, as it creates additional load; therefore, it’s best to perform such checks on a test machine or during periods of minimal activity.
FAQ or Frequently Asked Questions
What is epoll in simple terms?
epoll is a built-in Linux mechanism that allows an application to monitor a large number of file descriptors, including network sockets, and receive notifications only for those where the desired event has occurred. Thanks to this, the server does not waste time constantly checking all connections but works only with active ones.
How does epoll differ from select and poll?
When using select() and poll(), the application passes a list of relevant descriptors to the kernel each time. With epoll, this list is registered once, after which the application receives only ready events via epoll_wait(). This approach scales better when there are a large number of open connections.
Does Nginx use epoll automatically?
Yes. On Linux, the Nginx web server typically selects epoll itself as the most suitable event-handling mechanism, so manually specifying use epollis usually not necessary.
How many connections can a single worker handle?
This depends on more than just epoll. It is influenced by worker_connections, file descriptor limits, available memory, Linux settings, and the nature of the load. And if Nginx is running as a reverse proxy, you also need to account for connections to upstream servers.
Does Node.js use epoll?
On Linux, yes, since Node.js runs through the libuv library, which uses epoll and other operating system mechanisms. Developers usually do not have to interact directly with the epoll API.
How does epoll differ from io_uring?
epoll notifies the application that a file descriptor is ready for an operation.
io_uring offers a broader model for asynchronous I/O operations through queues of requests and completions. These mechanisms serve different purposes, so io_uring cannot be considered simply a new version of epoll.
Conclusion
Nginx’s high performance is not due to any single “secret mechanism”, but rather to the server’s entire architecture.
epoll, as one of its components, eliminates the need to constantly scan through thousands of sockets and instead allows the server to receive notifications only for those connections where actual work has occurred. This approach is particularly effective when there are many open connections, but only some of them are active at the same time.
However, epoll does not eliminate the limitations of the operating system itself, nor does it speed up slow code. Performance still depends on Nginx settings, file descriptor limits, memory capacity, network parameters, and the speed of the backend database application.
It’s worth remembering that if Nginx is used on a VPS as a reverse proxy, an API gateway, a WebSocket server, or a frontend for a high-load application, then Linux’s capabilities become just as much a part of overall performance as the CPU, memory, and disk subsystem speed. It is precisely the combination of sound architecture and kernel mechanisms that allows even a relatively small server to handle a huge number of network connections.