Skip to content

Retries & Timeouts

Add retries and timeouts to individual steps without wrapping your entire workflow in try/catch. For retries outside workflows (plain async/Result code), use tryAsyncRetry from awaitly/result/retry; see API Reference - Result retry.

Limit how long a step can run:

const data = await step.withTimeout(
'slowOp',
() => slowOperation(),
{ ms: 5000 }
);

The first argument is the step ID (used in events and timeout errors):

const data = await step.withTimeout(
'slowOp',
() => slowOperation(),
{ ms: 5000 }
);
/*
On timeout, error includes the name for debugging:
StepTimeoutError { name: 'Slow operation', ms: 5000 }
*/
import { isStepTimeoutError, getStepTimeoutMeta } from 'awaitly/workflow';
const result = await workflow.run(async ({ step, deps }) => {
return await step.withTimeout('slowOp', () => deps.slowOperation(), { ms: 1000 });
});
if (!result.ok && isStepTimeoutError(result.error)) {
const meta = getStepTimeoutMeta(result.error);
console.log(`${meta.name} timed out after ${meta.ms}ms`);
}
/*
Output:
Slow op timed out after 1000ms
*/

Control what happens when a timeout occurs using onTimeout:

// Default behavior - return timeout error
const data = await step.withTimeout(
'slowOp',
() => slowOperation(),
{ ms: 5000, onTimeout: 'error' }
);
// Returns StepTimeoutError when operation times out

Retry failed steps with configurable backoff:

const data = await step.retry(
'fetchData',
() => fetchData(),
{ attempts: 3 }
);

awaitly supports three backoff strategies. Each has different characteristics for different scenarios.

Same delay every time. Good for rate-limiting scenarios where you need consistent spacing.

{ attempts: 5, backoff: 'fixed', delayMs: 100 }
Fixed Backoff (delayMs: 100)
────────────────────────────
Attempt │ Delay │ Visual
────────┼────────┼─────────────────────
1 │ 100ms │ ████
2 │ 100ms │ ████
3 │ 100ms │ ████
4 │ 100ms │ ████
5 │ 100ms │ ████
Total max wait: 400ms

Delay increases linearly. Balances retry speed with backoff pressure.

{ attempts: 5, backoff: 'linear', delayMs: 100 }
Linear Backoff (delayMs: 100)
─────────────────────────────
Attempt │ Delay │ Visual
────────┼────────┼─────────────────────
1 │ 100ms │ ████
2 │ 200ms │ ████████
3 │ 300ms │ ████████████
4 │ 400ms │ ████████████████
5 │ 500ms │ ████████████████████
Total max wait: 1400ms

Delay doubles each time. The standard for network calls. Reduces load on struggling services.

{ attempts: 5, backoff: 'exponential', delayMs: 100 }
Exponential Backoff (delayMs: 100)
──────────────────────────────────
Attempt │ Delay │ Visual
────────┼────────┼─────────────────────────────────
1 │ 100ms │ ████
2 │ 200ms │ ████████
3 │ 400ms │ ████████████████
4 │ 800ms │ ████████████████████████████████
5 │ 1600ms │ (capped by maxDelayMs if set)
Total max wait: 3000ms (without cap)

Here’s how each strategy calculates delays:

// Helper to visualize backoff delays
const calculateDelay = (
strategy: 'fixed' | 'linear' | 'exponential',
attempt: number,
delayMs: number,
maxDelayMs?: number
): number => {
let delay: number;
switch (strategy) {
case 'fixed':
delay = delayMs;
break;
case 'linear':
delay = delayMs * attempt;
break;
case 'exponential':
delay = delayMs * Math.pow(2, attempt - 1);
break;
}
return maxDelayMs ? Math.min(delay, maxDelayMs) : delay;
};
// Example usage
console.log(calculateDelay('exponential', 5, 100)); // 1600
console.log(calculateDelay('exponential', 5, 100, 1000)); // 1000 (capped)

Use maxDelayMs to prevent delays from growing too large:

{
attempts: 10,
backoff: 'exponential',
delayMs: 100,
maxDelayMs: 5000, // Never wait more than 5 seconds
}
Exponential with Cap (delayMs: 100, maxDelayMs: 5000)
─────────────────────────────────────────────────────
Attempt │ Calculated │ Actual │ Visual
────────┼────────────┼─────────┼──────────────────────
1 │ 100ms │ 100ms │ ██
2 │ 200ms │ 200ms │ ████
3 │ 400ms │ 400ms │ ████████
4 │ 800ms │ 800ms │ ████████████████
5 │ 1600ms │ 1600ms │ ████████████████████
6 │ 3200ms │ 3200ms │ ████████████████████
7 │ 6400ms │ 5000ms │ ████████████████████ ← capped
8 │ 12800ms │ 5000ms │ ████████████████████ ← capped

