Decorator Pattern in Node.js
Part of my Node.js Design Patterns series.
The forty-line retry block pasted into six partner clients, from the introduction to this series, is the Decorator pattern's origin story in most codebases. Everyone needed retries. Nobody had a way to add them to a function without opening the function. So they opened six.
A decorator takes something that works and returns something that works the same way plus one more thing: retries, a timeout, a cache, timing. The original is untouched. In JavaScript the cleanest decorator is a function that takes a function and returns a function.
The pattern in modern Node
import { setTimeout as sleep } from "node:timers/promises";
// Retry on failure with exponential backoff. Only for calls that are safe to repeat.
export function withRetry(fn, { attempts = 3, baseMs = 100 } = {}) {
return async function retried(...args) {
let lastError;
for (let i = 0; i < attempts; i++) {
try {
return await fn(...args);
} catch (err) {
lastError = err;
if (i < attempts - 1) await sleep(baseMs * 2 ** i);
}
}
throw lastError;
};
}
// Give up after ms. AbortSignal.timeout is built into Node 24.
export function withTimeout(fn, ms) {
return async function timed(...args) {
const signal = AbortSignal.timeout(ms);
const abort = new Promise((_, reject) => {
signal.addEventListener("abort", () => reject(new Error(`timed out after ${ms}ms`)));
});
return Promise.race([fn(...args), abort]);
};
}
// Cache by first argument for ttl ms.
export function withCache(fn, { ttlMs = 1000 } = {}) {
const cache = new Map();
return async function cached(key, ...rest) {
const hit = cache.get(key);
if (hit && hit.expires > Date.now()) return hit.value;
const value = await fn(key, ...rest);
cache.set(key, { value, expires: Date.now() + ttlMs });
return value;
};
}import { withRetry, withTimeout, withCache } from "./decorators.mjs";
let calls = 0;
async function fetchMenu(storeId) {
calls++;
if (calls === 1) throw new Error("partner returned 503");
return { storeId, items: 42, fetchedOnCall: calls };
}
// Read inside-out: fetchMenu, then timeout around it, then retry around that, then cache.
const getMenu = withCache(withRetry(withTimeout(fetchMenu, 2000)), { ttlMs: 60_000 });
console.log(await getMenu("store-7")); // first call fails, retry succeeds
console.log(await getMenu("store-7")); // served from cache, calls stays at 2
console.log("partner called", calls, "times");Composition order is the design decision. Cache outside retry means a cached value never triggers a network call. Timeout inside retry means each attempt gets its own clock. Swap them and you get a retry loop that can run for six seconds, or a cache that stores a timeout error.
Decorating a whole object with Proxy
Wrapping every method of a client one by one gets tedious. A Proxy decorates all of them at once:
export function withTiming(client, label) {
return new Proxy(client, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
if (typeof value !== "function") return value;
return async (...args) => {
const start = performance.now();
try {
return await value.apply(target, args);
} finally {
console.log(`${label}.${String(prop)} ${(performance.now() - start).toFixed(1)}ms`);
}
};
},
});
}withTiming(createClient("grab"), "grab") now logs every method call on that client, and the client's file was never opened.
Where TC39 decorators stand
The @decorator syntax on classes and methods reached stage 3 in 2022 and TypeScript 5 implements it, but as of Node 26 the runtime does not, so @retry() on a method still needs a transpile step; Node's built-in type stripping deliberately refuses syntax that needs code generation. If your codebase is TypeScript with a build, class decorators are pleasant. If you run .ts files directly on Node, the function shape above is the one that works today, and it works on plain objects too.
Where it goes wrong: the retry that made it worse
The failure I owe this post to. A partner's order endpoint started timing out under load. Our withRetry did what it was told: three attempts, for every request, from every store. Their load tripled, their recovery slowed, and our queue backed up behind requests that were never going to succeed. Two lessons went into the decorator afterwards:
- Never retry a non-idempotent call. Retrying "create order" can create two orders. Retry reads and acknowledgements, not writes, unless the partner supports an idempotency key and you send one.
- Retries need a circuit breaker. After N consecutive failures, stop calling for a while and fail fast. That is another decorator, and the right place for it is outside
withRetryso it counts attempts, not calls.
Other traps, briefly: a decorator that swallows the original's this (use apply(target, args) as above, or arrow functions carefully); a cache decorator keyed on an object argument, so every call misses; and stacking so many decorators that a stack trace is twelve frames of retried, timed, cached before the real function. Name the returned functions, as the examples do, precisely so those frames read as something.
When not to use it
If only one function will ever need retries, put the loop in the function. The decorator earns its keep the second time, and by the sixth, it is the difference between a one-line fix and a pull request with six files changed.
That closes the series. If you found one of these useful, the introduction has the map of all seven, ordered by the problem you are probably staring at.

Nguyễn Hải Nam
Nguyễn Hải Nam
Project Management Lead. 16+ years from code to delivery. PMP®. Writing here about project management and engineering.