cURL error 28 means an operation exceeded its time limit. The message tells you which one if you read it carefully: connection timed out after N milliseconds points at the network, while operation timed out after N milliseconds with X bytes received points at a server that answered and then took too long.
That distinction is the whole diagnosis. One means you never reached the other end. The other means you reached it fine and it was slow. They have completely different causes and completely different fixes, and treating them as the same error is why this one gets debugged badly.
Table of contents
- Reading the message
- When it is a connection timeout
- When it is an operation timeout
- The WordPress version
- Setting timeouts on purpose
- Handling the failure properly
- How this fits the rest of the stack
- FAQ
Reading the message
Two messages, two meanings:
- Connection timed out after 5001 milliseconds — the TCP connection was never established. DNS resolution failed or hung, the host is unreachable, or a firewall dropped the packets silently. Nothing was received.
- Operation timed out after 30000 milliseconds with 0 bytes received — the connection succeeded and the server sent nothing before the deadline. The network is fine; the server is slow or hung.
- Operation timed out after 30000 milliseconds with 45231 bytes received — the transfer started and did not finish in time. Usually a large response over a slow link, or a server streaming slowly.
The bytes-received count is the most informative part and the part people skip. Zero bytes means the server never started responding. A non-zero count means it did and stalled — a different problem entirely.
The corresponding cURL options: CURLOPT_CONNECTTIMEOUT governs the first case, CURLOPT_TIMEOUT governs the whole operation and covers the other two.
When it is a connection timeout
Zero bytes and a connection-phase message means you never got there. Check in this order, cheapest first:
- DNS. Resolve the hostname from the same machine. A DNS failure inside a container with a misconfigured resolver is extremely common and presents as a connection timeout rather than a DNS error.
- Reachability. Try connecting to the port directly. A refused connection comes back instantly; a dropped one hangs. Hanging means a firewall is discarding packets silently, which is the default behaviour of most cloud security groups.
- Outbound egress rules. Many hosting environments restrict outbound connections. This is why a request works on your laptop and times out from a server — the code is identical and the network policy is not.
- IPv6. If the hostname resolves to an AAAA record and your network has broken IPv6, cURL tries v6 first and waits. Forcing IPv4 with the -4 flag is a fast test.
The IPv6 case is worth knowing because it produces intermittent timeouts that make no sense — some requests succeed, some hang, depending on resolution order. If -4 makes the problem disappear, that is your answer.
When it is an operation timeout
The connection worked and the response did not arrive in time. Now the question is whether the remote server is genuinely slow or something in between is.
Get the timing breakdown rather than guessing:
curl -w 'dns: %{time_namelookup}s connect: %{time_connect}s tls: %{time_appconnect}s ttfb: %{time_starttransfer}s total: %{time_total}s\n' -o /dev/null -s https://api.example.com/endpoint
Read the gaps between the numbers:
- A large time_namelookup is slow DNS.
- A large gap between time_connect and time_appconnect is a slow TLS handshake — often an incomplete certificate chain forcing the client to fetch an intermediate.
- A large gap between time_appconnect and time_starttransfer is the server thinking. This is the common case and it is the server’s problem.
- A large gap between time_starttransfer and time_total is a slow transfer — bandwidth or a large payload.
This single command usually ends the investigation, because it separates four phases that all present as one timeout.
The WordPress version
cURL error 28 appears constantly in WordPress, where it usually surfaces as a failed update check, a plugin unable to reach its licence server, or a site health warning about loopback requests.
The loopback case is the distinctive one. WordPress makes HTTP requests to itself for cron, the theme editor, and site health checks. When those time out, the causes are specific:
- The site cannot resolve its own domain, because DNS resolves to a public address the server cannot reach from inside its own network. Adding a hosts file entry pointing the domain at 127.0.0.1 fixes it.
- Only one PHP worker is available. The loopback request queues behind the request that made it, and both time out. This is a deadlock, not a slow server, and it is common on very small PHP-FPM pools.
- A firewall or security plugin blocking self-requests, which is a surprisingly common misconfiguration.
That second one is worth recognising because raising the timeout makes it worse rather than better — the request is waiting on a worker that cannot free up until the request finishes.
To raise WordPress’s HTTP timeout when the remote genuinely is slow:
add_filter( 'http_request_timeout', function() { return 30; } );
Treat that as a workaround. A timeout is a symptom, and a 30-second external call inside a page load is a bad outcome even when it succeeds.
Setting timeouts on purpose
Most error 28 incidents trace back to a default nobody chose. The values worth setting deliberately:
- Connect timeout: 3 to 5 seconds. Establishing a TCP connection either happens quickly or is not going to happen. Waiting 30 seconds to discover a host is unreachable is pure waste.
- Total timeout: as low as the operation allows. For an API call in a request path, a few seconds. Anything longer and you are holding a worker while a third party has problems.
- Always set both. cURL’s default connect timeout is 300 seconds and the default total timeout is unlimited. Neither is a sensible production value.
The reason short timeouts matter more than they look: an outbound call with no timeout inside a request handler turns a slow third party into your outage. Your workers fill up waiting, and every endpoint starts failing including the ones that make no external calls at all.
Short timeouts plus a retry with backoff handle transient problems better than a long timeout does, and they fail fast when the problem is not transient.
Handling the failure properly
Once timeouts are set correctly, you will get more of them — that is the point. So the code has to do something reasonable when one arrives.
- Retry idempotent requests with exponential backoff and a small random jitter. Two or three attempts clears most transient failures.
- Never blindly retry non-idempotent requests. A POST that timed out may have succeeded on the server. Retrying charges the card twice. Use an idempotency key if the API supports one.
- Degrade rather than fail. If the external call enriches a page rather than being the page, render without it.
- Log the timing breakdown, not just the failure. Knowing whether it was connect or transfer turns a recurring mystery into a specific fault.
The second point is the one that causes real damage. A timeout means you do not know the outcome — not that the operation failed.
How this fits the rest of the stack
The common thread in nearly every error 28 is a timeout that was inherited rather than chosen, in a call whose failure was never planned for. The fix is mostly configuration, and the thing that makes it tractable is being able to see the request and the response in the same place. Runtime logs sitting next to the deploy that introduced a change turn a recurring timeout into a specific commit, and outbound behaviour is easier to reason about when environment variables are set per service rather than baked into files. Services on RunxBuild covers that, and the RunxBuild hosting calculator itemises what the service and database cost together.
Useful related references:
- SSL_connect Error 5: What SSL_ERROR_SYSCALL Actually Means
- The 504 Gateway Timeout Error, in Plain English
- Math Domain Error Explained: Meaning and How to Debug
- Services on RunxBuild
FAQ
What does cURL error 28 mean?
An operation exceeded its time limit. Read the message carefully — connection timed out means the TCP connection was never established, while operation timed out with N bytes received means the server responded and was too slow to finish.
What is the difference between connect timeout and total timeout?
CURLOPT_CONNECTTIMEOUT limits how long establishing the TCP connection may take. CURLOPT_TIMEOUT limits the entire operation including the transfer. Set both — the defaults are 300 seconds and unlimited respectively.
Why does WordPress report loopback request timeouts?
WordPress makes HTTP requests to itself for cron and site health. They fail when the server cannot resolve its own domain internally, when only one PHP worker is available so the request deadlocks, or when a security plugin blocks self-requests.
How do I find which phase is slow?
Use curl -w with time_namelookup, time_connect, time_appconnect, time_starttransfer, and time_total. The gaps between them separate DNS, TCP, TLS, server think time, and transfer time.
Should I retry after a timeout?
Retry idempotent requests with exponential backoff. Never blindly retry a POST or payment — a timeout means the outcome is unknown, not that it failed, so the operation may have succeeded and retrying would duplicate it.