Blog
Hang Forever Is Not a Strategy
- software-engineering
- laravel
- typescript
Most HTTP clients, queue workers, and database drivers will wait a long time if you never tell them otherwise.
Sometimes "a long time" means minutes. Sometimes it means until the process dies. Either way, you did not choose infinity. You inherited it.
A hung payment API should not hold a PHP-FPM worker hostage. A stuck Node fetch should not leave a browser spinner spinning until someone refreshes. Timeouts are not pessimism. They are how you keep one bad dependency from owning the rest of the request.
What "no timeout" actually means
When you omit a timeout, you are still picking a policy. You are picking the library default, the proxy default, or "wait until something else gives up."
That is rarely the policy you want under load. Workers fill up. Connection pools empty. Retries stack on top of calls that are already dead. Users see a blank page while your process waits for a remote that already moved on.
Failing fast with a clear error is usually kinder than succeeding never.
Laravel: set it on the client, not in your head
Laravel's HTTP client makes this easy to forget because the happy path looks fine in local:
$response = Http::timeout(5)
->connectTimeout(2)
->retry(2, 200)
->post($url, $payload);
if ($response->failed()) {
throw new CrmUnavailableException(
"CRM call failed: ".$response->status()
);
}connectTimeout covers "never reached the host." timeout covers "reached it and it stalled." Retries only help if each attempt is bounded. Retrying an unbounded call just multiplies the hang.
For queued work, keep the job's own timeout in the same neighborhood as the slowest call inside it. A job that may run for 90 seconds with a remote that can hang for 120 is how you get half-written side effects and confused retries.
public int $timeout = 20;
public function handle(): void
{
Http::timeout(8)->get(config('services.rates.url'));
}The numbers are examples, not a benchmark. The point is they exist, and they match.
TypeScript: abort beats hope
fetch does not time out on its own in many runtimes. If you do not abort, you wait.
async function fetchJson<T>(
url: string,
{ timeoutMs = 8_000 }: { timeoutMs?: number } = {},
): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return (await res.json()) as T;
} finally {
clearTimeout(timer);
}
}On the server, wire the same idea into your HTTP client of choice. On the client, pair it with UI that can show "took too long" instead of an endless spinner.
If you retry, backoff, and still bound each attempt. Three tries at 8 seconds is a known worst case. Three tries with no timeout is a mystery outage.
Where generous timeouts still make sense
Bulk exports, report generation, and admin tools that users expect to be slow can wait longer. Say so in the UI and in the job config.
Do not copy that generosity onto every interactive request. A checkout page and a nightly CSV are not the same contract.
Also: a short timeout does not replace idempotency. Timeouts create retries. Retries need safe writes. Those are separate posts for a reason.
The habit
Before you ship an outbound call, ask: what happens if this remote never answers?
If the answer is "we wait," pick a timeout. Connect and read. Job and HTTP. Browser and API.
Hang forever is not a strategy. It is just the default you forgot to override.