The async Keyword That Eats Your Errors: Why new Promise(async ...) Is a Trap

One tiny keyword, and your rejections quietly walk off the job.
If you've ever chased an Uncaught (in promise) red box whose stack trace points at absolutely nothing useful — some framework internal, three layers away from any code you wrote — you already know the specific flavor of despair I'm talking about. The error is real. The thing it's blaming is not. And the actual failure happened somewhere the stack never mentions.
The remedy turns out to be almost insultingly simple. Before you do anything else, grep your codebase for this:
new Promise(async
If you get hits, there's a decent chance you've found your ghost.
The Pattern That Looks Fine
Here's the shape of the trap:
new Promise(async (resolve, reject) => {
// ... some awaits in here
});
It reads like reasonable code. You want to await a few things inside the executor, so you slap async on the function and move on. Every linter-free editor will let you. It'll even work — for the happy path, for the cases you test locally, for the demo.
The problem is what happens when something in there rejects.
Why It Silently Swallows Your Errors
The Promise constructor's executor is meant to be a synchronous function that synchronously calls resolve or reject. That's the entire contract.
The moment you mark it async, the function stops being that synchronous callback and starts returning a promise — a promise the Promise constructor never sees and has no reference to. So when a non-awaited await inside rejects, or a synchronous throw fires before your inner try block catches it, there is no path from that inner rejection back to the outer reject.
Two failure modes fall out of this:
- The outer promise sits pending forever — nobody ever resolves or rejects it, so whatever's awaiting it just... waits.
- Or the inner rejection surfaces as an
Uncaught (in promise)— with a stack that doesn't point at the real failure site, because by the time it bubbles up, you're miles from where it broke.
This failure mode is silent enough that ESLint ships a dedicated rule for it — no-async-promise-executor — precisely because you cannot rely on noticing it yourself.
A Real One in the Wild
This isn't a hypothetical. I ran into it while tearing apart an open-source React Native chat library, ethora-chat-component-rn — not my project, just one I was reading to see how a real product wires up XMPP. (Reading other people's source is a hobby. I don't make the rules.)
Its changelog for the 26.5.8 release documents a src/-wide promise-hygiene sweep that flagged this exact anti-pattern in two files — getRoomsPaged.xmpp.ts and presenceInRoom.xmpp.ts — as the most likely culprits of long-standing id:0 / id:2 red-screens that showed up after reconnect. I opened both files, and there it was, twice.
Both files used new Promise(async (resolve, reject) => …) with a non-awaited client.send(...) inside. So when a reconnect happened and one of those sends rejected, the rejection escaped the try/catch entirely — there was no channel from the inner async function back to the outer reject — and it landed as an unhandled rejection. A reconnect-time network hiccup, a red screen, and a stack trace pointing nowhere near XMPP send logic. Classic.
(Version note for anyone reading this later: that's the state as of the 26.5.8 release, documented mid-2026. If you're auditing your own copy, check the current source rather than trusting a version number in a blog post.)
The Fix
There are three moves, roughly in order of how much I'd reach for them.
1. Skip the Promise constructor entirely. Most of the time, the honest answer is that you didn't need new Promise at all. Lift the work into a plain async function and return the value. async/await already produces a promise that rejects correctly when something throws — that's the whole point of it. Reaching for the constructor on top of that is usually a sign the code grew sideways.
2. If you genuinely need the constructor, wrap an inner async IIFE and funnel every failure through reject():
new Promise((resolve, reject) => {
(async () => {
try {
// ... awaits here
resolve(value);
} catch (err) {
reject(err);
}
})();
});
The executor itself stays synchronous — the constructor is happy. The async work lives inside the IIFE, and the try/catch guarantees that anything that goes wrong reaches the real reject.
3. If for some reason you insist on the async executor — and you almost never must — attach the .catch to the inner promise synchronously, at construction time, so the rejection has somewhere to land. But at that point you're building a workaround for a rule you could have just followed.
And then make the machine remember it for you: enable no-async-promise-executor project-wide. The bug is silent; your linter shouldn't be.
Lessons
1. "It works locally" tells you nothing about error paths. The happy path never exercises the broken channel. This class of bug only shows up when something rejects — a flaky network, a reconnect, a race — which is exactly the condition you don't reproduce at your desk.
2. A stack trace that points nowhere is itself a clue. When the trace blames framework internals and none of your own frames, suspect a swallowed rejection before you suspect the framework. The failure site and the report site have been divorced.
3. grep before you theorize. For mysterious Uncaught (in promise) red boxes, searching for new Promise(async costs ten seconds and rules in or out the single most common cause. Cheaper than a debugger session.
4. Let the linter hold the rule. You will not remember this at 2 a.m. six months from now. no-async-promise-executor will.
What's Next
If you want to check your own code right now:
- Grep it:
grep -rn "new Promise(async" src/— see what falls out. - Fix the hits: lift to a plain
asyncfunction where you can, wrap an inner IIFE where you can't. - Lock it down: turn on
no-async-promise-executorso the next one gets caught at write time, not at reconnect time. - Found a ghost this explains? I'd genuinely like to hear which reject went missing — drop a note.
Silent errors are the worst kind. This one's silent, common, and a one-line lint rule away from never happening again.
Sources: the ethora-chat-component-rn details come from the promise-hygiene sweep documented in that library's 26.5.8 changelog, which I read (along with the two files it names) while studying the codebase — verify against the current source before relying on specifics.