Technical
Handle JavaScript Promise Errors Better
Handle promise errors in TypeScript: then/catch, try/catch, a typed result tuple, allSettled, AbortSignal timeouts and retries, with real output.
On this page
If you’re familiar with what promises are, you can skip the next section and jump straight to the setup.
Note: I first wrote this post in December 2023. This version is rewritten in TypeScript and adds timeouts, retries, the newer
Promisehelpers, and the mistakes I see most often. Every code sample type-checks understrictand the output under each one is real (Node 25).
What is a promise?
A promise is an object for a value that is not ready yet. It starts pending, then settles once: fulfilled with a value, or rejected with a reason. Your program keeps running while it waits.
Handling the rejected side is the part most code gets wrong. An unhandled rejection crashes a Node process by default, and in the browser it fails quietly while the UI waits for something that never comes.
Setup
Every example below uses this function.
It returns a promise that resolves with "Hi!", or rejects with an Error when you pass true.
function sayHi(shouldReject = false): Promise<string> {
return new Promise((resolve, reject) => {
if (shouldReject) {
reject(new Error("No, I won't say Hi!"));
return;
}
resolve("Hi!");
});
}
The old version of this post rejected with a plain string.
Don’t do that: a string has no stack trace, instanceof Error fails, and you can’t attach a cause.
1. then and catch
.then() handles the value and .catch() handles the error.
A single .catch() at the end catches a failure from any step above it.
await sayHi(true)
.then((message) => console.log(message))
.then(() => console.log("Skipped, the chain is already rejected"))
.catch((error: unknown) => console.log("Caught:", (error as Error).message));
Caught: No, I won't say Hi!
That is convenient, and it is also the problem: the .catch() can’t tell which step failed.
To handle each step on its own, you add a .catch() after it, and the chain recovers and keeps going:
await sayHi(true)
.catch((error: Error) => console.log("Step 1 failed:", error.message))
.then(() => {
throw new Error("Step 2 failed");
})
.catch((error: Error) => console.log("Caught:", error.message));
Step 1 failed: No, I won't say Hi!
Caught: Step 2 failed
Pros: short for a one-off call, works without an async function.
Cons: per-step handling gets noisy fast, and values from earlier steps are awkward to reach in later ones.
2. async/await with try/catch
await pauses the function until the promise settles.
If it rejects, await throws, so a normal try/catch handles it.
async function greet() {
try {
const message = await sayHi(true);
console.log(message);
} catch (error) {
console.log(error instanceof Error ? error.message : error);
} finally {
console.log("Runs either way");
}
}
await greet();
No, I won't say Hi!
Runs either way
In TypeScript the catch variable is unknown under strict, because JavaScript lets you throw anything.
Narrow it with instanceof Error before you read .message.
You’ll also see await mixed with .catch():
const message = await sayHi(true).catch((error: Error) => {
console.log(error.message);
});
console.log(message); // string | void
No, I won't say Hi!
undefined
This one is a trap.
The code after it still runs, with message set to undefined, and the error is gone by the time you’re on the next line.
TypeScript does warn you (the type is string | void), so read that type before you move on.
Pros: reads top to bottom, try can wrap exactly the step that can fail, and finally is built in.
Cons: the try block scopes your variables, so you end up declaring let outside it, and deep nesting creeps back.
3. A typed [error, data] tuple
When I want to handle an error on the next line instead of in a catch block, I use a small helper that never throws.
It’s the same idea as a Node callback’s (err, data) or Go’s value, err.
type Result<T> = [error: Error, data: undefined] | [error: undefined, data: T];
async function attempt<T>(promise: Promise<T>): Promise<Result<T>> {
try {
return [undefined, await promise];
} catch (error) {
return [error instanceof Error ? error : new Error(String(error)), undefined];
}
}
const [error, message] = await attempt(sayHi(true));
if (error) {
console.log("Failed:", error.message);
} else {
console.log(message.toUpperCase()); // message is a string here
}
Failed: No, I won't say Hi!
The union type is what makes this worth it.
Once you check error, TypeScript knows message is a string in the else branch, with no ! or as.
The helper also turns anything thrown into a real Error in one place.
The 2023 version of this post built the same thing on Promise.allSettled.
It works, but the try/catch version is shorter and doesn’t allocate an array for one promise.
Pros: flat code, the error is a value you can return or log, and types narrow for you.
Cons: it’s easy to forget the if (error) check, and nothing forces you to write it.
4. Many promises at once
Running independent work in parallel is where the choice of combinator matters most. Each one gives a single failure a different meaning.
const results = await Promise.allSettled([sayHi(true), sayHi()]);
for (const result of results) {
if (result.status === "fulfilled") console.log("ok:", result.value);
else console.log("failed:", (result.reason as Error).message);
}
try {
await Promise.all([sayHi(), sayHi(true), sayHi()]);
} catch (error) {
console.log("Promise.all:", (error as Error).message);
}
const first = await Promise.any([sayHi(true), sayHi()]);
console.log("Promise.any:", first);
try {
await Promise.any([sayHi(true), sayHi(true)]);
} catch (error) {
const all = error as AggregateError;
console.log(all.constructor.name, all.errors.length);
}
failed: No, I won't say Hi!
ok: Hi!
Promise.all: No, I won't say Hi!
Promise.any: Hi!
AggregateError 2
| Method | Resolves when | Rejects when | Use it for |
|---|---|---|---|
Promise.all | every promise resolves | the first one rejects | steps that are useless without each other |
Promise.allSettled | every promise settles | never | independent work where partial success is fine |
Promise.any | the first one resolves | all of them reject (AggregateError) | fallbacks, like trying two mirrors |
Promise.race | the first one settles | the first one settles with an error | rarely; a timeout race leaks the losing work |
Note:
Promise.allrejects fast, but it doesn’t stop the other promises. They keep running in the background. If they should stop, pass them anAbortSignaland abort it in yourcatch.
5. Timeouts and cancellation
A promise has no timeout of its own. A request to a server that never answers can keep your code waiting forever.
AbortSignal.timeout(ms) gives you a signal that aborts itself.
Anything that accepts a signal (fetch, most Node APIs, your own functions) can use it.
const slow = (ms: number, signal?: AbortSignal) =>
new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => resolve("done"), ms);
signal?.addEventListener("abort", () => {
clearTimeout(timer);
reject(signal.reason);
});
});
try {
await slow(5_000, AbortSignal.timeout(100));
} catch (error) {
console.log((error as DOMException).name);
}
TimeoutError
When the user can also cancel, combine both with AbortSignal.any().
Whichever fires first wins, and signal.reason tells you which one it was.
const userCancel = new AbortController();
const signal = AbortSignal.any([userCancel.signal, AbortSignal.timeout(5_000)]);
setTimeout(() => userCancel.abort(new Error("User pressed cancel")), 50);
try {
await slow(1_000, signal);
} catch (error) {
console.log((error as Error).message);
}
User pressed cancel
With fetch it’s one line: await fetch(url, { signal: AbortSignal.timeout(10_000) }).
This is better than racing your promise against a setTimeout with Promise.race, because the race only stops you waiting while the request itself keeps running.
6. Retries with backoff
Some errors go away on a second try: a 503, a dropped connection, a rate limit.
Others never will: a 400, a validation error, a bug.
Only retry the first kind.
async function retry<T>(
task: (attempt: number) => Promise<T>,
{ attempts = 3, baseMs = 200 }: { attempts?: number; baseMs?: number } = {},
): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await task(attempt);
} catch (error) {
lastError = error;
if (attempt === attempts) break;
const delay = baseMs * 2 ** (attempt - 1) + Math.random() * baseMs;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw new Error(`Failed after ${attempts} attempts`, { cause: lastError });
}
The delay doubles each time (200 ms, 400 ms, 800 ms), plus random jitter. The jitter matters when many clients fail at the same moment: without it they all retry at the same moment too.
let calls = 0;
const flaky = async () => {
calls++;
if (calls < 3) throw new Error(`Attempt ${calls} failed`);
return "Hi!";
};
console.log(await retry(flaky, { baseMs: 10 }));
try {
await retry(
async () => {
throw new Error("Server said 503");
},
{ baseMs: 10 },
);
} catch (error) {
const e = error as Error;
console.log(e.message, "|", (e.cause as Error).message);
}
Hi!
Failed after 3 attempts | Server said 503
cause keeps the original error attached, so your logs show both what gave up and why.
In a real app, add a check inside the catch that rethrows right away for errors that should not be retried.
Two newer helpers worth knowing
Promise.withResolvers() (ES2024) returns a promise together with its resolve and reject.
It’s for when the code that settles the promise lives somewhere else, like an event handler or a queue.
const { promise, resolve } = Promise.withResolvers<string>();
setTimeout(() => resolve("Resolved from outside"), 10);
console.log(await promise);
Resolved from outside
Promise.try() (ES2025) runs a function and always gives you a promise back.
If the function throws synchronously, you get a rejected promise instead of an exception that skips your .catch().
function parseConfig(raw: string): { port: number } {
return JSON.parse(raw); // throws synchronously on bad input
}
await Promise.try(() => parseConfig("{oops"))
.then((config) => console.log(config.port))
.catch((error: Error) => console.log("Caught:", error.name));
Caught: SyntaxError
Both work in current Chrome, Firefox, Safari and Node 24+.
For the types, Promise.withResolvers needs lib set to ES2024 or later, and Promise.try needs ESNext (TypeScript 5.9).
Mistakes I see most
- Forgetting
await. The promise runs, nobody waits for it, and its error becomes an unhandled rejection. Turn on@typescript-eslint/no-floating-promises; it catches every one of these. awaitinsideforEach.forEachignores the promises your callback returns, so nothing waits. Usefor...ofto run them in order, orPromise.all(items.map(...))to run them in parallel.- Rejecting with strings or plain objects. Use
Error, and pass the original failure ascausewhen you wrap it. - Catching and doing nothing. An empty
catchhides the failure from you and from your logs. If you really mean to ignore an error, leave a comment that says why. - Swallowing in the middle of a chain. A
.catch()that logs and returns nothing turns a rejection into a resolvedundefined, and the next step runs on it.
The rule of thumb
Here is what I do:
async/awaitwithtry/catchby default.attempt()when I want the error as a value on the next line.allSettledfor independent work,allwhen one failure makes the rest pointless.- A timeout on every network call.
- Retries only for errors that can succeed the second time.
If you handle promise errors differently, or you think I got one of these wrong, I’d like to hear it. The contact page has the fastest ways to reach me.
Thanks for reading! 🙏
FAQ
Should I use then/catch or async/await?
async/await for anything with more than one step. It reads top to bottom, lets you scope try/catch to the exact step that can fail, and gives you finally. A single .then().catch() is fine for a one-off call.
What is the difference between Promise.all and Promise.allSettled?
Promise.all rejects as soon as one promise rejects and gives you only that error. Promise.allSettled waits for every promise and never rejects; you get a status per item and decide what a failure means.
How do I add a timeout to a promise?
Pass AbortSignal.timeout(ms) to anything that accepts a signal, like fetch. It rejects with a TimeoutError. Combine it with your own AbortController through AbortSignal.any() when the user can also cancel.
Why is the error in catch typed as unknown in TypeScript?
Because anything can be thrown, not only Error objects. Narrow it with error instanceof Error before reading .message, or normalize it once in a helper.
Should I reject a promise with a string?
No. Reject with an Error (or a subclass). You get a stack trace, instanceof checks work, and you can chain the original failure through cause.