The ReactPHP Event Loop in Depth: Timers, Streams and Promises
AI generated
<?php
8.4
PHP · ReactPHP · Event Loop · Async
The ReactPHP Event Loop in Depth
Understanding timers, streams and promises

The ReactPHP event loop is the heart of every asynchronous PHP application built on React: a single thread that manages timers, watches file descriptors and runs callbacks in a clear order. Anyone who understands the tick cycle, the timer queue and how streams and promises interact writes code that truly does not block, instead of just looking like it does.

18 min read Event Loop · Timers · Streams · Promises PHP 8.4 · react/event-loop

1. Why the ReactPHP event loop exists

PHP was originally designed around a request response model: one process, one request, one end. The ReactPHP event loop breaks with that model without changing the language itself. It is a library that keeps a single PHP process alive continuously and repeatedly checks in a loop which timers have expired and which file descriptors are ready to read or write. Instead of starting a separate process or thread for every connection, a single ReactPHP event loop handles thousands of simultaneous connections cooperatively.

The decisive difference from classic PHP is that there is no kernel preemptively interrupting the code. Every task must voluntarily return control to the ReactPHP event loop, usually by registering an operation and returning immediately instead of waiting for the result. This cooperative model is the reason a single PHP process running ReactPHP can handle more simultaneous network connections than a classic PHP FPM pool with the same number of worker processes, as long as the work itself is predominantly I/O bound rather than CPU bound.

An important distinction: the ReactPHP event loop does not replace CPU intensive computation through magic. It merely shifts the waiting time for network, disk and timers into a structure that can perform other work during that wait. This single principle explains every design decision covered in the rest of this article, from the timer queue to stream processing.

2. The tick cycle: what happens in every round

The ReactPHP event loop works in rounds called ticks. In every tick, the loop implementation walks through a fixed order: first all timers whose expiry time has been reached are checked, then all registered streams are checked for read and write readiness, and finally any registered signals are processed. This order is deterministic, which makes debugging much easier because behavior stays reproducible given the same inputs.

Internally, before every tick the ReactPHP event loop computes a timeout: the time until the next due timer. This timeout is passed to the underlying system function, usually stream_select, ev_run or uv_run, depending on the installed backend. If no streams are active but a timer is scheduled in the future, the process blocks exactly until the timer becomes due or a stream event arrives, whichever happens first. This minimizes unnecessary CPU load without losing responsiveness.

A tick only ends once every callback due in that round has finished running. If a callback registers a new timer or stream during its own execution, it is only considered in the next tick, never in the same one. This rule prevents infinite loops within a single tick and is a key difference from naive hand rolled polling loops, which do not provide this guarantee without an explicit implementation.


<?php

declare(strict_types=1);

use React\EventLoop\Loop;

// Get the global event loop instance (react/event-loop 1.3+)
$loop = Loop::get();

$loop->addTimer(1.0, function (): void {
    echo "Tick after 1 second\n";
});

$loop->addPeriodicTimer(0.5, function (): void {
    echo "Periodic tick every 500ms\n";
});

echo "Loop starts now, script does not block here\n";

// The loop keeps the process alive until explicitly stopped
$loop->run();

echo "This line only runs after $loop->stop() was called\n";

3. Timers: addTimer and addPeriodicTimer in detail

The ReactPHP event loop offers two basic timer methods. addTimer registers a one time callback that runs after a given number of seconds, fractions of a second included. addPeriodicTimer registers a callback that repeats at regular intervals until it is explicitly removed via cancelTimer. Both methods return a timer object needed for later removal, a detail that is easy to miss and then leads to timers that can never be stopped again.

Internally the ReactPHP event loop does not manage timers as a simple list but as a priority queue sorted by expiry time. This lets the loop implementation find the next due timer with constant effort instead of scanning every registered timer on every tick. With thousands of simultaneously active timers, for example connection timeouts in a server with many clients, this difference is what separates an application that scales linearly with the connection count from one that scales quadratically.

