# Two tiny functions that make your async code production-ready: `retry` and `timeout`

> Source: <https://dev.to/danikeya/two-tiny-functions-that-make-your-async-code-production-ready-retry-and-timeout-44e3>
> Published: 2026-05-24 00:01:00+00:00

Every async function you write assumes the network cooperates, the server responds, and the database doesn't hiccup. In production, none of those assumptions hold forever.

Here are two higher-order functions — each under 15 lines — that make any async function resilient without touching its internals.

## The problem

You have an async function. Maybe it calls an API, queries a database, or reads a file over the network.

``` js
async function fetchUserData(userId) {
    const response = await fetch(`/api/users/${userId}`);
    return response.json();
}
```

Two things will go wrong eventually:

- It will fail intermittently and you'll want to retry it
- It will hang indefinitely and you'll want to give up after a deadline

You could wrap every function in retry logic and timeout logic inline. Or you could write it once, properly, and wrap any function you want.

## `retry`

— automatic reattempts on failure

```
export function retry(count, callback) {
  return async function (...args) {
    let attempts = 0;
    let lastError;

    while (attempts <= count) {
      try {
        return await callback(...args);
      } catch (err) {
        lastError = err;
        attempts++;
      }
    }

    throw lastError;
  };
}
```

### How it works

`retry`

is a higher-order function — it takes a function and returns a new function with retry behaviour baked in. The original function is untouched.

The `while (attempts <= count)`

condition is deliberate. If `count`

is `3`

, the loop runs when `attempts`

is `0, 1, 2, 3`

— that's 4 total executions: one initial attempt plus three retries. This matches the natural language meaning of "retry 3 times".

On success, `return await callback(...args)`

exits immediately — no more iterations. On failure, the error is stored in `lastError`

and `attempts`

increments. Once the loop exhausts all attempts, the last error is rethrown — not a generic `new Error('Max retries reached')`

, but the actual error the callback produced. Your callers get a meaningful error message, not a wrapper.

### Usage

``` js
const resilientFetch = retry(3, fetchUserData);

// Works exactly like fetchUserData, but retries up to 3 times on failure
const user = await resilientFetch('user_123');
```

### Why `await`

inside `try`

matters

```
try {
    return await callback(...args); // ✓ catches rejected promises
} catch (err) { ... }
```

Without `await`

, a rejected promise escapes the try/catch entirely:

```
try {
    return callback(...args); // ✗ returns a pending promise — catch never fires
} catch (err) { ... }
```

`await`

unwraps the promise inside the try block, so rejections are catchable. This is one of the most common async/await mistakes and `retry`

only works correctly because it gets this right.

## `timeout`

— give up after a deadline

```
export function timeout(delay, callback) {
  return async function (...args) {
    const timer = new Promise((_, reject) =>
      setTimeout(() => reject(new Error('timeout')), delay)
    );

    return Promise.race([callback(...args), timer]);
  };
}
```

### How it works

`Promise.race`

resolves or rejects with whichever promise settles first. This function creates a race between two competitors:

-
`callback(...args)`

— the actual work -
`timer`

— a promise that rejects after`delay`

milliseconds

If the callback finishes in time, its value wins and `timer`

becomes irrelevant. If `delay`

milliseconds pass first, `timer`

rejects with `Error('timeout')`

and the callback's eventual result is ignored.

Notice the timer promise is constructed with `(_, reject)`

— it never resolves, only rejects. This ensures the timer can never accidentally win the race with a successful value; it can only interrupt with a failure.

### Usage

``` js
const limitedFetch = timeout(5000, fetchUserData);

try {
    const user = await limitedFetch('user_123');
} catch (e) {
    if (e.message === 'timeout') {
        console.error('Request took too long');
    }
}
```

## Combining them

Both functions return async functions with the same signature as their input — which means they compose cleanly.

``` js
// Retry up to 3 times, but abandon any single attempt after 5 seconds
const resilientFetch = retry(3, timeout(5000, fetchUserData));

await resilientFetch('user_123');
```

Here's what happens on each attempt:

-
`timeout(5000, fetchUserData)`

races the fetch against a 5-second timer - If it times out,
`timeout`

rejects with`Error('timeout')`

-
`retry`

catches that rejection, increments attempts, and tries again - After 3 retries all fail,
`retry`

rethrows the last error

Four attempts, each with a 5-second ceiling, maximum 20 seconds total. All from two composable functions and one line of setup.

## What makes these worth keeping

**They don't modify the original function.** `fetchUserData`

is unchanged. You can use it with or without retry/timeout anywhere else.

**They forward arguments transparently.** `...args`

passes everything through — the wrapped function behaves identically to the original from the caller's perspective.

**They preserve the error.** `retry`

rethrows `lastError`

, not a new generic error. `timeout`

rejects with a named `Error('timeout')`

you can check by message. Callers always know what actually went wrong.

**They compose.** Because both return async functions with matching signatures, you can layer them in any order and they work together without knowing about each other.

## The pattern

Both functions follow the same structure:

```
higherOrderFn(config, callback) {
    return async function (...args) {
        // enhanced behaviour around callback(...args)
    }
}
```

This is the decorator pattern applied to async functions. You write the enhancement once, and apply it to any async function that needs it — no inheritance, no classes, no modification of the original. Just functions wrapping functions.

It's a small pattern. It shows up everywhere once you start looking for it.
