How reused connections eliminate handshake costs
Every new TCP and TLS connection costs several network round trips before the first useful byte reaches the client, and naive implementations pay that setup cost again on every single request. HTTP Keep-Alive, connection pools between PHP-FPM and backend services, and modern HTTP/2 and HTTP/3 multiplexing eliminate exactly this overhead. This article shows how Nginx, Redis, MySQL, and Guzzle clients deliberately reuse connections.
Table of Contents
- 1. TCP and TLS handshakes: the hidden cost of every new connection
- 2. HTTP Keep-Alive: reusing one open connection for multiple requests
- 3. Configuring keep-alive in the web server: keepalive_timeout and keepalive_requests
- 4. Upstream keep-alive: connection pools between reverse proxy and backend
- 5. Connection pooling for backend services: payment, search, Redis, MySQL
- 6. Naive PHP code: why a fresh cURL/Guzzle client per request wastes overhead
- 7. Using persistent connections correctly: Redis, PDO, and the tradeoffs
- 8. Domain sharding: an anti-pattern from the HTTP/1.1 era
- 9. HTTP/2/HTTP/3 multiplexing and what it means for Magento/Hyva
- 10. Summary
- 11. FAQ
1. TCP and TLS handshakes: the hidden cost of every new connection
Before a browser or a backend service can send even a single application byte over a new connection, it first has to complete the TCP three-way handshake: the client sends a SYN packet, the server responds with SYN-ACK, and the client confirms with ACK. That costs one full round trip time (RTT) before any HTTP request is even possible. On HTTPS, the TLS handshake stacks on top of that: certificate exchange, key negotiation, and the cryptographic confirmation that both sides derived the same session key. Under TLS 1.2, that takes two more round trips in the default configuration, three RTT in total before the first response byte arrives at the client. On a mobile connection with 150 to 200 milliseconds of latency to the server, that alone means 450 to 600 milliseconds spent purely on connection setup, before a single byte of payload has moved.
TLS 1.3 noticeably reduces this overhead: the client already sends its key material in the first Client Hello, so Server Hello, certificate, and the Finished message all come back in a single response. That pushes the TLS portion down to one round trip, combined with the TCP handshake that means two RTT until the first response byte. With session resumption via pre-shared keys from a previous connection, TLS 1.3 even enables 0-RTT for repeat connections to the same host, with the first request piggybacking on the initial data packet. The difference between two and three RTT sounds small, but it multiplies fast once a page loads dozens of external resources from different hosts, or a backend service opens a fresh connection to an API hundreds of times per second instead of reusing an existing one.
2. HTTP Keep-Alive: reusing one open connection for multiple requests
Under HTTP/1.0, a connection was meant for exactly one request by default: after the response, the server closed the TCP connection, and the entire handshake process started over for the next request. HTTP/1.1 changed that by making persistent connections the default behavior. Via the Connection header, client and server signal that the connection stays open after a response, so the next request can use the same already-established TCP and TLS connection. This is how a browser loads HTML, CSS, JavaScript, and images from the same domain over a small number of reused connections, instead of paying for a new handshake per resource. That reduces not just latency but also CPU load on both client and server, since TLS key negotiation is a computationally expensive operation that repeats on every new handshake.
Keep-alive is not unlimited by default, though: an open connection ties up memory and a worker slot on the server for as long as it exists, even when no request is currently running. Servers therefore have to strike a balance between reusing connections as long as possible to save handshake costs, and a timeout that frees up unused connections so resources aren't blocked by idle clients. That balance is controlled through concrete configuration values in the web server, which the next section covers in detail.
3. Configuring keep-alive in the web server: keepalive_timeout and keepalive_requests
Nginx controls the behavior of client-facing keep-alive connections through two central directives. keepalive_timeout defines how long a connection stays open, idle, after the last response before Nginx actively closes it, typically 60 to 75 seconds in production setups. keepalive_requests limits how many requests can be processed over a single connection before it's forcibly rebuilt, 1000 by default. This second value exists to avoid memory fragmentation in long-lived worker processes and to spread load more evenly across available workers when a page loads a large number of resources over few connections.
On top of pure keep-alive configuration, TLS session resumption pays off: with ssl_session_cache and session tickets enabled, a client that reconnects can reuse a previously negotiated session key instead of running through the full TLS handshake again. That's especially effective for returning visitors and for backend-to-backend connections that reconnect at short intervals.
# nginx.conf - Keep-Alive tuning for client-facing connections
http {
keepalive_timeout 65s; # close idle client connections after 65s
keepalive_requests 1000; # allow up to 1000 requests per connection
server {
listen 443 ssl http2;
server_name shop.mironsoft.de;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets on; # enable session resumption, avoid full handshake
}
}
4. Upstream keep-alive: connection pools between reverse proxy and backend
Client-facing keep-alive configuration only covers the connection between browser and Nginx. For connections that Nginx itself opens as a reverse proxy to a backend service, say an internal search API or a microservice, a separate configuration applies. Without an explicit setting, Nginx closes the connection to the upstream after every single request and rebuilds it from scratch for the next client request, even though the same target host is being addressed. That causes the same handshake overhead server-side that keep-alive is meant to avoid client-side.
The fix is an upstream keep-alive pool: the keepalive directive inside an upstream block defines how many already-open connections are kept ready for reuse per worker process. That requires proxy_http_version 1.1 and a cleared Connection header, since HTTP/1.0 connections don't support reuse and the Connection header would otherwise carry hop-by-hop semantics that prevent reuse. The same principle applies to setups with load balancers in front of multiple backend instances: a pool of persistent connections per backend target is strictly cheaper than establishing a new connection per forwarded request.
# nginx.conf - upstream keepalive pool for a reverse-proxied backend service (e.g. search API)
upstream opensearch_backend {
server 10.0.1.10:9200;
server 10.0.1.11:9200;
keepalive 32; # persistent connection pool, reused across client requests
keepalive_timeout 60s;
keepalive_requests 500;
}
server {
location /internal/search/ {
proxy_pass http://opensearch_backend;
proxy_http_version 1.1; # keepalive requires HTTP/1.1
proxy_set_header Connection ""; # clear hop-by-hop Connection header
}
}
5. Connection pooling for backend services: payment, search, Redis, MySQL
A typical Magento request on PHP-FPM triggers several outbound connections on its own: a call to a payment gateway for payment status, a search query to OpenSearch or Elasticsearch for a category page, multiple Redis calls for cache and session, and at least one MySQL connection for product data. Each of these connections, if naively rebuilt per request, pays the full TCP and, where applicable, TLS handshake price from section one, multiplied by the number of concurrent requests hitting the server.
Because PHP-FPM handles requests in isolated, short-lived processes, true connection pooling in the sense of a connection pool shared across multiple workers isn't readily possible, the way Node.js or a Java application server would offer it. What does work are persistent connections within a single FPM worker process, which survive beyond the end of a request and get reused on the next request that same worker happens to handle. To check whether that reuse actually kicks in, ss -tnp | grep :6379 shows the number of active connections to Redis, while redis-cli INFO stats with the total_connections_received field measures how often a new connection was actually established.
6. Naive PHP code: why a fresh cURL/Guzzle client per request wastes overhead
A common pattern in codebases that have grown organically: a new Guzzle or cURL instance gets created directly inside the method that makes the external call, instead of instantiating it once and reusing it. new Client() inside a payment or search integration that gets called multiple times per request creates a fresh set of curl handles on every call, with no knowledge of connections already open to the same host. The result: even within a single PHP request with three sequential calls to the same API, three full TCP and TLS handshakes get paid for, when one would have sufficed.
The fix is trivial, yet often overlooked in practice: the HTTP client gets instantiated once, for example as a singleton via dependency injection, and reused for every call within the same request. Guzzle's default handler with CurlMultiHandler keeps the underlying curl connections open between calls, as long as the same client instance is used. Important to understand: this reuse only applies within the lifetime of a single PHP process, meaning a single request under classic PHP-FPM. Reuse across requests additionally requires persistent connections, or a worker-based runtime like Swoole or RoadRunner that keeps PHP processes alive long-term.
<?php
declare(strict_types=1);
namespace Mironsoft\Payment\Client;
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Handler\CurlMultiHandler;
/**
* Wrong: a fresh Guzzle client (and fresh curl handles) is created on every call,
* paying a full TCP + TLS handshake for each request even to the same host.
*/
final class NaivePaymentClient
{
public function charge(array $payload): array
{
$client = new Client(); // new connection pool created and discarded each call
$response = $client->post('https://api.payment-gateway.example/v1/charge', [
'json' => $payload,
]);
return json_decode((string) $response->getBody(), true);
}
}
/**
* Right: one shared Guzzle client instance (injected via DI) reuses its
* underlying curl handles across all calls within the same request lifecycle.
*/
final class PooledPaymentClient
{
private Client $client;
public function __construct()
{
$this->client = new Client([
'base_uri' => 'https://api.payment-gateway.example/',
'timeout' => 5.0,
// CurlMultiHandler keeps curl handles alive between requests
'handler' => HandlerStack::create(new CurlMultiHandler()),
]);
}
public function charge(array $payload): array
{
$response = $this->client->post('v1/charge', ['json' => $payload]);
return json_decode((string) $response->getBody(), true);
}
}
7. Using persistent connections correctly: Redis, PDO, and the tradeoffs
For Redis, the phpredis extension supports persistent connections through pconnect() instead of connect(), which survive beyond the end of a request inside the PHP-FPM worker process and get reused directly on the next request handled by that same worker, without a new TCP handshake or re-running AUTH authentication. Magento's own Redis cache configuration in env.php supports this natively through the 'persistent' => '_magentoPersistentRedis' option in the cache backend block, which makes the cache adapter automatically use persistent connections tagged with a unique identifier.
The tradeoff: with N parallel FPM workers, you get up to N permanently open connections to the Redis server, even when no request is currently active. Redis' maxclients limit has to be sized accordingly, otherwise new connection attempts fail with "ERR max number of clients reached" once the worker count scales up. A similar principle, and a similar risk, applies to MySQL with PDO::ATTR_PERSISTENT: persistent PDO connections save handshake time, but can carry transaction state, locked tables, or session variables from a previous request into the next one if nothing is explicitly reset. In practice, PDO::ATTR_PERSISTENT is only advisable with careful monitoring of max_connections, or alternatively an external connection pooler like ProxySQL in front of the database.
<?php
declare(strict_types=1);
// Redis: persistent connection survives across requests handled by the same
// PHP-FPM worker process, avoiding a new TCP handshake + AUTH on every request.
$redis = new Redis();
$redis->pconnect('127.0.0.1', 6379, 2.5, 'fpm-worker-pool-1');
$redis->auth('secret-password');
$redis->select(1);
// PDO: ATTR_PERSISTENT reuses the MySQL connection across requests too,
// but beware of leaked transaction state and session variables between requests.
$pdo = new PDO(
'mysql:host=127.0.0.1;dbname=magento;charset=utf8mb4',
'magento_user',
'secret-password',
[
PDO::ATTR_PERSISTENT => true,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]
);
// Always reset session-scoped state explicitly when reusing persistent connections
$pdo->exec('SET SESSION sql_mode = "STRICT_TRANS_TABLES"');
8. Domain sharding: an anti-pattern from the HTTP/1.1 era
Under HTTP/1.1, browsers historically limited the number of concurrent connections per hostname, usually to six. For pages with many static resources, such as product images, CSS, and JavaScript files, that limit quickly became a bottleneck: even if bandwidth would have allowed it, no more than six files could load in parallel from the same domain. Domain sharding established itself as a workaround: static assets got artificially spread across multiple subdomains like static1.example.com, static2.example.com, and img.example.com, so the browser could open six fresh connections per subdomain again, adding up to significantly more parallelism overall.
The price for that was conveniently left out of the conversation during the HTTP/1.1 era: every additional subdomain requires its own DNS lookup, its own TCP handshake, and, on HTTPS, its own TLS handshake, each carrying the full overhead from section one. Four shards mean four separate connection setups that can't be reused across each other, even if they ultimately get served by the same physical server or CDN edge. On a high-latency connection, that extra setup overhead can partially or entirely eat up the time gained from parallelism.
9. HTTP/2/HTTP/3 multiplexing and what it means for Magento/Hyva
HTTP/2 removed the original reason for domain sharding: over a single TCP connection, an arbitrary number of streams can transfer in parallel without blocking each other, known as multiplexing. The browser no longer needs six connections per host to achieve parallelism; it can request dozens of resources simultaneously over a single, already-established connection. HTTP/3, built on QUIC over UDP, goes even further and eliminates head-of-line blocking at the transport level too, which can still occur under HTTP/2 due to TCP packet loss. Domain sharding is not just unnecessary under either protocol, it's actively counterproductive: it forces extra handshakes, prevents connection reuse, and slices the multiplexing benefit into exactly as many pieces as there are shards.
For a Magento and Hyva stack, that translates into concrete guidance: static assets, CDN, and the main domain should be consolidated onto a single, HTTP/2- or HTTP/3-capable origin wherever possible, instead of carrying forward historically grown static1/static2 subdomains. At the Nginx level, that's enabled with listen 443 ssl http2;, and at the CDN provider level HTTP/2 is typically the default today and just needs to not be blocked by outdated legacy configuration. The same consolidation logic applies to backend service clients: one reused HTTP client instead of many separate instances, persistent connections to Redis and MySQL instead of reconnecting per request, and an upstream keep-alive pool between Nginx and internal services. The di.xml example below shows how a reusable HTTP client gets wired up cleanly through dependency injection, instead of being freshly instantiated in every class.
<?xml version="1.0"?>
<!-- di.xml: register a shared, connection-pooled HTTP client for outbound API calls -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<virtualType name="Mironsoft\Payment\Client\PooledGuzzleClient" type="GuzzleHttp\Client">
<arguments>
<argument name="config" xsi:type="array">
<item name="base_uri" xsi:type="string">https://api.payment-gateway.example/</item>
<item name="timeout" xsi:type="number">5</item>
<!-- shared handler keeps curl connections alive across calls -->
<item name="handler" xsi:type="object">Mironsoft\Payment\Client\PooledHandlerStack</item>
</argument>
</arguments>
</virtualType>
<type name="Mironsoft\Payment\Gateway\ChargeService">
<arguments>
<argument name="httpClient" xsi:type="object">Mironsoft\Payment\Client\PooledGuzzleClient</argument>
</arguments>
</type>
</config>
Each of the techniques covered here addresses the same underlying mechanism from a different angle: reusing an already-paid-for connection setup as often as possible, instead of paying for it again. The table below summarizes the cost differences.
| Scenario | Without pooling/keep-alive | With pooling/keep-alive | Recommended setting |
|---|---|---|---|
| New HTTPS connection (TLS 1.2) | 2-3 RTT before first byte | 0 RTT when reused | Enable TLS 1.3, session resumption |
| Sequential requests to the same host | Handshake on every request | One connection for all requests | keepalive_requests >= 1000 |
| PHP-FPM to Redis | New connect + auth per request | Persistent connection per worker | pconnect() with maxclients tuning |
| Nginx to backend upstream | New connection per client request | Reused upstream pool | keepalive 32; in the upstream block |
| Static assets across multiple subdomains | DNS + handshake per shard | One consolidated, multiplexed origin | Remove domain sharding, use HTTP/2 |
Mironsoft
Network performance, connection pooling, and backend tuning for Magento stores
Ready to eliminate connection overhead in your stack?
We analyze handshake overhead, keep-alive configuration, and connection pooling across your Magento and Hyva stack, from Nginx through PHP-FPM to Redis, MySQL, and external APIs.
Network audit
Analysis of TCP/TLS handshakes, keep-alive, and multiplexing in your traffic
Backend tuning
Configuring connection pools for Redis, MySQL, OpenSearch, and payment APIs
Monitoring setup
Setting up connection reuse metrics and regression alerts
10. Summary
Connection pooling and keep-alive solve the same underlying problem from different angles: an already-paid-for connection setup, TCP handshake plus, where applicable, TLS handshake, is too expensive to pay for again on every single request. HTTP Keep-Alive keeps client-facing connections open across multiple requests, with keepalive_timeout and keepalive_requests controlling the balance between reuse and resource consumption. Upstream keep-alive pools carry the same principle over to the connection between reverse proxy and backend services.
At the PHP level, freshly instantiated Guzzle or cURL clients cause unnecessary handshake overhead, while persistent connections to Redis and MySQL avoid that cost within a single FPM worker process, though with clear tradeoffs around connection limits and session state. Domain sharding, once a sensible workaround for the HTTP/1.1 connection limit, has become a pure anti-pattern under HTTP/2 and HTTP/3 multiplexing, forcing extra handshakes instead of avoiding them. A consolidated, well-configured stack built on a few reused connections beats the old strategy of many parallel, short-lived connections on every measured metric.
Connection Pooling and Keep-Alive - The Essentials at a Glance
TCP/TLS handshake
Every new connection costs 1 to 3 RTT before the first byte. TLS 1.3 cuts that to 1 RTT, session resumption to 0 RTT.
HTTP Keep-Alive
keepalive_timeout and keepalive_requests in the web server, keepalive in the upstream block for backend pools.
Backend connection pooling
Persistent connections to Redis, MySQL, and search avoid repeated handshake overhead per PHP-FPM request.
No more domain sharding
HTTP/2 and HTTP/3 multiplexing make the old six-connections-per-host workaround unnecessary and counterproductive.