A common trap: a periodic timer whose callback itself runs longer than the interval does not pile up calls inside the ReactPHP event loop. The next invocation only happens once the current callback has fully returned, and then relative to the actual execution time, not the originally scheduled time. Anyone needing precise timing, for example metrics exports, should measure the actual elapsed time themselves rather than blindly trusting the interval value.

4. Streams: reading and writing without blocking

Network I/O is the main reason the ReactPHP event loop exists at all. The react/stream library offers ReadableResourceStream and WritableResourceStream as wrappers around native PHP stream resources put into non blocking mode. Instead of calling fread and waiting, the ReactPHP event loop registers the underlying file descriptor with the operating system and only invokes the registered callback once data is actually available.

These streams emit events following the observer pattern: data for new data, end when the other side closes the connection cleanly, error on failures and close once the resource has been finally released. This model allows building complex processing chains without ever writing a single line of blocking code, because every step in the chain only reacts once data is actually available.

Important for practice: backpressure has to be actively respected when writing. If an application writes to a stream faster than the other side can read, an internal buffer inside the ReactPHP event loop grows without bound and memory consumption keeps rising. The write method returns a boolean that signals whether writing should continue or whether the code should wait for the drain event before sending more data.


<?php

declare(strict_types=1);

use React\EventLoop\Loop;
use React\Socket\SocketServer;
use React\Socket\ConnectionInterface;

$loop = Loop::get();
$server = new SocketServer('0.0.0.0:8090', [], $loop);

$server->on('connection', function (ConnectionInterface $connection): void {
    $connection->on('data', function (string $chunk) use ($connection): void {
        // Echo back with a simple protocol prefix
        $response = "ECHO: " . trim($chunk) . "\n";

        // Respect backpressure: write() returns false if the buffer is full
        $canWriteMore = $connection->write($response);

        if (!$canWriteMore) {
            $connection->once('drain', function () use ($connection): void {
                error_log('Write buffer drained, ready for more data');
            });
        }
    });

    $connection->on('close', function (): void {
        error_log('Client disconnected');
    });
});

echo "Server listening on port 8090\n";
$loop->run();

5. Promises: managing results of asynchronous operations

Once several asynchronous operations inside the ReactPHP event loop depend on each other, plain callback chaining quickly becomes unmanageable, a problem known in the JavaScript world as callback hell. The react/promise library solves this with the promise pattern: an operation immediately returns a promise object that is later either fulfilled or rejected once the actual result becomes available inside the ReactPHP event loop.

The then method registers callbacks for success and failure and itself returns another promise, which allows chaining: $promise->then($onSuccess, $onError). If the callback inside then itself returns another promise, the chain automatically waits for it to resolve before running the next step. This chaining forms the foundation on which libraries such as react/http and react/mysql are built, keeping complex asynchronous workflows readable.

Functions such as React\Promise\all and React\Promise\race coordinate multiple promises simultaneously inside the ReactPHP event loop. all waits until every passed promise is fulfilled and returns an array of all results in the original order. race resolves as soon as the first promise finishes, which fits timeout patterns: pitting a promise for the actual operation against a promise for a timer and letting the faster one win.


<?php

declare(strict_types=1);

use React\EventLoop\Loop;
use React\Promise\Promise;
use function React\Promise\all;

$loop = Loop::get();

function fetchUserAsync(int $id): Promise
{
    global $loop;

    return new Promise(function (callable $resolve, callable $reject) use ($id, $loop): void {
        // Simulated async database call using a timer
        $loop->addTimer(0.2, function () use ($resolve, $id): void {
            $resolve(['id' => $id, 'name' => "User {$id}"]);
        });
    });
}

$promises = [
    fetchUserAsync(1),
    fetchUserAsync(2),
    fetchUserAsync(3),
];

// Wait for all three fake database calls in parallel, not sequentially
all($promises)->then(function (array $users): void {
    foreach ($users as $user) {
        echo "Loaded: {$user['name']}\n";
    }
});

$loop->run();

6. Loop backends: StreamSelect, Ev and Uv compared

