new Promise(async ...) Trap: The Rejection That Vanishes Into the Void2026-06-13
This function is supposed to fetch a user record and propagate any HTTP errors to the caller. The happy path works perfectly. When the server returns 404, the caller's catch block never fires — and worse, the await never returns. The request just hangs forever.
async function fetchUser(id) {
return new Promise(async (resolve, reject) => {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const user = await response.json();
resolve(user);
});
}
// Caller
async function showProfile(id) {
try {
const user = await fetchUser(id);
renderProfile(user);
} catch (err) {
showError(`Could not load user: ${err.message}`);
}
}
// Works on 200. On 404, showError never runs.
// The spinner spins forever. Sentry stays quiet.
Mixing new Promise(...) with an async executor function is one of the most dangerous patterns in modern JavaScript, precisely because it looks reasonable.
Here's what actually happens. new Promise(executor) ignores the executor's return value entirely — it only cares about resolve and reject being called. But async functions always return a promise: a fulfilled one for normal returns, a rejected one for thrown errors. So when you pass an async function as the executor, you create two promises:
new Promise(...), whose fate depends on resolve/reject.Promise constructor silently discards.When throw new Error(...) fires, it rejects the inner promise. That rejection has no listener, so it bubbles out as an unhandledrejection event (which your logging may or may not capture). Meanwhile, reject was never called, so the outer promise stays in the pending state forever. Your await on it never resumes, your try/catch never sees an error, and the function just… disappears.
Network errors from fetch itself behave identically. So does any sync exception in the executor body. Every error path leaks into the void.
Stop wrapping. An async function already returns a promise — that's the whole point. The new Promise wrapper is not just redundant, it's actively breaking error propagation:
async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
The new Promise constructor is only the right tool when you're adapting a non-promise API — event emitters, callbacks, setTimeout. The moment you reach for await inside the executor, you've already left that world. Drop the wrapper.
If you must keep the constructor (say, you're bridging an event handler with some async setup), then either keep the executor synchronous, or wrap the async body in its own try/catch and forward errors explicitly:
return new Promise((resolve, reject) => {
(async () => {
try {
const data = await doAsyncWork();
resolve(data);
} catch (err) {
reject(err); // bridge inner rejection to outer
}
})();
});
ESLint's no-async-promise-executor rule catches this exact pattern and is on by default in eslint:recommended. Turn it on; the bug is too quiet to find by hand.
new Promise(async ...) creates two promises and throws one away — every error thrown inside the async executor rejects the discarded one, leaving your caller's await hanging forever.
