Async PHP in 2026: Guzzle, amphp, or Swoole? We benchmarked all three.

A few weeks ago I published a post about our ReactPHP WebSocket server that's been running in production since 2019. While writing it, I realized something: the async PHP ecosystem has matured to the point where it's genuinely easy to adopt — yet almost nobody uses it in regular application code.

Most PHP developers still reach for async only when they "have to" — real-time features, WebSockets, heavy background processing. But there's a much more common case where it would help: any endpoint that fans out to multiple data sources. Fetch five things from the database. Call three external APIs. You're paying for sequential I/O even though the operations have nothing to do with each other.

I wanted to know: which async approach is actually worth using, and for which problem? So we ran benchmarks.

The scenario

We simulated generating a sales report — a realistic case that mixes database queries and external API calls:

  • 4 regional DB queries — SELECT COUNT(*), SUM(amount) FROM orders WHERE region = ? with 200ms simulated I/O each
  • 1 summary DB query — 150ms
  • 3 exchange rate API calls — 250ms each
  • 2 partner pricing API calls — 250ms each

Sequential total: ~2.4 seconds. Fully concurrent: ~260ms (bounded by the slowest single call).

Latency is simulated with SLEEP() in MySQL and usleep() in a mock API server. That's intentional — it isolates I/O wait, which is exactly what async PHP is designed to address.

Part 1: HTTP-only — Guzzle is already enough

If your bottleneck is parallel HTTP calls only — no database — Guzzle's async API handles it cleanly. No extra dependencies, no event loop, no new mental model.

$client   = new Client();
$promises = [];

foreach ($endpoints as $key => $url) {
    $promises[$key] = $client->getAsync($url);
}

$responses = Utils::unwrap($promises);

Utils::unwrap() fires all requests concurrently via curl_multi and blocks until they all settle. For pure HTTP fan-out, this is all you need.

The numbers confirm it:

ApproachTime (ms)Speedup
Sequential (Guzzle sync)12721.0x
Guzzle async2604.9x
amphp/http-client2724.7x
Swoole coroutines2555.0x

All three async approaches land at ~260ms. Guzzle is just as fast as amphp and Swoole for HTTP-only work. If this is your use case, adding a heavier dependency would solve a problem you don't have.

Part 2: Add database queries — Guzzle can't help anymore

Now add the five database queries. Guzzle's async model only covers HTTP — PDO is synchronous, and there's no way around it with Guzzle alone.

ApproachTime (ms)Speedup
Sequential (PDO + Guzzle sync)23901.0x
Guzzle async (HTTP only, DB sequential)13181.8x
amphp full (DB + HTTP concurrent)2838.4x
Swoole full (DB + HTTP concurrent)2609.2x

Guzzle gets you from 2.4s to 1.3s — a real improvement, but you're still paying for five sequential database queries. amphp and Swoole both hit ~260ms by running everything concurrently: DB queries and HTTP calls in parallel, same scheduler.

The 1.3s Guzzle row is the important one. It shows what "partial async" looks like in practice. A lot of teams would call that a win and stop there, not realizing they're still at 55% of the sequential time.

amphp: no extension required

amphp v3 is built on PHP Fibers (8.1+) and the Revolt event loop. No PECL extension, no special PHP build — composer require amphp/mysql amphp/http-client and you're done.

The same async()/await() pattern works for both database queries and HTTP calls:

$pool       = new MysqlConnectionPool(MysqlConfig::fromString('host=mysql;user=root;password=secret;db=app'));
$httpClient = HttpClientBuilder::buildDefault();

$futures = [];

foreach ($queries as $region => $sql) {
    $futures["db_$region"] = async(fn() => $pool->query($sql)->fetchRow());
}

foreach ($endpoints as $key => $url) {
    $futures[$key] = async(fn() => json_decode(
        $httpClient->request(new Request($url))->getBody()->buffer(), true
    ));
}

$results = await($futures); // all 10 operations running in parallel

Once you learn the concurrency model, it applies everywhere. That's the real value — not the library, but the unified mental model for any I/O.

Swoole: C-level hooks, ~8% faster

Swoole is a C++ extension that patches PHP's I/O layer at runtime. With SWOOLE_HOOK_ALL, standard PDO becomes non-blocking transparently — no code changes required to your queries. The coroutine model uses WaitGroup, which will feel familiar if you've used Go:

Co\run(static function (): void {
    $wg = new Swoole\Coroutine\WaitGroup();

    foreach ($queries as $region => $sql) {
        $wg->add();
        go(function () use ($region, $sql, &$results, $wg): void {
            try {
                $pdo = new PDO('mysql:host=mysql;dbname=app', 'root', 'secret');
                $results["db_$region"] = $pdo->query($sql)->fetch();
            } finally {
                $wg->done(); // always release, even on exception
            }
        });
    }

    $wg->wait();
}, SWOOLE_HOOK_ALL);

The 8% gap between Swoole (260ms) and amphp (283ms) comes from C-level coroutine suspension overhead vs. userland Fibers. In practice, the gap will be smaller on real queries that do actual work beyond sleeping.

The trade-off is operational: Swoole is a C++ extension that needs to be compiled and deployed, which adds complexity to your Docker images and CI pipeline. For most teams, amphp's "composer install and done" is the right call. Swoole makes sense when you need every millisecond and have the infrastructure discipline to manage it.

When to use what

  • Guzzle async — HTTP fan-out, no DB, zero new dependencies. Start here.
  • amphp — mixed DB + HTTP concurrency, no extension required. The right default for most PHP teams.
  • Swoole — when you need maximum throughput and have the ops capacity for a C extension. Also the right choice if you're already running a Swoole-based server.
  • None of the above — if your endpoint does one query and one HTTP call, the added code complexity isn't worth it. Async helps when you have multiple independent I/O operations.

One thing the benchmarks don't show: long-running processes

If you go beyond request-scoped async — running a persistent event loop process — you'll hit a different class of problems that have nothing to do with concurrency.

Our WebSocket server has been running continuously since 2019. Over six years, the operational lessons were harder than the async code itself. One that surprised us recently: after a PHP upgrade, the process memory started growing by 50MB every five minutes.

The culprit was Symfony's deprecation error handler — it collects deprecation notices in memory, and after the upgrade, one of our dependencies triggered thousands of deprecation warnings per request cycle. The handler was doing its job correctly; we just hadn't noticed the cost until the upgrade changed the volume.

Memory leaks in long-running PHP processes are rarely "real" leaks. They're usually accumulation — event listeners that aren't removed, log buffers that aren't flushed, error handlers collecting data you forgot about. The fix is to audit what your framework is holding onto, not just your own code. In this case, disabling the deprecation handler in the production process resolved it immediately, with the proper fix being to update the library causing the deprecations.

The monitoring and restart setup matters as much as the code. We use watchdog timers with memory thresholds that trigger graceful restarts before things go wrong — better than finding out at 3am.

Are you using any of these in production? I'm curious whether teams are reaching for amphp or Swoole — or still leaving the DB queries sequential. Let me know in the comments.

icon

Need help with PHP architecture?

We work on mature PHP systems — performance, modernization, and architecture. Free code review included.

Talk to us

Related posts