Randomize delays to avoid the “thundering herd” problem, when many clients retry simultaneously after a service recovers.

{
attempts: 3,
backoff: 'exponential',
delayMs: 100,
jitter: true, // Adds random variation ±50%
}
Without Jitter (all clients) With Jitter (clients spread out)
──────────────────────────── ────────────────────────────────
Time → Time →
100ms: ████████████████████ 80ms: ████
(all clients retry) 95ms: ████████
112ms: ██████
140ms: ████████████████
Load distributed!

With jitter: true, the actual delay is randomized within ±50% of the base delay:

// With delayMs: 200 and jitter: true
// Possible delays: 100ms to 300ms (200 ± 50%)
// Internal calculation:
const baseDelay = 200;
const jitterRange = baseDelay * 0.5; // 100
const actualDelay = baseDelay - jitterRange + (Math.random() * jitterRange * 2);
// Results in: 100ms to 300ms

Only retry certain errors. Don’t retry permanent failures:

const user = await step.retry(
'fetchUser',
() => fetchUser('1'),
{
attempts: 3,
backoff: 'exponential',
retryOn: (error) => {
// Don't retry NOT_FOUND - the user doesn't exist
if (error === 'NOT_FOUND') return false;
// Don't retry INVALID_ID - it will never work
if (error === 'INVALID_ID') return false;
// Retry everything else (network errors, timeouts, etc.)
return true;
},
}
);
// Retry only network/server errors
retryOn: (error) => {
const noRetry = ['NOT_FOUND', 'UNAUTHORIZED', 'INVALID_INPUT', 'DUPLICATE'];
return !noRetry.includes(error);
}
// Retry only rate limits
retryOn: (error) => error === 'RATE_LIMITED'
// Retry HTTP 5xx only
retryOn: (error) => error.status >= 500

Each attempt has its own timeout:

const data = await step.retry(
'fetchData',
() => step.withTimeout('fetchData', () => fetchData(), { ms: 2000 }),
{ attempts: 3, backoff: 'exponential', delayMs: 100 }
);
Timeline with Retry + Timeout
─────────────────────────────
├── Attempt 1 ────────────────────────► timeout at 2s
│ (wait 100ms)
├── Attempt 2 ────────────────────────► timeout at 2s
│ (wait 200ms)
├── Attempt 3 ────────────────────────► success or final failure
Total max time: 2s + 100ms + 2s + 200ms + 2s = 6.3s

awaitly’s retry options can be combined for sophisticated resilience patterns:

// Like AWS SDK default behavior
const data = await step.retry(
'callExternalApi',
() => callExternalApi(),
{
attempts: 5,
backoff: 'exponential',
delayMs: 100,
maxDelayMs: 5000,
jitter: true,
}
);

You can also configure retry and timeout directly in step options:

const user = await step('Fetch user', () => fetchUser('1'), {
retry: {
attempts: 3,
backoff: 'exponential',
delayMs: 100,
jitter: true,
},
timeout: {
ms: 5000,
},
});
import { createWorkflow } from 'awaitly/workflow';
const workflow = createWorkflow('workflow', { fetchUserFromApi, cacheUser });
const result = await workflow.run(async ({ step, deps }) => {
// Retry API calls with production-ready settings
const user = await step.retry(
'fetchUser',
() => step.withTimeout(
'fetchUser',
() => deps.fetchUserFromApi('123'),
{ ms: 3000 }
),
{
attempts: 3,
backoff: 'exponential',
delayMs: 200,
maxDelayMs: 2000,
jitter: true,
retryOn: (error) => error !== 'NOT_FOUND',
}
);
// Cache doesn't need retry - it's local and fast
await step('cacheUser', () => deps.cacheUser(user));
return user;
});
if (!result.ok) {
console.log('Failed after retries:', result.error);
}
/*
Output (success):
{ ok: true, value: User }
Output (failure after 3 attempts):
{ ok: false, error: 'NETWORK_ERROR' }
Output (immediate failure, no retries):
{ ok: false, error: 'NOT_FOUND' }
*/
OptionTypeDefaultDescription
attemptsnumberrequiredMax retry attempts
backoff'fixed' | 'linear' | 'exponential''fixed'Delay growth strategy
delayMsnumber0Base delay in milliseconds
maxDelayMsnumberundefinedMaximum delay cap
jitterbooleanfalseAdd random variation
retryOn(error) => boolean() => trueCondition for retry

Learn about Caching →