skip to content

On Linux, why can a PHP web request stuck waiting on a slow external API run far past max_execution_time = 30, and what does set_time_limit() actually reset?

level: seniorimportance: should knowfreq 33%

answer

  1. CPU time, not wall clock
  2. ITIMER_PROF on standard builds
  3. Windows measures real time
  4. set_time_limit restarts the counter
  5. bound I/O with its own timeouts

basics

~20 s

On standard non-thread-safe Linux builds, max_execution_time counts CPU time the process uses, so waiting on I/O such as an API call or query barely counts. set_time_limit() sets a new limit and restarts the counter from zero.

solid answer

~40 s

On a typical Linux build (non-thread-safe, as PHP-FPM usually is), the engine arms a **profiling timer** that counts **CPU time**, not wall-clock time. The manual states it plainly: time spent in system calls, stream operations and database queries is not included. A request blocked for two minutes on a slow API uses almost no CPU, so `max_execution_time = 30` never fires. Windows measures real time, and thread-safe Linux builds use wall-clock timers, so the same code behaves differently across platforms. `set_time_limit(int $seconds): bool` sets a new limit **and restarts the counter from zero**: 25 seconds in, `set_time_limit(20)` allows 45 in total, and `0` removes the limit. What really bounds a stuck request is a timeout on the I/O itself, such as the HTTP client's timeout, plus the FPM and web-server request timeouts.

code

php · 11 lines
php
<?php
declare(strict_types=1);

// max_execution_time = 30 (web SAPI)
sleep(60);                 // waiting: almost no CPU time used
echo "still running\n";    // reached on a typical Linux FPM build

$end = microtime(true) + 60;
while (microtime(true) < $end) {
    // busy loop: burns CPU, so the 30-second limit fires here
}

go deeper

for a junior

Know that max_execution_time limits how long a web script may run and that set_time_limit() changes it at runtime.

for a middle

Explain that set_time_limit() restarts the counter and that, on typical Linux builds, time spent waiting on I/O is not counted.

for a senior

Diagnose piled-up workers with no timeout fatals as wall-clock waiting, and bound it with client, database, FPM and web-server timeouts.

for a principal

Define a timeout budget across the stack, from client to FPM to web server, so slow dependencies fail fast instead of exhausting workers.

## The symptom A newsletter tool's web UI calls an external mail-validation API before saving a subscriber. One afternoon the API hangs. The PHP-FPM workers fill up with requests that have been running for two, three, five minutes, even though php.ini says `max_execution_time = 30`. Nothing logs "Maximum execution time exceeded". ## What max_execution_time measures The name suggests elapsed time, but on most Unix builds it is not. How the engine arms the timer depends on the build: | Build | Timer | What counts | |---|---|---| | Linux or FreeBSD, non-thread-safe (typical for PHP-FPM and the CLI) | `setitimer(ITIMER_PROF)` | **CPU time** the process uses, user plus system | | Linux or FreeBSD, thread-safe (ZTS) with Zend max execution timers | a POSIX `timer_create()` timer (`CLOCK_BOOTTIME` on Linux) | wall-clock time | | Windows | a timer queue | real time | | macOS on Apple Silicon | `setitimer(ITIMER_REAL)` | wall-clock time | The manual's note on `set_time_limit()` gives the practical consequence: time spent on activity outside the script's own execution, such as system calls made with `system()`, stream operations and database queries, is **not included**, except on Windows, where the measured time is real. So on the typical FPM box, a request that sleeps, waits on a socket or waits for a database row uses nearly no CPU, and its 30-second budget barely moves. ## What set_time_limit() does `set_time_limit(int $seconds): bool` does two things at once: 1. sets the limit to `$seconds` (`0` means no limit); 2. **restarts the counter from zero**. The manual's example: with the default 30 seconds, calling `set_time_limit(20)` 25 seconds into a script lets it run 45 seconds in total. Calling it inside a loop therefore extends the budget on every iteration, a common way to let batch jobs run long, and a common way to accidentally remove any bound at all. ## When the limit does fire When the timer expires, the engine marks the request as timed out and raises `Fatal error: Maximum execution time of 30 seconds exceeded` at the next point where the VM checks for interrupts, so a single long internal call can overrun. Registered shutdown functions still run. On non-thread-safe builds, the `hard_timeout` directive (default `2` seconds) adds a last resort: if the script has not stopped that long after the soft timeout, the process is terminated. ## What actually bounds a stuck request Because the CPU-time limit does not cover waiting, you bound waiting where it happens: - **The I/O call's own timeout.** Give every outbound HTTP call a connect and total timeout in the client you use, and every socket stream a read timeout; `default_socket_timeout` (default `60` seconds) is only the fallback for socket-based streams. - **Database timeouts** configured on the connection or the server. - **PHP-FPM's per-request timeout** in the pool configuration, which kills a worker after wall-clock time; its settings are a separate topic. - **The web server's upstream timeout**, which gives up on the client side but does not stop the PHP worker by itself. ## Diagnosing it in production - If workers pile up but no timeout fatal appears in the logs, suspect **wall-clock waiting** rather than CPU work. - FPM's status page and slow log show which requests are long-running and where they are stuck; configuring them is a separate topic. - After adding client timeouts, the failure turns into a fast, logged exception you can handle, which is the goal: **fail fast, not hang**. ## A checklist for long web requests 1. Give every outbound call a timeout shorter than the page's patience. 2. Keep `max_execution_time` as a guard against CPU runaways such as infinite loops, which it does catch. 3. Avoid resetting the limit in loops unless a total bound exists elsewhere. 4. Move work that legitimately takes minutes into a queue processed outside the web request. ## Common misconceptions - **"max_execution_time is wall-clock time."** Only on some builds and platforms; on the typical Linux FPM build it is CPU time. - **"set_time_limit(60) adds 60 seconds."** It restarts the counter at zero with a 60-second limit. - **"The web server's timeout stops PHP."** It stops waiting for PHP; the worker may keep running.

  • A script calls set_time_limit(20) after 25 seconds of a 30-second limit. When does it time out?
    At about 45 seconds of counted time. set_time_limit() restarts the counter from zero with the new limit, so the script gets 20 more seconds on top of the 25 already used.
  • Why does the same code time out on a Windows server but not on a Linux one?
    On Windows the timer measures real elapsed time, so waiting on a slow API counts. On a typical non-thread-safe Linux build it measures CPU time, so waiting on I/O barely counts toward the limit.
  • If max_execution_time does not cover waiting, what should bound a slow API call?
    The call itself: set connect and total timeouts in the HTTP client, read timeouts on streams, and timeouts on database connections. Behind that, the FPM per-request timeout and the web server's upstream timeout act as outer limits.

saying these in an interview costs you the question

  • max_execution_time always measures wall-clock time on every platform.
  • set_time_limit(60) adds 60 seconds to the remaining budget.
  • A request blocked on a slow API is killed after max_execution_time.
  • The web server's timeout also stops the PHP worker immediately.
  • Shutdown functions do not run when the time limit is hit.