The ReactPHP event loop is an abstraction over several interchangeable implementations, called loop backends. If no PHP extension is installed, the library falls back to StreamSelectLoop, which internally uses the native stream_select function. This implementation works everywhere PHP runs but has a practical upper limit: on many systems stream_select cannot monitor more than 1024 file descriptors simultaneously by default.

If the ext-ev or ext-uv PHP extension is installed, the ReactPHP event loop automatically selects ExtEvLoop or ExtUvLoop respectively. These implementations bind to the libev and libuv C libraries, which internally use more efficient system calls such as epoll on Linux or kqueue on BSD and macOS. The result is significantly more simultaneous connections combined with lower CPU load, because monitoring no longer scales linearly with the number of descriptors.

For most projects StreamSelectLoop is entirely sufficient, especially when the number of simultaneous connections stays in the low thousands. Only servers that need to hold tens of thousands of simultaneous long lived connections, for example WebSocket gateways or chat systems, notably benefit from installing ext-ev. The ReactPHP event loop API stays identical throughout, switching backends requires no code change, only installing the matching PHP extension.

7. The biggest danger: blocking code in the loop

The ReactPHP event loop runs in a single thread. Any callback that takes longer than milliseconds blocks every other timer and stream during that time, because the loop can only continue once that callback fully completes. A single synchronous file_get_contents call to a slow external URL or an unoptimized SQL query through the classic PDO driver can freeze the entire server for every simultaneous client.

This is the most important mental shift between classic PHP and running inside the ReactPHP event loop: in classic PHP FPM, a slow request only affects that one worker process, other requests continue unaffected in parallel processes. In the event loop model, a blocking call immediately affects every simultaneously managed connection, because there is no process boundary to contain the impact.

The solution is to consistently use asynchronous counterparts to classic functions: react/mysql instead of PDO, the react/http client instead of cURL, react/filesystem instead of file_get_contents. For rare cases where no asynchronous counterpart exists, it makes sense to offload the blocking work to a separate child process, for example via react/child-process, so the ReactPHP event loop itself stays responsive while the child process does the heavy work.

8. Signals and clean shutdown

A long running process hosting the ReactPHP event loop must be able to react to operating system signals, especially when managed under Supervisor, systemd or inside a Docker container. The addSignal method registers a callback for signals such as SIGTERM or SIGINT without having to manually wire the PHP pcntl extension into ticks, the ReactPHP event loop handles this integration itself.

A clean shutdown in practice means: upon receiving SIGTERM, new incoming connections are rejected immediately, existing connections get a short grace period to finish their current request, and only afterward is $loop->stop() called to end the ReactPHP event loop. Without this sequence, a hard stop tears down active connections and produces client side errors that would be avoidable with an orderly shutdown.

In containerized environments the grace period before a hard kill is usually limited, often ten seconds by default under Kubernetes and Docker Compose. The shutdown callback inside the ReactPHP event loop should be aware of this limit and, if necessary, terminate hard itself once it expires rather than trusting the orchestration layer to wait indefinitely.


<?php

declare(strict_types=1);

use React\EventLoop\Loop;

$loop = Loop::get();
$activeConnections = [];
$shuttingDown = false;

function gracefulShutdown(&$shuttingDown, array &$activeConnections, $loop): void
{
    $shuttingDown = true;
    error_log('SIGTERM received, refusing new connections');

    // Give active connections 5 seconds to finish, then force stop
    $loop->addTimer(5.0, function () use ($loop): void {
        error_log('Grace period expired, stopping loop now');
        $loop->stop();
    });
}

$loop->addSignal(SIGTERM, function () use (&$shuttingDown, &$activeConnections, $loop): void {
    gracefulShutdown($shuttingDown, $activeConnections, $loop);
});

$loop->addSignal(SIGINT, function () use ($loop): void {
    error_log('SIGINT received, stopping immediately');
    $loop->stop();
});

echo "Server running, press Ctrl+C to stop\n";
$loop->run();

9. Event loop concepts compared directly

The ReactPHP event loop offers multiple possible tools for many tasks. Which one fits depends on the concrete use case, from the expected number of simultaneous operations to the required precision of timing.

