· 5 min read

The 30-second deploy timeout

mod_fastcgi's idle timeout is exactly 30 seconds. Under cgi-fcgi, flush() does nothing. The only fix that worked was calling exit() right after.

By Marten

Apache’s mod_fastcgi has an -idle-timeout flag. The default is 30 seconds. If your PHP script does not produce any output on the FastCGI socket within 30 seconds, Apache kills the connection and returns 500 to the client. The PHP script keeps running. The client has no idea.

I learned this the hard way last week, on a deploy endpoint that was meant to return immediately while a background worker continued the actual deploy.

The user clicked deploy. Got HTTP 500 after exactly 30 seconds. The deploy itself completed successfully four minutes later, in the background. The UI showed “failed.” Production was fine. The user was, understandably, not happy.

The setup

The endpoint does three things, in order:

  1. Pre-create a deployment record in the database
  2. Spawn a worker process to run the deploy
  3. Return JSON with the deployment ID so the UI can poll for progress

Step 3 is supposed to take less than a second. The worker in step 2 is detached and runs independently. The endpoint should respond fast.

The endpoint, in PHP:

$deploymentId = $service->createQueuedDeployment(...);
$service->spawnWorker(...);  // double-fork, returns in ~2s
echo json_encode([
    'success' => true,
    'deployment_id' => $deploymentId,
]);
flush();

I expected this to return in 2-3 seconds. It returned in 30, with HTTP 500.

What flush() actually does

flush() flushes PHP’s output buffer to the SAPI. The SAPI is the layer between PHP and the web server. Under PHP-FPM (fpm-fcgi SAPI), there is also fastcgi_finish_request() which tells the FastCGI process manager that this request is done — the response is sent, and PHP can keep running for cleanup.

Under MAMP’s default — cgi-fcgi SAPI — fastcgi_finish_request() does not exist. It’s not just disabled, the function is literally not defined.

I checked:

echo function_exists('fastcgi_finish_request') ? 'yes' : 'no';
// no
echo php_sapi_name();
// cgi-fcgi

So flush() is the only option. And on cgi-fcgi, flush() is a no-op. The response is buffered until the PHP process exits.

This is documented behaviour. It is also extremely surprising the first time you hit it, because the equivalent code under PHP-FPM works perfectly. You can develop the entire feature against PHP-FPM, ship it, and only discover the bug on the customer’s MAMP install.

What I tried first

The intuitive fixes did not work.

Sending Content-Length and Connection: close headers: no effect. Apache buffers the body until end-of-script regardless.

Calling flush() multiple times: no effect, for the same reason.

Calling ob_end_flush() and then flush(): no effect.

Increasing PHP’s output_buffering to 0: no effect — that controls PHP’s internal buffer, not the SAPI’s.

Wrapping the call in register_shutdown_function: no effect, because the script was already past the controller code by the time it would have triggered.

The pattern of “send response now, keep working” simply does not exist on cgi-fcgi. The only way to release the response is to end the script.

The fix that worked

Call exit immediately after the response is written.

$deploymentId = $service->createQueuedDeployment(...);
$service->spawnWorker(...);

http_response_code(200);
header('Content-Type: application/json');
header('Content-Length: ' . strlen($json));
header('Connection: close');
echo $json;
flush();
exit;

The worker is already running in a detached process. The PHP-FPM (or in this case PHP-CGI) process has nothing left to do. Exit. The OS closes the FastCGI socket. Apache forwards the buffered response to the client. The client sees the JSON within milliseconds.

I tested it with a synthetic version: PHP that spawns a 20-second background sleep via proc_open, then exits. The HTTP response arrived in 30 milliseconds. The background sleep continued for its full 20 seconds.

The double-fork detail

Spawning the worker has to actually detach the worker from the PHP process tree, or the worker keeps the FastCGI socket open via inheritance and you are back where you started. The standard pattern, for shell:

( exec 3<&- 4<&- 5<&- ... ; nohup command </dev/null >>/var/log/worker.log 2>&1 & echo $! )

The outer parentheses create a subshell. The exec N<&- lines close inherited file descriptors. The & puts the command in the background. echo $! returns the PID. The subshell exits, orphaning the worker to init/launchd.

In PHP, called via proc_open:

$cmd = '( exec 3<&- ; ' . $bin . ' worker.php params </dev/null >> log 2>&1 & echo $! )';
$proc = proc_open($cmd, [['pipe','r'],['pipe','w'],['pipe','w']], $pipes);
fclose($pipes[0]);
$pid = (int) trim(stream_get_contents($pipes[1]));
fclose($pipes[1]);
proc_close($proc);

proc_close blocks until the subshell exits, which is instant because the subshell only runs echo. The worker, however, continues to run independently of the subshell.

Without the FD-close loop, the worker inherits the parent PHP’s file descriptors, including potentially the FastCGI socket. With the FD-close loop, the worker has only stdin/stdout/stderr (redirected to /dev/null and the log file).

What I want you to take from this

Three things.

fastcgi_finish_request is not always available, and the documentation does not foreground how often it is missing. If you are writing PHP that relies on it, gate every use behind function_exists.

On the SAPI where it does not exist, the only release-the-response mechanism is to exit the script. There is no other escape hatch.

If you are building a feature where “respond fast, work in the background” matters, your test environment must include the SAPI you ship to. Otherwise you will write code that works perfectly in dev and breaks in production for reasons that are completely invisible from the dev side.

I learned this on my own product. The fix was three lines. The diagnosis took most of a Tuesday.