A page can be perfectly lean in the browser and still spend seconds waiting for its first byte. The reason is usually not in the source you are reading, but in a single line that asks a foreign system something while the page is being assembled: the ERP for stock levels, the carrier for delivery times, the pricing service for today's price. As long as the answer arrives, nobody notices. When it does not, the visitor waits exactly as long as the configured timeout allows - and in the common runtimes that timeout is far more generous than most teams assume. This article shows which timeouts are documented as defaults, how to cache foreign answers in two tiers, and why a fallback value is usually the better answer than an error page. If you want to measure your server response time properly first, that is where the groundwork is.
Key takeaways
- Outbound calls made during page assembly are rarely visible: they appear in no browser waterfall, only in the gap between request and first byte. They get measured only if you time each one individually.
- The documented defaults are long: 60 seconds for socket streams in PHP (php.net), 300 seconds for the connection phase in libcurl (curl.se) and a 60 second read gap on an upstream proxy (nginx.org).
- A connect timeout and a total timeout are two different things: for the whole transfer, libcurl sets no limit unless you set one (curl.se). A single call can therefore outlast anyone's patience.
- At least 90 percent of the pages surveyed load at least one third party in the browser (Web Almanac 2025). Whatever the server fetches in the background on top of that appears in no such count at all.
- A cached value with two tiers - fresh and still usable - keeps the page serviceable even while the foreign system is not answering (RFC 9111, RFC 5861).
- Fallback value instead of error page: deciding up front what is shown without an answer settles the question in calm conditions rather than during an incident.
When the server becomes the caller
Almost every application that has grown over the years has places where its own code waits for a foreign answer while a page is being assembled. Stock levels come from the ERP, today's price from a pricing service, the delivery window from the carrier, review data from a portal, the origin of the request from a geolocation service. Technically these are outbound calls made by the server, not browser resources. They therefore appear in no waterfall that a developer tool can draw and in no list of blocking scripts. They surface only in the time before the first byte - and there only as a single, not very informative number. To find out which call cost how much, you need a timestamp per section inside the backend.
How common foreign systems are can be quantified for the browser side: the share of pages with at least one third party stands at at least 90 percent (Web Almanac 2025). That count, however, only covers what the browser loads. Outbound calls made by the server appear in none of these surveys, because they cannot be measured from the outside. For performance work this means the visible half is well researched and can be trimmed of unnecessary third-party scripts with the usual tools, while the invisible half is known only to whoever reads the code. In many projects that is where the larger lever sits, because a slow foreign answer during page assembly blocks the entire page, whereas a slow script in the browser merely delays part of the rendering.
What the field data says about server time
Default timeouts are eternities
The defaults in the common runtimes date from a time when a script ran in the background and nobody was waiting for its result. In PHP, the timeout for socket-based streams is preset to 60 seconds (php.net). It applies to everything that goes through the stream layer, including a plain read of a foreign address via file_get_contents. A negative value, the manual notes, means an infinite timeout. One minute is not a borderline case during page assembly, it is a total outage: no visitor and no crawler waits that long, and the process doing the waiting is unavailable for other requests. How quickly that turns into a chain reaction shows most clearly under load - in a load test before peak season the blocked worker is one of the most common causes of a sudden rise in response times.
In libcurl, the library behind most HTTP calls in PHP, two timeouts are cleanly separated. For the connection phase the manual names 300 seconds as the built-in default (curl.se). For the whole transfer the default is 0, and according to the documentation that means no timeout ends the transfer (curl.se). Both values were designed for calls that happen in a batch run, not for a call a page is hanging on. Anyone who does not set them explicitly has not chosen a timeout but handed the decision to a library that knows nothing about the page.
| Timeout | Default | What it limits | Range during page assembly |
|---|---|---|---|
| default_socket_timeout (PHP) | 60 s | waiting on streams in the stream layer | 1 to 3 s |
| max_execution_time (PHP) | 30 s | the script's own processing time | leave as is, it does not replace the timeout on the call |
| CURLOPT_CONNECTTIMEOUT (libcurl) | 300 s | the connection phase only | 0.5 to 2 s |
| CURLOPT_TIMEOUT (libcurl) | no limit | the entire transfer | 2 to 5 s, depending on purpose |
| proxy_read_timeout (proxy) | 60 s | the gap between two read operations | keep above the application's timeout |
The left half of that table is documented, the right half is not: the values in the last column are experience values from performance projects (project experience) and replace no measurement in your own network. Another widespread misconception is that the script runtime limit already catches the case. It is preset to 30 seconds (php.net) - and in the default tracking mode via setitimer(ITIMER_PROF) it measures CPU time on non-Windows systems: system calls and stream operations do not count towards it there (php.net). Waiting for a foreign answer consists of exactly that. Under that tracking mode a script can hang on a request far longer than the runtime limit suggests without being terminated. If PHP is compiled with --enable-zend-max-execution-timers instead - the default for ZTS builds since PHP 8.3.0 - the limit measures elapsed time and the waiting time does count (php.net). Which mode applies in your own build is therefore a question of compile options, not of configuration. Either way, the timeout set on the call itself is the one that applies to an outbound call in both cases.
A connect timeout is not a total timeout
Between application and visitor there is usually an upstream proxy, and it brings timeouts of its own. Its read timeout stands at 60 seconds (nginx.org), and the documentation is precise about what it means: it is set only between two successive read operations, not for the transmission of the whole response. An application that sends single bytes at short intervals slips past that timeout indefinitely. Establishing the connection has a separate timeout which, according to the documentation, usually cannot exceed 75 seconds (nginx.org). Timeouts therefore have to increase from the inside out: the application aborts the foreign call before the proxy aborts the application, and the proxy aborts before the browser gives up. Where the order is reversed, the visitor sees an error page from the proxy while the application is still working - and the log holds no usable clue about the cause. Server optimization starts here with a single table: which timeout sits where.
<?php
// Every outbound call gets its own short timeout.
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => 800, // connection phase only
CURLOPT_TIMEOUT_MS => 2500, // the entire transfer
CURLOPT_FOLLOWLOCATION => false, // no unnoticed detours
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$response = curl_exec($ch);
$duration = curl_getinfo($ch, CURLINFO_TOTAL_TIME);
curl_close($ch);
if ($response === false) {
log_failure($key, $duration);
return fallback_value($key); // instead of an error page
}What a timeout does not catch
Caching foreign answers
The most effective lever against foreign waiting time is not fetching the answer again on every page view. RFC 9111 puts the mechanism in a single sentence: when a response is fresh, it can be used to satisfy subsequent requests without contacting the origin server, thereby improving efficiency. Fresh means the age of the response has not yet exceeded its freshness lifetime; after that it counts as stale. The decisive question during page assembly is therefore not whether to cache, but how long a value may remain valid. Stock levels may be seconds old, a shipping tariff hours, a country list days. Those lifetimes belong in a table rather than in a single developer's head - they are part of any dependable caching strategy.
For failure there is a second tier. RFC 5861 describes two extensions to the Cache-Control header field for exactly that. With stale-while-revalidate a cache may immediately return a stale response while it revalidates it in the background, thereby hiding latency from clients. With stale-if-error it may return a stale response when an error is encountered, rather than returning a hard error, which improves availability. Applied to your own application cache this means every stored value gets two time values. After the first, it is refreshed in the background; after the second, it is discarded. Between the two lies the range in which a failure of the foreign system stays invisible to the visitor.
<?php
// Two tiers: fresh until $fresh, usable in an emergency until $expiry.
function external_value(string $key, callable $fetch, int $fresh, int $expiry): ?array
{
$entry = cache_get($key);
if ($entry && $entry['age'] < $fresh) {
return $entry['value']; // fresh, serve directly
}
try {
$value = $fetch(); // the timeout sits in the call itself
cache_set($key, $value, $expiry);
return $value;
} catch (Throwable $e) {
log_failure($key, $e);
if ($entry && $entry['age'] < $expiry) {
return $entry['value']; // stale, but usable
}
return null; // the caller picks the fallback
}
}Fallback value instead of error page
When neither a fresh nor a stale value is available, it is decided whether the page breaks or merely gets poorer. RFC 9110 provides status code 504 for the intermediary case: it indicates that the server, while acting as a gateway or proxy, did not receive a timely response from an upstream server it needed to access in order to complete the request. For your own overload, 503 is intended, indicating a temporary overload or scheduled maintenance which will likely be alleviated after some delay. It comes with the Retry-After header field, with which the server indicates how long a client ought to wait before making a follow-up request. For a programming interface these codes are the right answer. For a page a human opens they are the worse choice: a missing stock note is a small flaw, an error page is a lost visit.
- Stock: show the last known value and check availability bindingly only in the cart.
- Price: show the price from your own data and treat the foreign daily price as an addition only.
- Delivery time: show the stored standard delivery window instead of an empty line.
- Review data: hide the block rather than render an empty shell with placeholders.
- Geolocation: fall back to the default language, default currency and default shipping location.
- Payment methods: show the methods stored in your own system and obtain authorization only in the order step.
The order that has proven itself
Which part of the page may wait?
The technical question about timeouts and caching has a business-side prelude that often gets skipped: which part of the page justifies waiting for a foreign answer at all? Price usually does, because a wrong display gets expensive both legally and commercially. The number of reviews usually does not. That classification is not an engineering decision but a business one, and it should be made once in writing rather than debated again during every incident. The checkout is the most sensitive area here, because that is where most foreign systems are involved at once and no page comes from a cache.
Stock from the ERP
The most common outbound call during page assembly. A value that is seconds old is enough for display purposes. The binding check belongs in the ordering process, not on a listing page.
Daily price from pricing
Prices must not be wrong, so this call needs a short timeout and your own data as a fallback. A price from your own system is better than no price at all.
Delivery time from the carrier
The standard delivery window is usually held in your own tariff data as well. The foreign call refines it, it does not replace it - and may therefore fail during an incident.
Review data
A display with no weight for the purchase decision at the moment of page assembly. A block that is hidden when no answer arrives costs less than a page that appears noticeably later.
Geolocation of the request
Language, currency and shipping location can be derived up front from the stored address. Falling back to the default setting is inconspicuous here and acceptable as a rule.
Payment methods and authorization
The list of methods comes from your own system, the authorization only in the order step. An outbound call during page assembly is rarely necessary here in business terms.
What comes out of this is a short document naming four things for every outbound call: the timeout, the cache lifetime, the fallback value and the person who decides during an incident. That document is the basis of any acceptance test, because it is the only place holding an answer to the question of how the page behaves without the foreign system. In a technical analysis this list is typically the first find. For portals behind a login it matters even more, because hardly any page there comes from a cache - how to measure speed behind the login is a subject of its own.
A page does not become stable because every foreign system is available. It becomes stable when, for each foreign system, it has already been decided what is shown without its answer.
The path there is unspectacular: a list of outbound calls, a timestamp per call, a timeout per call, a cache lifetime per value and a defined fallback. That is less work than rebuilding a frontend, and it acts where the usual tools show nothing. If you suspect your server time is being set by foreign answers, measure the time to first byte first and then break it down into its parts. Only then is it worth looking at database queries or at a caching concept with Varnish and Redis. And if your content management system forces the call during page assembly, look at the caching layers of that system before touching any timeouts.
Related Articles
TYPO3 Performance: Caching and Load Times Under Control
How the TYPO3 page cache works, which settings switch it off, and how the cache hash, the backends and the deployment decide the load time of a page.
Performance Measurement Without Consent: What Is Allowed
Section 25 TDDDG comes before the GDPR: when a Vitals measurement is device access, how narrow the exemption is and what stays measurable without consent.
Style Recalculation: When CSS Slows the Browser Down
Why style calculation costs time at runtime, how Selector Stats and Long Animation Frames make its share visible, and which changes actually shrink the scope.