Task Unsuitable in the loop Recommended tool Reason
One time timeout sleep(1) addTimer(1.0, …) Does not block the loop
HTTP request curl_exec() react/http Browser Non blocking, promise based
Database access PDO::query() react/mysql Does not wait synchronously on the network
Waiting for multiple results nested callbacks React\Promise\all() Readable, flat structure
Tens of thousands of connections StreamSelectLoop ExtEvLoop / ExtUvLoop epoll/kqueue instead of select()

The table shows a consistent pattern: every row replaces a synchronous, blocking call with an asynchronous counterpart that immediately gives control back to the ReactPHP event loop. Anyone who applies this pattern consistently across the entire application code ends up with a server that handles loads with a single process for which classic PHP would need many parallel worker processes.

Mironsoft

Asynchronous PHP architectures and ReactPHP consulting

A server that truly works concurrently, not just looks like it?

We analyze existing PHP applications for blocking spots, design ReactPHP based event loop architectures and support migrations from classic PHP FPM to long running, asynchronous services.

Architecture review

Identify and prioritize blocking calls in existing code

ReactPHP implementation

Design and build servers, workers and clients on an event loop basis

Operations & monitoring

Graceful shutdown, signal handling and loop backend selection for production

10. Summary

The ReactPHP event loop solves a fundamental problem of classic PHP applications: the impossibility of efficiently waiting on many simultaneous, slow I/O operations within a single process. The tick cycle with its fixed order of timer checking, stream polling and signal processing makes behavior predictable. Timers via addTimer and addPeriodicTimer, streams via react/stream and coordination through promises together form the foundation on which practically every ReactPHP application is built.

The biggest challenge remains discipline: every blocking call inside the ReactPHP event loop freezes the entire application, because there is no process boundary to limit the damage. Anyone who consistently relies on asynchronous libraries, respects backpressure when writing and registers signals for a clean shutdown ends up with a server that handles a surprisingly large number of simultaneous connections with minimal resources, far beyond what a classic PHP FPM pool with comparable hardware would achieve.

The ReactPHP Event Loop in Depth — The Essentials at a Glance

Tick cycle

Fixed order per round: check timers, poll streams, process signals. Deterministic and reproducible.

Timers & streams

addTimer/addPeriodicTimer for timing, react/stream for non blocking I/O with backpressure handling.

Promises

then, all and race coordinate multiple asynchronous results without nested callbacks.

Backends & blocking

StreamSelect for most cases, Ev/Uv for high scale. Never run blocking calls in the loop thread.

11. FAQ: The ReactPHP Event Loop in Depth

1What exactly is the ReactPHP event loop?
A library that keeps a single PHP process alive continuously, manages timers and watches file descriptors to cooperatively process multiple operations at once.
2Difference from PHP FPM?
FPM starts an independent worker process per request. The event loop keeps a process alive continuously and processes many connections cooperatively in one thread.
3What happens with blocking code?
The entire loop stands still until the call returns. All other timers and streams wait, because no process boundary contains the effect.
4Which loop backend to use?
StreamSelectLoop is mostly sufficient. With tens of thousands of long lived connections ext-ev or ext-uv pays off for epoll/kqueue based scaling.
5What are promises for?
They structure results of asynchronous operations without nested callbacks. then chains, all waits in parallel, race takes the fastest result.
6What is backpressure?
Arises when writing faster than reading. write() signals through its return value whether to continue writing or wait for drain.
7Can I use PDO?
Technically yes, but every query blocks the entire loop. For real concurrency use react/mysql or a comparable asynchronous driver.
8Reaction to SIGTERM?
addSignal registers a callback that rejects new connections, gives active ones a grace period and then cleanly stops the loop via stop().
9Does a periodic timer run exactly?
Only as long as the callback runs shorter than the interval. If longer, the next invocation shifts relative to the actual execution time.
10When does the event loop pay off?
Whenever the workload is predominantly I/O bound, for example many network connections, WebSockets or queue consumers. No advantage for CPU bound tasks.