Skip to main content

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

ContextDefaultRetried by default
Read during server side rendering1500 msyes
Read from the client (browser or native)15000 msyes
Mutation — POST, PATCH30000 msno
Mutation — PUT, DELETE (idempotent per RFC 9110)30000 msyes

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, not 500. Error handling that branched on status >= 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.race deadline, 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 });