Request timeouts and retries
Every request goes out with a deadline, and a failed request may be repeated. Both are resolved from the request's context rather than from one flat number, because the deadline above a request is not the same in each case: an SSR read runs inside the render budget and has to fail early enough for the page to still be rendered without it, a browser read only has the user's patience above it, and a mutation must be given the time to complete rather than be abandoned half-applied.
Defaults
| Context | Default | Retried by default |
|---|---|---|
| Read during server side rendering | 1500 ms | yes |
| Read from the client (browser or native) | 15000 ms | yes |
Mutation — POST, PATCH | 30000 ms | no |
Mutation — PUT, DELETE (idempotent per RFC 9110) | 30000 ms | yes |
A read is GET, HEAD or OPTIONS. A request is retried only when repeating it cannot change the
outcome, so a timed out POST fails instead of being sent a second time — nothing came back to say
whether the first one was applied. A 401 is still retried for mutations too: it means the request was
rejected rather than applied, so replaying it with a refreshed token duplicates nothing.
Configuring them
Per api origin, in the environment config:
{
"app": {
"api": {
"timeout": {
"read": { "server": 1500, "client": 15000 },
"mutation": 30000
},
"retry": { "times": 1, "delay": 100 }
}
}
}
A single number applies to every context at once: "timeout": 5000.
Per call, on the entity or request options:
const product = await api.createRequest({ url: '/products/:code', timeout: 4000 });
server.api.timeout overrides app.api.timeout server side, the same way the other api config keys
work.
idempotent: reads that are not GET
Some backends answer a search over POST (Coveo does). A method-based rule alone hands such a request
the mutation budget and never retries it. Declare what it actually is:
const results = await api.createRequest({
url: '/search',
method: HttpMethod.POST,
body: query,
// A read: gets the read deadline, and is retried like one.
idempotent: true
});
The inverse works too — idempotent: false protects a PUT/DELETE whose handler is not actually
idempotent.
Telling a timeout from an upstream failure
A request abandoned at its deadline fails with 408 and meta.reason === 'timeout', not with a
500:
try {
await api.createRequest({ url: '/products' });
} catch (error) {
if (HttpError.isTimeout(error)) {
// slow, not broken
}
}
This is what keeps the retry policy from retrying timeouts as if they were upstream errors, and what lets monitoring separate a slow upstream from a broken one.
Migrating from the flat 30s timeout
Before this, every request — SSR read, browser upload, order submission — shared one 30s deadline and
one retry policy that repeated anything failing with >= 500, timeouts included.
What changes for an existing project:
- Reads fail much sooner. An SSR read now gives up at 1.5s instead of 30s. A slow but healthy
upstream that used to make it into the server HTML at 20s is dropped to client side hydration
instead. That is the intended trade — the alternative is the whole page falling back — but it will
read as a regression to anyone measuring "content present in the SSR HTML". Raise
api.timeout.read.server(and the render budget with it) if a specific origin genuinely needs longer. - A timed out mutation is no longer retried. Re-test every write flow — cart, checkout, auth, address and payment updates. A transient failure that used to be papered over by a second attempt now surfaces to the user. This is a data-integrity fix: the retry could apply the same change twice.
- Timeouts report
408, not500. Error handling that branched onstatus >= 500, and dashboards that counted 500s, both need to account for 408. - Error timing changes on every request. Anything with its own timeout wrapped around a request
(a race, a
Promise.racedeadline, a test that waited on a 30s failure) now sees the framework fail first.
What does not change: an explicit timeout or retry on a request or an api config still wins,
shouldRetry and retryOnCodes still override the default policy entirely, and the 401 refresh
retry is unchanged.
Options are resolved per field
Defaults, api config and per-call options are merged field by field, ignoring keys whose value is
undefined. Services pass options on by spreading their own partials, which routinely produces a
present-but-undefined key:
// `timeout` is undefined here, and the api default still applies.
api.createRequest({ url, ...options, timeout: options.timeout });