Most promise bugs are not exotic. They are the same handful of mistakes, made over and over, and they bite hardest when the code looks like it works. Below are the eight I flag most in code review, each with the problem and the fix. If you want the underlying model first (states, chaining, async/await), read the complete guide to JavaScript promises; this page is the troubleshooting companion.
Why does an unhandled promise rejection crash my Node process?
Because that is the default. Since Node 15 (October 2020), an unhandled rejection terminates the process with a fatal error, the same as an unhandled throw. The old behavior, a UnhandledPromiseRejectionWarning that let the process limp on in a broken state, is gone unless you opt back into it with --unhandled-rejections=warn.
// Rejects, nothing catches it. On Node 15+ the process exits non-zero.
async function te_loadUser(id) {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
te_loadUser(42); // no await, no .catchThe fix is to actually handle the rejection at the call site, with try/catch around await or a .catch on the chain:
try {
const user = await te_loadUser(42);
} catch (err) {
// log, retry, surface to the user, whatever the situation needs
console.error("user load failed:", err);
}process.on('unhandledRejection', ...) is a last-resort logger, not a fix. It is fine for capturing telemetry on the way down, but if you are using it to keep the process alive past an unhandled rejection, you are running an app whose state you no longer trust. Handle the rejection where it happens; let the global handler log and exit.
Should I wrap a fetch call in new Promise?
No. If the thing you are calling already returns a promise, new Promise around it is pure noise, and it is the single most common antipattern I see. It is sometimes called the explicit-construction antipattern: you take a perfectly good promise and re-wrap it, adding a layer where rejections can leak.
// Antipattern: fetch already returns a promise.
function te_getJson(url) {
return new Promise((resolve, reject) => {
fetch(url)
.then((res) => res.json())
.then(resolve)
.catch(reject);
});
}Just return the chain (or await it in an async function):
async function te_getJson(url) {
const res = await fetch(url);
return res.json();
}new Promise is for one job only: wrapping a callback-based API that does not speak promises yet, like setTimeout or an old event emitter. If there is already a promise in your hand, do not build another one around it.
Why does my .then chain not pass the value down?
Almost always because a .then callback forgot to return. The chain links on what each callback returns; drop the return and the next .then receives undefined, and any rejection inside the omitted branch escapes the chain instead of propagating to your trailing .catch.
// Bug: the inner fetch is fired but not returned, so the chain
// does not wait for it and the second .then gets undefined.
function te_loadProfile(id) {
return fetch(`/api/users/${id}`)
.then((res) => res.json())
.then((user) => {
fetch(`/api/orders/${user.id}`); // missing return
})
.then((orders) => orders.json()); // orders is undefined
}Return the promise so the chain sequences and errors propagate:
function te_loadProfile(id) {
return fetch(`/api/users/${id}`)
.then((res) => res.json())
.then((user) => fetch(`/api/orders/${user.id}`)) // returned
.then((orders) => orders.json());
}This is one of the strongest arguments for async/await: with await there is no callback to forget a return in. The line either resolves to a value or throws.
Why does my async forEach not wait?
Because forEach ignores the promise its callback returns. It is a synchronous method: it calls your callback once per element and moves on immediately, so an async callback fans out all the work at once and your surrounding await resolves before any of it finishes.
// Looks sequential, runs in parallel, and the function returns
// before a single save completes.
async function te_saveAll(items) {
items.forEach(async (item) => {
await te_save(item); // forEach does not await this
});
console.log("done"); // logs before any save finishes
}For sequential work, use for...of, which does honor await:
async function te_saveAll(items) {
for (const item of items) {
await te_save(item);
}
console.log("done"); // now this is true
}If the work can run in parallel, that is a feature, not a bug, but make it explicit with await Promise.all(items.map(te_save)) so you actually wait for the batch. For the difference between all, allSettled, race, and any, see which promise combinator to reach for.
Can I mix await and .then in the same function?
You can, and it compiles, but it almost always reads worse than picking one. Mixing the two scatters the error handling: half your failures land in a try/catch, half in a .catch, and the control flow zig-zags.
// Two styles in one breath: hard to follow, easy to drop an error.
async function te_publish(id) {
const post = await te_load(id);
return te_render(post).then((html) => te_upload(html));
}Commit to await:
async function te_publish(id) {
const post = await te_load(id);
const html = await te_render(post);
return te_upload(html);
}One exception is deliberate: appending .catch or .finally to an awaited expression to handle a single step inline. That is intentional and reads fine. The thing to avoid is interleaving the two styles for no reason across a function.
What is a floating promise and why is it dangerous?
A floating promise is one you start and then neither await nor attach a .catch to. The call kicks off, you walk away, and if it rejects you get an unhandled rejection (which, per the first mistake, now crashes Node). Even when it resolves, you have lost ordering: the rest of your function does not wait for it.
function te_handler(req, res) {
te_writeAuditLog(req); // floating: not awaited, not caught
res.send("ok");
}Decide what you mean. If the result matters, await it inside an async function. If it is genuinely fire-and-forget, say so by attaching a .catch so a failure is logged instead of crashing the process:
async function te_handler(req, res) {
await te_writeAuditLog(req).catch((err) =>
console.error("audit log failed:", err)
);
res.send("ok");
}Linters catch these well: TypeScript's no-floating-promises rule (from typescript-eslint) is worth turning on for exactly this class of bug.
Do I need Promise.resolve() to return a value from an async function?
No, and it is redundant. An async function always returns a promise; whatever you return is wrapped for you, and whatever you throw becomes a rejection. Wrapping the return value in Promise.resolve() yourself is belt-and-braces that adds nothing.
// Redundant: async already wraps the return value.
async function te_config() {
return Promise.resolve({ retries: 3 });
}Just return the value:
async function te_config() {
return { retries: 3 }; // caller still gets a promise
}The same applies to a plain (non-async) function: if it returns a promise, the caller can await it directly, no Promise.resolve shim required. await on a non-promise value also just hands the value straight back, so you rarely need to normalize by hand.
Does .finally swallow a rejection?
No, and counting on it to is a real bug. .finally runs its callback for cleanup regardless of outcome, but it does not change the outcome: a rejected promise is still rejected after .finally runs, and the rejection keeps propagating. If nothing downstream catches it, it is unhandled.
// Misconception: finally does not "handle" the rejection.
function te_fetchWithSpinner(url) {
showSpinner();
return fetch(url).finally(() => hideSpinner());
// if fetch rejects, this still rejects; finally did not absorb it
}.finally is for the cleanup that must happen either way (hide the spinner, close the connection). The error handling is still your job, with a .catch or a surrounding try/catch:
async function te_fetchWithSpinner(url) {
showSpinner();
try {
const res = await fetch(url);
return res.json();
} finally {
hideSpinner(); // runs on success and on throw
}
}The one thing .finally will quietly change the outcome for is if its own callback throws or returns a rejecting promise, which replaces the original result. Keep .finally callbacks side-effect-only and they stay predictable.
FAQ
See also
- JavaScript promises: the complete guide: states, chaining, and
async/awaitfrom the ground up. - Promise.all vs allSettled vs race vs any: which combinator to reach for, and the gotchas in each.
- Add a timeout to fetch() with AbortController: cancel a request that hangs, in the browser and in Node.
Sources
Authoritative references this article was fact-checked against.
- Promise — MDN Web Docsdeveloper.mozilla.org
- Array.prototype.forEach() — MDN Web Docsdeveloper.mozilla.org
- process 'unhandledRejection' event — Node.js docsnodejs.org
- --unhandled-rejections CLI flag — Node.js docsnodejs.org





