Retrying runs an async operation again after it fails. It can make transient failures less visible to users, but it can also repeat side effects and increase load on an unhealthy service.
AsyncRetryer is an alpha API and may change before 1.0. Its current design also supports the retry behavior inside Pacer's other async utilities.
If TanStack Query already owns the request, use its retry support so one system controls request state and cancellation.
Retry only errors that are likely to succeed later, such as a temporary network failure, a rate-limit response, or some server errors. Validation, authentication, permission, and most other client errors usually need a code or user-input change instead.
AsyncRetryer retries every thrown error. It does not provide a shouldRetry predicate. Make the wrapped function throw only for retriable outcomes:
async function loadUser(id: string) {
const response = await fetch(`/api/users/${id}`)
if (response.status === 429 || response.status >= 500) {
throw new Error(`Temporary failure: ${response.status}`)
}
if (!response.ok) {
return { ok: false as const, status: response.status }
}
return { ok: true as const, user: await response.json() }
}async function loadUser(id: string) {
const response = await fetch(`/api/users/${id}`)
if (response.status === 429 || response.status >= 500) {
throw new Error(`Temporary failure: ${response.status}`)
}
if (!response.ok) {
return { ok: false as const, status: response.status }
}
return { ok: true as const, user: await response.json() }
}Also consider whether repeating the operation is idempotent. Reads are commonly safe. Writes may create duplicate records, charges, messages, or other side effects when the first response is lost after the server completes the operation. Use idempotency keys or server-side deduplication before retrying such writes.
asyncRetry creates one retryer and returns its bound execution function:
import { asyncRetry } from '@tanstack/pacer'
const loadUserWithRetry = asyncRetry(loadUser, {
maxAttempts: 3,
baseWait: 1000,
jitter: 0.2,
})
try {
const result = await loadUserWithRetry('123')
console.log(result)
} catch (error) {
console.error('All attempts failed:', error)
}import { asyncRetry } from '@tanstack/pacer'
const loadUserWithRetry = asyncRetry(loadUser, {
maxAttempts: 3,
baseWait: 1000,
jitter: 0.2,
})
try {
const result = await loadUserWithRetry('123')
console.log(result)
} catch (error) {
console.error('All attempts failed:', error)
}The defaults are three total attempts, exponential backoff from 1000 milliseconds, no maximum delay, no jitter, and throwOnError: 'last'.
The returned function can be reused sequentially. It owns one AsyncRetryer, so starting a new call while an earlier call is active aborts the earlier retry flow. When calls may overlap, create a retryer per call or use another utility that manages concurrency:
import { AsyncRetryer } from '@tanstack/pacer'
async function loadOneUser(id: string) {
const retryer = new AsyncRetryer(loadUser, { maxAttempts: 3 })
return retryer.execute(id)
}import { AsyncRetryer } from '@tanstack/pacer'
async function loadOneUser(id: string) {
const retryer = new AsyncRetryer(loadUser, { maxAttempts: 3 })
return retryer.execute(id)
}Use AsyncRetryer when you need callbacks, state, changing options, or manual abort control:
import { AsyncRetryer } from '@tanstack/pacer'
const retryer = new AsyncRetryer(loadUser, {
maxAttempts: 4,
backoff: 'exponential',
baseWait: 500,
maxWait: 5000,
jitter: 0.2,
onRetry: (attempt, error) => {
console.log(`Attempt ${attempt} failed; retrying`, error)
},
onLastError: (error) => {
console.error('Attempts exhausted:', error)
},
})
const result = await retryer.execute('123')import { AsyncRetryer } from '@tanstack/pacer'
const retryer = new AsyncRetryer(loadUser, {
maxAttempts: 4,
backoff: 'exponential',
baseWait: 500,
maxWait: 5000,
jitter: 0.2,
onRetry: (attempt, error) => {
console.log(`Attempt ${attempt} failed; retrying`, error)
},
onLastError: (error) => {
console.error('Attempts exhausted:', error)
},
})
const result = await retryer.execute('123')maxAttempts includes the first call. A value of 1 disables retries while retaining the result, error, timeout, callback, and abort behavior.
The first attempt starts immediately. A delay is calculated only after a failed attempt that has another attempt available.
With baseWait: 1000, the nominal delays are:
| Failed attempt | Exponential | Linear | Fixed |
|---|---|---|---|
| 1 | 1000 ms | 1000 ms | 1000 ms |
| 2 | 2000 ms | 2000 ms | 1000 ms |
| 3 | 4000 ms | 3000 ms | 1000 ms |
| 4 | 8000 ms | 4000 ms | 1000 ms |
maxWait caps the nominal delay before jitter. baseWait, maxWait, and maxAttempts can also be functions that receive the retryer instance.
Many clients can fail at the same time and otherwise retry in synchronized waves. jitter adds random variation above or below each nominal delay:
const retryer = new AsyncRetryer(loadUser, {
maxAttempts: 4,
baseWait: 1000,
maxWait: 10_000,
jitter: 0.25,
})const retryer = new AsyncRetryer(loadUser, {
maxAttempts: 4,
baseWait: 1000,
maxWait: 10_000,
jitter: 0.25,
})Use a value from 0 to 1, where 0.25 allows up to 25 percent variation. Because jitter is applied after maxWait, the final randomized delay can be slightly greater than maxWait.
The error callbacks serve different levels of the retry lifecycle:
throwOnError controls the final failed result:
Providing onError changes the default throwOnError value to false. Set it explicitly if you want both observation and rejection:
const retryer = new AsyncRetryer(loadUser, {
onError: (error) => reportError(error),
throwOnError: 'last',
})const retryer = new AsyncRetryer(loadUser, {
onError: (error) => reportError(error),
throwOnError: 'last',
})An AbortError thrown by the wrapped function is treated as cancellation. It resolves with undefined without consuming further attempts or calling onAbort automatically.
Two independent options limit retry work:
const retryer = new AsyncRetryer(loadUser, {
maxAttempts: 4,
maxExecutionTime: 5000,
maxTotalExecutionTime: 15_000,
onExecutionTimeout: () => console.warn('Attempt timed out'),
onTotalExecutionTimeout: () => console.warn('Retry operation timed out'),
})const retryer = new AsyncRetryer(loadUser, {
maxAttempts: 4,
maxExecutionTime: 5000,
maxTotalExecutionTime: 15_000,
onExecutionTimeout: () => console.warn('Attempt timed out'),
onTotalExecutionTimeout: () => console.warn('Retry operation timed out'),
})A timeout aborts the retry flow. onAbort receives either 'execution-timeout' or 'total-timeout'. A total timeout normally resolves with undefined. A per-attempt timeout can reject with its final timeout or abort error when throwOnError is enabled, and resolves with undefined when error throwing is disabled.
JavaScript cannot forcibly stop an arbitrary Promise. A timeout prevents Pacer from continuing the retry flow, but the underlying operation stops only if it cooperates with the abort signal.
Call getAbortSignal() from the wrapped function and pass the signal to an API that supports it:
const retryer = new AsyncRetryer(
async (url: string) => {
const response = await fetch(url, {
signal: retryer.getAbortSignal() ?? undefined,
})
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
return response.json()
},
{ maxAttempts: 3 },
)
const request = retryer.execute('/api/data')
retryer.abort()
await request // undefinedconst retryer = new AsyncRetryer(
async (url: string) => {
const response = await fetch(url, {
signal: retryer.getAbortSignal() ?? undefined,
})
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
return response.json()
},
{ maxAttempts: 3 },
)
const request = retryer.execute('/api/data')
retryer.abort()
await request // undefinedabort() stops the active retry flow and pending backoff. onAbort receives 'manual'. Starting another execute() on the same instance aborts the earlier flow with 'new-execution'.
Without passing the signal to the operation, abort() prevents later retry work but the active Promise may continue running in the background.
setOptions() merges new options into the current configuration. It does not restart an active execution.
reset() restores default state only. It does not abort active work:
retryer.abort()
retryer.reset()retryer.abort()
retryer.reset()Use asyncRetryerOptions() to define reusable, type-checked option objects:
const networkRetryOptions = asyncRetryerOptions({
maxAttempts: 3,
backoff: 'exponential',
baseWait: 1000,
jitter: 0.2,
})const networkRetryOptions = asyncRetryerOptions({
maxAttempts: 3,
backoff: 'exponential',
baseWait: 1000,
jitter: 0.2,
})The class stores state at retryer.store.
To restore selected state that your app has persisted, pass a partial snapshot through initialState. It is merged with the defaults. Restore only durable fields. Pending timers and active executions are not restored.
Commonly useful properties include:
AsyncRetryer is a core API and does not have framework-specific hooks. Subscribe through retryer.store or the appropriate TanStack Store adapter when reactive state is needed.
For exact signatures, see the asyncRetry function reference, AsyncRetryer class reference, and AsyncRetryerOptions reference.