Table of Contents
The most common PHP cURL timeout problem isn’t that developers don’t know about CURLOPT_TIMEOUT – it’s that they set it wrong, misread the error, or don’t know where in the request the time is actually being lost.
A timeout can fail at three different points: establishing the connection, waiting for the server to start responding, or transferring the data. Each one has a different fix. Throwing a higher number at CURLOPT_TIMEOUT and hoping for the best is why the same problem keeps coming back.
This guide covers every timeout option cURL gives you, how to read error code 28, how to pinpoint exactly where your request is losing time, and how to build retry logic that handles timeouts without hanging your script.
The Three Timeout Options and What Each One Actually Controls
PHP cURL gives you three separate timeout options. Most tutorials only mention one. Using the wrong one — or only one — is why timeout problems are hard to debug.
CURLOPT_CONNECTTIMEOUT
Controls how long cURL waits to establish a connection to the server. If the server doesn’t respond within this time, cURL stops trying and returns error code 28.
<?php
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => "https://example.com",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 10, // give up connecting after 10 seconds
]);
$response = curl_exec($ch);
if (curl_errno($ch) === 28) {
echo "Connection timed out — server did not respond.";
}
curl_close($ch);
?>
Set this between 5-15 seconds depending on how patient you want to be with slow servers. Setting it too low causes false timeouts on legitimately slow connections. Setting it too high means your script hangs for a long time before giving up on a dead server.
CURLOPT_TIMEOUT
Controls the maximum time allowed for the entire request — from start to finish. This includes connection time, waiting for the server to start sending data, and downloading the response.
<?php
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => "https://example.com",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30, // entire request must finish within 30 seconds
]);
$response = curl_exec($ch);
if (curl_errno($ch) === 28) {
echo "Request timed out — took longer than 30 seconds total.";
}
curl_close($ch);
?>
This is the safety net for the whole operation. A request can connect instantly but then take forever to download a large response. CURLOPT_TIMEOUT catches that.
CURLOPT_TIMEOUT_MS
Same as CURLOPT_TIMEOUT but in milliseconds instead of seconds. Useful when you need precise control — for example, when hitting an API that should respond within 500ms and anything slower means something is wrong.
<?php
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => "https://api.example.com/data",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT_MS => 5000, // 5000ms = 5 seconds
]);
$response = curl_exec($ch);
if (curl_errno($ch) === 28) {
echo "API request timed out after 5 seconds.";
}
curl_close($ch);
?>
One important caveat: on Linux systems, if you set CURLOPT_TIMEOUT_MS to less than 1000ms (under 1 second), you may hit a known issue where cURL uses a 1-second minimum internally. For sub-second timeouts, test on your actual server environment first.
Using All Three Together
The correct approach is to set both CURLOPT_CONNECTTIMEOUT and CURLOPT_TIMEOUT on every request. They cover different failure scenarios and work together:
<?php
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => "https://example.com",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 10, // stop trying to connect after 10s
CURLOPT_TIMEOUT => 30, // entire request must finish within 30s
]);
$response = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
curl_close($ch);
if ($errno === 28) {
echo "Timed out: " . $error;
} elseif ($errno) {
echo "cURL error $errno: " . $error;
} else {
echo "Success. Response length: " . strlen($response) . " bytes";
}
?>
Output on success:
Success. Response length: 48291 bytes
Output on connection timeout:
Timed out: Connection timed out after 10001 milliseconds
Output on request timeout:
Timed out: Operation timed out after 30000 milliseconds with 0 bytes received
Notice the error messages are different. “Connection timed out” means the server never responded. “Operation timed out with 0 bytes received” means the connection opened but the server never sent anything back. These point to different problems — the first is a dead or unreachable server, the second is a server that accepted the connection but got stuck processing the request.
Understanding Error Code 28 and What It’s Actually Telling You
When a cURL request times out, PHP sets the error number to 28. Most developers check for this with curl_errno() and print the message without understanding what the number actually means or what to do differently based on where the timeout happened.
How to Detect Error Code 28 Correctly
<?php
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => "https://example.com",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
curl_close($ch);
switch ($errno) {
case 0:
echo "Success." . PHP_EOL;
break;
case 6:
echo "Could not resolve host — DNS lookup failed." . PHP_EOL;
break;
case 7:
echo "Failed to connect — server refused the connection." . PHP_EOL;
break;
case 28:
echo "Timeout: " . $error . PHP_EOL;
break;
case 35:
echo "SSL handshake failed." . PHP_EOL;
break;
default:
echo "cURL error $errno: " . $error . PHP_EOL;
}
?>
Handling error codes individually matters because the fix for each one is different. A code 6 (DNS failure) means you have a network issue or the domain doesn’t exist — increasing your timeout won’t help. A code 7 (connection refused) means the server is actively rejecting requests — again, not a timeout problem. Code 28 is the only one where adjusting timeout values is the right response.
Reading the Error Message to Find Where the Timeout Happened
The string returned by curl_error() on a code 28 tells you exactly where time ran out. There are three distinct messages:
<?php
// Message 1 — connection phase timed out
// "Connection timed out after 10001 milliseconds"
// Cause: server never responded to the TCP handshake
// Fix: increase CURLOPT_CONNECTTIMEOUT or check if server is reachable
// Message 2 — waiting for first byte timed out
// "Operation timed out after 30000 milliseconds with 0 bytes received"
// Cause: connection opened but server sent nothing back
// Fix: increase CURLOPT_TIMEOUT, server may be overloaded
// Message 3 — transfer stalled mid-download
// "Operation timed out after 30000 milliseconds with 14brevity bytes received"
// Cause: download started but slowed to a crawl or stopped
// Fix: increase CURLOPT_TIMEOUT or use CURLOPT_LOW_SPEED_LIMIT
?>
The bytes received number in message 3 is useful — if you’re getting 0 bytes it’s a server processing problem, if you’re getting partial bytes the connection dropped mid-transfer.
Logging Timeout Errors With Useful Context
Printing the error to screen is fine for development. In production you want to log it with enough context to debug later:
<?php
function handle_curl_error($ch, $url) {
$errno = curl_errno($ch);
$error = curl_error($ch);
$info = curl_getinfo($ch);
if ($errno === 0) {
return true; // no error
}
$logEntry = sprintf(
"[%s] Error %d on %s | Connect time: %.2fs | Total time: %.2fs | Message: %s",
date('Y-m-d H:i:s'),
$errno,
$url,
$info['connect_time'],
$info['total_time'],
$error
);
file_put_contents(__DIR__ . '/curl_errors.log', $logEntry . PHP_EOL, FILE_APPEND);
return false;
}
// Usage
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => "https://example.com",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
$success = handle_curl_error($ch, "https://example.com");
curl_close($ch);
if (!$success) {
echo "Request failed. Check curl_errors.log for details.";
}
?>
Example log output:
[2026-05-01 14:23:11] Error 28 on https://example.com | Connect time: 0.00s | Total time: 30.00s | Message: Operation timed out after 30000 milliseconds with 0 bytes received
The connect time of 0.00s combined with 0 bytes received tells you the connection never actually opened — despite cURL reporting a timeout rather than a connection refused error. This happens when a server accepts the TCP connection but then sits idle without sending an HTTP response. The log entry gives you that diagnosis in one line instead of having to reproduce the problem manually.
The One Mistake That Makes Error 28 Misleading
If you set CURLOPT_TIMEOUT lower than CURLOPT_CONNECTTIMEOUT, the total timeout fires before the connection timeout gets a chance to. You’ll get error 28 with a message that looks like a connection timeout but is actually the total timeout firing first:
<?php
// Wrong — CURLOPT_TIMEOUT is lower than CURLOPT_CONNECTTIMEOUT
curl_setopt_array($ch, [
CURLOPT_CONNECTTIMEOUT => 30, // wait 30s to connect
CURLOPT_TIMEOUT => 10, // but total request only gets 10s
]);
// Result: always times out at 10s regardless of connection speed
// The connection timeout of 30s never gets reached
// Correct — total timeout is always higher than connection timeout
curl_setopt_array($ch, [
CURLOPT_CONNECTTIMEOUT => 10, // 10s to connect
CURLOPT_TIMEOUT => 30, // 30s total — leaves 20s for data transfer
]);
?>
CURLOPT_TIMEOUT must always be higher than CURLOPT_CONNECTTIMEOUT. If it isn’t, the connection timeout setting is effectively ignored.
Using curl_getinfo() to Find Exactly Where Time Is Being Lost
Setting timeouts blindly — just throwing 30 or 60 seconds at CURLOPT_TIMEOUT — means you never actually know where your request is slow. curl_getinfo() gives you a breakdown of every phase of the request so you can pinpoint the problem instead of guessing.
The Timing Breakdown
<?php
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => "https://books.toscrape.com/",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
$info = curl_getinfo($ch);
curl_close($ch);
echo "DNS lookup time: " . round($info['namelookup_time'], 4) . "s" . PHP_EOL;
echo "Connection time: " . round($info['connect_time'], 4) . "s" . PHP_EOL;
echo "SSL handshake time: " . round($info['appconnect_time'], 4) . "s" . PHP_EOL;
echo "Time to first byte: " . round($info['starttransfer_time'], 4). "s" . PHP_EOL;
echo "Total time: " . round($info['total_time'], 4) . "s" . PHP_EOL;
echo "Downloaded: " . $info['size_download'] . " bytes" . PHP_EOL;
echo "Download speed: " . round($info['speed_download'] / 1024, 2) . " KB/s" . PHP_EOL;
echo "HTTP status: " . $info['http_code'] . PHP_EOL;
?>
Output:
DNS lookup time: 0.0021s
Connection time: 0.0843s
SSL handshake time: 0.2341s
Time to first byte: 0.3102s
Total time: 0.4891s
Downloaded: 51274 bytes
Download speed: 102.31 KB/s
HTTP status: 200
What Each Value Tells You
namelookup_time — time spent resolving the domain to an IP address. Normally under 0.05s. If this is high (0.5s+) your DNS is slow. Consider using a faster DNS resolver or caching DNS lookups.
connect_time — time from DNS resolution to TCP connection established. High values here mean the server is geographically far away or under heavy load. This is the phase CURLOPT_CONNECTTIMEOUT controls.
appconnect_time — time for the SSL/TLS handshake to complete. Only relevant for HTTPS. If this is significantly higher than connect_time, the server has SSL configuration issues or is overloaded handling handshakes.
starttransfer_time — time until the first byte of the response arrived. This is the most useful number for diagnosing slow APIs and web servers. High values here mean the server received your request but took a long time to start sending back a response — usually a server-side processing problem, not a network problem.
total_time — full request duration from start to finish. Compare this to starttransfer_time — if total_time is much higher, the download itself was slow. If they’re close together, the server was slow to respond but the actual download was fast.
Diagnosing Common Timeout Patterns
<?php
function diagnose_request($url) {
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
$errno = curl_errno($ch);
$info = curl_getinfo($ch);
curl_close($ch);
// Pattern 1: DNS is the bottleneck
if ($info['namelookup_time'] > 0.5) {
echo "Slow DNS lookup: " . round($info['namelookup_time'], 3) . "s" . PHP_EOL;
echo "Fix: Use Google DNS (8.8.8.8) or cache DNS results." . PHP_EOL;
return;
}
// Pattern 2: Server is unreachable
if ($errno === 28 && $info['connect_time'] === 0.0) {
echo "Server unreachable — TCP connection never opened." . PHP_EOL;
echo "Fix: Check if the URL is correct and the server is online." . PHP_EOL;
return;
}
// Pattern 3: Server connected but not responding
if ($errno === 28 && $info['connect_time'] > 0 && $info['starttransfer_time'] === 0.0) {
echo "Server connected but sent no response after " . round($info['connect_time'], 3) . "s." . PHP_EOL;
echo "Fix: Server may be overloaded. Increase CURLOPT_TIMEOUT and retry later." . PHP_EOL;
return;
}
// Pattern 4: Slow download
if ($info['total_time'] > $info['starttransfer_time'] * 3) {
$downloadTime = $info['total_time'] - $info['starttransfer_time'];
echo "Slow download: " . round($downloadTime, 3) . "s for " . $info['size_download'] . " bytes." . PHP_EOL;
echo "Fix: Increase CURLOPT_TIMEOUT or check your bandwidth." . PHP_EOL;
return;
}
// All good
echo "Request completed in " . round($info['total_time'], 3) . "s — no issues detected." . PHP_EOL;
}
diagnose_request("https://books.toscrape.com/");
?>
Output on a healthy request:
Request completed in 0.489s — no issues detected.
Output when server connects but doesn’t respond:
Server connected but sent no response after 0.091s.
Fix: Server may be overloaded. Increase CURLOPT_TIMEOUT and retry later.
Setting Timeouts Based on Real Measurements
Instead of guessing timeout values, measure first and set values based on actual data:
<?php
function measure_baseline($url, $samples = 5) {
$times = [];
for ($i = 0; $i < $samples; $i++) {
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_TIMEOUT => 60,
]);
curl_exec($ch);
$info = curl_getinfo($ch);
$times[] = $info['total_time'];
curl_close($ch);
sleep(1);
}
$average = array_sum($times) / count($times);
$max = max($times);
echo "Average response time: " . round($average, 3) . "s" . PHP_EOL;
echo "Slowest response: " . round($max, 3) . "s" . PHP_EOL;
echo "Recommended CURLOPT_TIMEOUT: " . ceil($max * 2) . "s" . PHP_EOL;
return ceil($max * 2);
}
$recommendedTimeout = measure_baseline("https://books.toscrape.com/");
?>
Output:
Average response time: 0.412s
Slowest response: 0.631s
Recommended CURLOPT_TIMEOUT: 2s
Running this before building a scraper gives you a data-driven timeout value instead of an arbitrary number. The recommended value is 2x the slowest observed response — enough buffer for occasional slow responses without letting genuinely stuck requests hang indefinitely.
Handling Stalled Transfers With CURLOPT_LOW_SPEED_LIMIT
A request can connect successfully, start downloading, and then stall — dropping to near-zero speed without fully timing out. CURLOPT_TIMEOUT won’t catch this until the total time limit is reached, which means your script can sit waiting for the full 30 or 60 seconds on a transfer that effectively stopped after 2 seconds.
CURLOPT_LOW_SPEED_LIMIT and CURLOPT_LOW_SPEED_TIME fix this. They tell cURL to abort if the transfer speed drops below a threshold for a sustained period.
Basic Setup
<?php
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => "https://example.com/large-file.zip",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 60,
// Abort if speed drops below 100 bytes/sec for more than 10 seconds
CURLOPT_LOW_SPEED_LIMIT => 100,
CURLOPT_LOW_SPEED_TIME => 10,
]);
$response = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
curl_close($ch);
if ($errno === 28) {
echo "Transfer stalled and was aborted: " . $error . PHP_EOL;
} elseif ($errno) {
echo "cURL error $errno: " . $error . PHP_EOL;
} else {
echo "Downloaded " . strlen($response) . " bytes successfully." . PHP_EOL;
}
?>
Output when transfer stalls:
Transfer stalled and was aborted: Operation too slow. Less than 100 bytes/sec transferred the last 10 seconds
Output on success:
Downloaded 524288 bytes successfully.
When a stalled transfer triggers, cURL still returns error code 28 — the same as a regular timeout. The difference shows up in the error message: “Operation too slow” instead of “Operation timed out after X milliseconds.”
Choosing the Right Threshold Values
The values you set depend entirely on what you’re downloading:
<?php
// For scraping HTML pages — pages should transfer fast
// Abort if under 500 bytes/sec for more than 5 seconds
curl_setopt_array($ch, [
CURLOPT_LOW_SPEED_LIMIT => 500,
CURLOPT_LOW_SPEED_TIME => 5,
]);
// For downloading files or large responses — more lenient
// Abort if under 1KB/sec for more than 15 seconds
curl_setopt_array($ch, [
CURLOPT_LOW_SPEED_LIMIT => 1024,
CURLOPT_LOW_SPEED_TIME => 15,
]);
// For hitting slow APIs — very lenient on speed, strict on time
// Abort if under 10 bytes/sec for more than 20 seconds
curl_setopt_array($ch, [
CURLOPT_LOW_SPEED_LIMIT => 10,
CURLOPT_LOW_SPEED_TIME => 20,
]);
?>
Setting CURLOPT_LOW_SPEED_TIME too low causes false aborts on connections that momentarily slow down but recover. Setting it too high defeats the purpose — you’re still waiting a long time on a stalled transfer. For most scraping jobs, 5-10 seconds is the right range.
Complete Timeout Setup for a Scraper
This is the full timeout configuration you should use as a starting point on any scraping project:
<?php
function scrape_url($url) {
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
// Connection phase
CURLOPT_CONNECTTIMEOUT => 10,
// Total request limit
CURLOPT_TIMEOUT => 30,
// Stalled transfer detection
CURLOPT_LOW_SPEED_LIMIT => 500, // bytes per second
CURLOPT_LOW_SPEED_TIME => 10, // seconds below limit before abort
CURLOPT_ENCODING => '',
CURLOPT_HTTPHEADER => [
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language: en-US,en;q=0.5',
],
]);
$response = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$info = curl_getinfo($ch);
curl_close($ch);
if ($errno === 28) {
// Distinguish between stalled transfer and regular timeout
if (strpos($error, 'too slow') !== false) {
echo "Stalled transfer on $url — aborted after " . round($info['total_time'], 2) . "s" . PHP_EOL;
} else {
echo "Timeout on $url after " . round($info['total_time'], 2) . "s" . PHP_EOL;
}
return false;
}
if ($errno) {
echo "cURL error $errno on $url: $error" . PHP_EOL;
return false;
}
if ($info['http_code'] !== 200) {
echo "HTTP " . $info['http_code'] . " on $url" . PHP_EOL;
return false;
}
return $response;
}
$html = scrape_url("https://books.toscrape.com/");
if ($html) {
echo "Fetched successfully. " . strlen($html) . " bytes." . PHP_EOL;
}
?>
Output on success:
Fetched successfully. 51274 bytes.
Output on stalled transfer:
Stalled transfer on https://books.toscrape.com/ — aborted after 10.24s
Output on regular timeout:
Timeout on https://books.toscrape.com/ after 30.01s
With this setup your scraper handles three distinct timeout scenarios — connection failure, total time exceeded, and stalled mid-transfer — and tells you which one happened instead of lumping them all into a generic error message.
Building Retry Logic Specifically for Timeout Errors
A single timeout doesn’t mean the request will never succeed. Servers get momentarily overloaded, network routes become congested, and DNS resolution occasionally takes longer than usual. Retrying after a short pause succeeds more often than not on these temporary failures.
The key is retrying smartly — not every error deserves a retry, the delay between retries should increase with each attempt, and there has to be a hard limit on how many times you try.
Basic Retry on Timeout
<?php
function scrape_with_timeout_retry($url, $maxRetries = 3) {
$attempt = 0;
$baseDelay = 2; // seconds
while ($attempt < $maxRetries) {
$attempt++;
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_LOW_SPEED_LIMIT => 500,
CURLOPT_LOW_SPEED_TIME => 10,
CURLOPT_HTTPHEADER => [
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
],
]);
$response = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// Success
if ($errno === 0 && $httpCode === 200) {
if ($attempt > 1) {
echo "Succeeded on attempt $attempt." . PHP_EOL;
}
return $response;
}
// Timeout — worth retrying
if ($errno === 28) {
echo "Attempt $attempt timed out: $error" . PHP_EOL;
if ($attempt < $maxRetries) {
$delay = $baseDelay * $attempt; // 2s, 4s, 6s
echo "Retrying in {$delay}s..." . PHP_EOL;
sleep($delay);
}
continue;
}
// DNS failure — retrying might help if temporary
if ($errno === 6) {
echo "Attempt $attempt DNS failure. Retrying in {$baseDelay}s..." . PHP_EOL;
sleep($baseDelay);
continue;
}
// Server error — retry after pause
if ($httpCode >= 500) {
echo "Attempt $attempt server error HTTP $httpCode. Retrying..." . PHP_EOL;
sleep($baseDelay * $attempt);
continue;
}
// Rate limited — wait longer
if ($httpCode === 429) {
echo "Rate limited on attempt $attempt. Waiting 15s..." . PHP_EOL;
sleep(15);
continue;
}
// Permanent failures — no point retrying
if ($httpCode === 403 || $httpCode === 404) {
echo "Permanent failure HTTP $httpCode on $url" . PHP_EOL;
return false;
}
// Other curl errors — not timeout related, stop retrying
echo "cURL error $errno: $error" . PHP_EOL;
return false;
}
echo "All $maxRetries attempts failed for: $url" . PHP_EOL;
return false;
}
// Usage
$html = scrape_with_timeout_retry("https://books.toscrape.com/", 3);
if ($html) {
echo "Page fetched. Length: " . strlen($html) . " bytes." . PHP_EOL;
}
?>
Output when first attempt succeeds:
Page fetched. Length: 51274 bytes.
Output when first attempt times out but second succeeds:
Attempt 1 timed out: Operation timed out after 30000 milliseconds with 0 bytes received
Retrying in 2s...
Succeeded on attempt 2.
Page fetched. Length: 51274 bytes.
Output when all attempts fail:
Attempt 1 timed out: Operation timed out after 30000 milliseconds with 0 bytes received
Retrying in 2s...
Attempt 2 timed out: Operation timed out after 30000 milliseconds with 0 bytes received
Retrying in 4s...
Attempt 3 timed out: Operation timed out after 30000 milliseconds with 0 bytes received
All 3 attempts failed for: https://books.toscrape.com/
Exponential Backoff
The delay in the example above increases linearly — 2s, 4s, 6s. For servers that are genuinely overloaded, exponential backoff works better. It gives the server progressively more recovery time between attempts:
<?php
function get_retry_delay($attempt, $baseDelay = 2, $maxDelay = 60) {
// 2s, 4s, 8s, 16s, 32s — doubles each time, capped at maxDelay
$delay = $baseDelay * pow(2, $attempt - 1);
return min($delay, $maxDelay);
}
// Delay per attempt:
echo get_retry_delay(1) . "s" . PHP_EOL; // 2s
echo get_retry_delay(2) . "s" . PHP_EOL; // 4s
echo get_retry_delay(3) . "s" . PHP_EOL; // 8s
echo get_retry_delay(4) . "s" . PHP_EOL; // 16s
echo get_retry_delay(5) . "s" . PHP_EOL; // 32s
echo get_retry_delay(6) . "s" . PHP_EOL; // 60s — capped
?>
Output:
2s
4s
8s
16s
32s
60s
Use exponential backoff when scraping sites that rate limit aggressively or when hitting APIs with strict request quotas. For general scraping with occasional timeouts, linear delay is simpler and sufficient.
Increasing Timeout on Each Retry
Sometimes the right response to a timeout isn’t just waiting longer before retrying — it’s giving the next attempt more time. This handles cases where the server is slow but functional:
<?php
function scrape_with_escalating_timeout($url, $maxRetries = 3) {
$timeouts = [30, 60, 90]; // increase timeout on each attempt
$attempt = 0;
while ($attempt < $maxRetries) {
$timeout = $timeouts[$attempt];
$attempt++;
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_HTTPHEADER => [
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
],
]);
$response = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errno === 0 && $httpCode === 200) {
echo "Succeeded on attempt $attempt with {$timeout}s timeout." . PHP_EOL;
return $response;
}
if ($errno === 28) {
echo "Attempt $attempt timed out at {$timeout}s." . PHP_EOL;
if ($attempt < $maxRetries) {
sleep(2);
}
continue;
}
return false;
}
echo "Failed after $maxRetries attempts." . PHP_EOL;
return false;
}
$html = scrape_with_escalating_timeout("https://books.toscrape.com/");
?>
Output when server is slow but responds on second attempt:
Attempt 1 timed out at 30s.
Succeeded on attempt 2 with 60s timeout.
Handling Timeouts in a Scraping Loop
When scraping multiple pages, failed URLs should be logged and re-queued rather than silently skipped:
<?php
$urls = [
"https://books.toscrape.com/catalogue/page-1.html",
"https://books.toscrape.com/catalogue/page-2.html",
"https://books.toscrape.com/catalogue/page-3.html",
];
$failed = [];
$success = 0;
foreach ($urls as $url) {
$html = scrape_with_timeout_retry($url, 3);
if ($html) {
$success++;
// process page...
} else {
$failed[] = $url;
}
sleep(1);
}
echo PHP_EOL . "Completed: $success succeeded, " . count($failed) . " failed." . PHP_EOL;
// Retry failed URLs once more with a longer timeout
if (!empty($failed)) {
echo PHP_EOL . "Retrying " . count($failed) . " failed URLs with extended timeout..." . PHP_EOL;
foreach ($failed as $url) {
$html = scrape_with_escalating_timeout($url, 2);
if ($html) {
echo "Recovered: $url" . PHP_EOL;
} else {
// Log permanently failed URLs
file_put_contents(
'failed_urls.log',
date('Y-m-d H:i:s') . " | " . $url . PHP_EOL,
FILE_APPEND
);
}
sleep(2);
}
}
?>
Output:
Completed: 3 succeeded, 0 failed.
Output when one URL fails and gets recovered:
Completed: 2 succeeded, 1 failed.
Retrying 1 failed URLs with extended timeout...
Recovered: https://books.toscrape.com/catalogue/page-2.html
Running a second pass on failed URLs with a longer timeout recovers a significant percentage of them. In large scraping jobs where you’re hitting hundreds of URLs, this second pass is worth doing before logging anything as permanently failed.
Frequently Asked Questions
What is PHP cURL error code 28?
Error code 28 is cURL’s timeout error. It triggers when a request exceeds the time limit set by CURLOPT_TIMEOUT, when the connection phase exceeds CURLOPT_CONNECTTIMEOUT, or when a transfer stalls below the speed set by CURLOPT_LOW_SPEED_LIMIT for too long. All three scenarios return the same error code — the difference is in the error message text returned by curl_error().
What is the difference between CURLOPT_TIMEOUT and CURLOPT_CONNECTTIMEOUT?
CURLOPT_CONNECTTIMEOUT only controls the connection phase — how long cURL waits for the server to accept the TCP connection. CURLOPT_TIMEOUT controls the entire request from start to finish including connection, waiting for a response, and downloading the data. Always set both. CURLOPT_TIMEOUT must always be higher than CURLOPT_CONNECTTIMEOUT — if it isn’t, the connection timeout is effectively ignored.
Why does my cURL request timeout even with a high CURLOPT_TIMEOUT value?
Three common causes. First, PHP’s max_execution_time is lower than your cURL timeout — PHP kills the script before cURL finishes. Fix with set_time_limit(0) at the top of your script. Second, the transfer stalled — the connection is open but data stopped flowing, and CURLOPT_TIMEOUT won’t fire until the full time is reached. Use CURLOPT_LOW_SPEED_LIMIT to catch stalled transfers earlier. Third, CURLOPT_TIMEOUT is set lower than CURLOPT_CONNECTTIMEOUT — the total timeout fires before the connection timeout gets a chance to.
Should I use CURLOPT_TIMEOUT or CURLOPT_TIMEOUT_MS?
Use CURLOPT_TIMEOUT for most scraping and API work where second-level precision is fine. Use CURLOPT_TIMEOUT_MS when you need millisecond precision — for example, enforcing that an API must respond within 500ms. Don’t use both on the same handle — CURLOPT_TIMEOUT_MS overrides CURLOPT_TIMEOUT if both are set.
How do I stop a cURL request from hanging my script?
Set both CURLOPT_CONNECTTIMEOUT and CURLOPT_TIMEOUT on every request — never leave either unset. Also add set_time_limit(0) only if you intend the script to run indefinitely, otherwise PHP’s execution limit acts as a safety net. For long scraping jobs, add CURLOPT_LOW_SPEED_LIMIT and CURLOPT_LOW_SPEED_TIME to catch stalled transfers that would otherwise hang until the full timeout is reached.
How many times should I retry a timed-out request?
Three retries is the standard for most scraping projects — enough to recover from temporary failures without wasting excessive time on genuinely dead URLs. Use increasing delays between retries: 2s, 4s, 8s works well for linear backoff. On the third failure, log the URL to a file and move on rather than blocking the rest of the job.
Why does curl_error() return nothing even when the request failed?
curl_error() only returns a message when curl_errno() is non-zero. If your request returned an empty response with no cURL error, the problem is at the HTTP level — the server returned a 403, 404, or redirect that resolved to an error page. Always check curl_getinfo($ch, CURLINFO_HTTP_CODE) separately. A 200 status from cURL means the request completed — it doesn’t mean you got the content you expected.
Summary
PHP cURL timeout errors come down to three options, one error code, and knowing where in the request time is being lost:
- Use
CURLOPT_CONNECTTIMEOUTto control the connection phase andCURLOPT_TIMEOUTto cap the entire request — always set both, always keep total higher than connect - Error code 28 covers three different failures — read the error message to know which one you’re dealing with
- Use
curl_getinfo()timing values to find exactly where time is being lost instead of guessing - Add
CURLOPT_LOW_SPEED_LIMITto catch stalled transfers thatCURLOPT_TIMEOUTwon’t catch quickly - Build retry logic with increasing delays — three attempts with exponential backoff handles the majority of temporary failures
If you’re using cURL timeouts inside a scraper that hits multiple pages, the retry logic here plugs directly into a pagination loop. The PHP cURL web scraping guide covers how to combine timeout handling with pagination, HTML parsing, and MySQL storage into a complete working scraper.
Related Guides
- Web Scraping Errors in PHP (Common Issues & Fixes)
- How to Scrape Dynamic Websites in PHP
- PHP Cron Job Automation Guide
- Laravel Controllers Guide (MVC Explained)
External Resource
Refer official documentation: PHP cURL Documentation
If you’re using cURL for scraping, you should also read our PHP cURL web scraping guide to understand request handling in detail.
