HIROSE PAPER MFG. CO., LTD.

Employees' Blog

An async onClick Handler’s Exception Never Reaches React’s Error Boundary
A microtask still meets the deadline for preventDefault(); a task started with setTimeout(0) does not

Published on: 2026.09.08 Last updated: 2026.09.08
A hero illustration headlined NO ONE CAUGHT IT, showing a fingertip pressing a button while a small red spark slips past an open hand and off the edge of the frame, unseen

Picture an ordinary form: a submit button that opens a confirmation dialog, and a single checkbox for consent. Nothing exotic about either one. Every experiment in this article runs on those same two widgets.

Start with the submit button. Click it, a confirmation dialog pops up, you cancel it — that should be the end of the story. Instead, the button never responds to another click.

Nothing on the screen explains why. Open the developer console, and there’s exactly one red line waiting for you.

You’ve probably run into a bug like this before. Why does it happen? Trace the cause far enough back, and it starts at the exact moment onClick becomes async.

An async handler’s return value has no receiver

Pass an async function to <button onClick={handleSubmit}>, and that function no longer returns void. Every call now returns a Promise instead. That part is common knowledge.

The catch is that nobody is holding onto that Promise. DOM event dispatch — the synchronous sequence that runs from the moment an event fires, through every registered listener, up to the point where the browser decides whether to run its default action (the browser’s own built-in reaction) — never waits for a listener’s return value. React doesn’t await the return value of onClick, either. So an exception thrown inside handleSubmit has no path back to any caller.

A regular synchronous function propagates its exception straight to the caller, and inside a React render an exception is caught by an Error Boundary. An exception inside an async function takes a different shape entirely: it becomes a rejected Promise. A rejected Promise with no one listening is, to the JavaScript engine, simply a failure that nobody handled.

React’s official documentation draws a hard line around what an Error Boundary catches: exceptions inside event handlers and exceptions inside asynchronous code are explicitly out of scope (reference 1). An async onClick checks both of those excluded boxes at once.

The only destination is unhandledrejection

So where does a rejected Promise with no receiver go? To the unhandledrejection event. MDN documents that it fires whenever a Promise is rejected and no handler has been attached to deal with that rejection (reference 2).

Figure 1 shows how the two familiar paths and this third one diverge: a synchronous handler’s exception gets thrown back up to the caller, a rendering exception lands in an Error Boundary, and an async handler’s exception slips past both of them and escapes through unhandledrejection.

A diagram contrasting three arrows: a synchronous handler's exception returns to the caller, a render exception is caught by the Error Boundary, and an async handler's exception escapes to unhandledrejection
Figure 1: Where the exception goes depends on the path

Here’s the part that trips people up: nothing is being silently swallowed. The browser fires unhandledrejection exactly as specified, and when you actually run it, the console shows exactly one line:

[error] Uncaught (in promise)

That single line doesn’t obviously connect to “clicking the button does nothing.” That gap is exactly why bugs like this one are so hard to spot.

Recovery belongs in finally, not catch

Here’s what happened when I actually ran it. askConfirm(), standing in for the confirmation dialog, throws before execution ever reaches the try block. I wrote two versions that differ only in where the recovery code lives.

  • A1: reset the submitting flag inside catch
  • A2: reset the same flag inside finally

Because askConfirm() fails outside the try, catch never sees it. Clicking A1 once, then a second time, produced this:

[unhandledrejection] reason=Error: confirm helper failed
A1 ignored: already submitting

The first click let the exception slip past catch, leaving the submitting flag stuck at true. The second click hit the guard at the top of the handler and did nothing — the exact symptom from the introduction.

A2, on the other hand, ran fine both times, because finally always runs no matter how the block exits:

A2 catch: confirm helper failed
A2 catch: confirm helper failed

“Telling the user something failed” and “putting the state back the way it was” are two separate concerns, and they need separate homes. catch covers part of the first one. Only finally guarantees the second. If you want state restored no matter which path a failure takes, that code belongs in finally from the start.

How late can preventDefault() still make the deadline?

There’s a second trap that async handlers set, and this one is about when you call preventDefault().

Looking at the same async-handler problem from a different angle needs a default action you can actually see, so this part of the running example uses the checkbox from that same form. A checkbox’s click has an obvious default action — toggling whether it’s checked — so it’s easy to tell at a glance whether that action was stopped.

Three handlers differ only in where preventDefault() is called.

  • B1: call it synchronously
  • B2: call it after await Promise.resolve()
  • B3: call it after await sleep(0) (via setTimeout)

Honest admission: before running this, I expected that crossing even a single await would make preventDefault() too late. The measurements said otherwise.

Handlerchecked after dispatch completesDefault action
B1 (synchronous)falsestopped
B2 (after await Promise.resolve())falsestopped
B3 (after await sleep(0))truenot stopped

B2 crosses an await and still makes it in time. The real boundary isn’t “did you cross an await” — it’s whether the continuation comes back as a microtask or spills into a separate task (also called a macrotask). A continuation from Promise.resolve() is queued on the microtask queue, and that queue drains before the event’s dispatch finishes. A continuation from setTimeout(0) is queued as a task, which only runs once dispatch is long over.

Figure 2 shows where those two continuations land relative to the closing gate.

A diagram showing a preventDefault call sneaking in before the default action runs when queued as a microtask, but landing after the default action runs once queued as a task
Figure 2: A microtask makes it in time; a task does not

defaultPrevented only tells you it was called

Here’s another place I nearly drew the wrong conclusion. Logging event.defaultPrevented and checked from inside the handler gave the same values for B1, B2, and B3:

B1 sync: defaultPrevented=true checked=true
B2 microtask: defaultPrevented=true checked=true
B3 timer: defaultPrevented=true checked=true

Read only that, and it looks like preventDefault() succeeded in all three cases. But those values are read from inside the handler, and a checkbox is checked once before dispatch begins and, if the event was canceled, un-checked again afterward. At the point where the handler runs, that reversal hasn’t happened yet, so checked always reads true there.

Reading the DOM again after dispatch had fully finished told a different story:

{"B1_sync": false, "B2_microtask": false, "B3_timer": true}

defaultPrevented stayed true in all three. In other words, that property only tells you whether preventDefault() was called — never whether the call arrived in time. Trust it for timing, and you’ll miss cases like B3, where the default action ran anyway.

The same handler flips outcomes between a test and a real browser

This is the discovery I found most useful for day-to-day work. I kept B2’s handler (preventDefault() after await Promise.resolve()) completely unchanged and only changed how the event was fired.

How the event firedchecked after dispatchDefault action
A real mouse clickfalsestopped
el.click() from JavaScripttruenot stopped

Same code, opposite outcome. Why would that happen?

It comes down to when the microtask checkpoint runs. The microtask queue drains the moment the JavaScript call stack empties. A real mouse click invokes the listener from the browser’s own task, so the stack empties as soon as the listener returns, and the await continuation slips in before dispatch is fully done. el.click(), by contrast, is invoked from on top of a stack that your own script is already running, and that stack doesn’t empty until every synchronous statement around it has finished. By the time it does, dispatch is long over, and the await continuation runs after the fact.

Figure 3 shows how the two calling paths leave the stack in a different state.

A diagram contrasting a real click, which is invoked from the browser's task so the stack empties quickly, with el.click(), which is invoked from on top of the running script's stack so the stack does not empty
Figure 3: Who calls the event changes how empty the stack is

This has real weight in practice. Behavior you confirmed with el.click() in a test might not hold for an actual user action, and the reverse is just as possible. A test can stay green while the real browser is broken, or the real browser can work fine while a test’s simulated firing can’t reproduce it. The way a test fires an event can quietly undermine the very assumption it’s trying to verify.

Browsers keep going; Node stops

One more comparison: run the same shape of code as the submit handler — call the confirmation step, then the real work, and let nothing hold onto the returned Promise — but on the server, in Node.js instead of a browser. Not awaiting the return value of an async function is exactly the same pattern as the event handler.

With no handler attached, the run looked like this:

Error: confirm failed
    at confirmThenSubmit (file:///.../node-unhandled.mjs:3:9)
    ...
Node.js v22.12.0
EXIT=1

The setTimeout log that was supposed to print 100ms later never showed up, because the process had already exited. A browser keeps running after firing unhandledrejection, but Node.js, by default, treats an unhandled rejection as grounds to exit the process.

Attaching process.on("unhandledRejection", ...) changes the outcome:

process.on(unhandledRejection) reason: Error: confirm failed
still alive after 100ms
EXIT=0

Identically written code ends very differently depending on where it runs. Keep this in mind when you bring browser-style code into a Node environment, such as server-side rendering or a batch job.

The scope of what this article measured

Every measurement here was run by hand, locally. On the browser side, I ran the same steps on both Chrome 152 and Chrome 148, and the A and B results matched exactly between them. Node.js was v22.12.0.

Firefox and Safari were not tested. The only default action used here is a checkbox toggle, so whether form submission or following a link share the same microtask-versus-task boundary is outside what this article checked.

What to take away

An async onClick is convenient, but it quietly breaks a handful of assumptions you didn’t know you were relying on. Here’s what these measurements leave you with.

  • Where you need to roll back state after a failure — the submit button here — put that recovery in finally, not catch. The failure path can sit entirely outside the try block.
  • Where you need to stop a default action — the checkbox here — make the decision to call preventDefault() as close to synchronous as you can. In this measurement, a single microtask’s worth of delay still made it, but pushing the continuation into the next task with setTimeout did not. Because dispatch finishes synchronously within a single task, and a setTimeout continuation always lands in the next task, it’s reasonable to guess that other default actions — form submission, following a link — follow the same rule, but this article only confirmed it for a checkbox.
  • As the same checkbox example showed, don’t use event.defaultPrevented to judge whether a call arrived in time. It only tells you whether the call happened at all.
  • When a test fires an event with el.click(), keep in mind that the outcome can differ from what happens on a real user action.

An async event handler is easy enough to write that these mismatched assumptions are easy to miss entirely. Try it by hand once, and it stops being abstract.

Primary sources

Pixcel Art of Aki. holdin a cat.

About the Author

Aki Matsumura

Joined HIROSE PAPER MFG. CO., LTD. in November 2024.

Brings a diverse professional background spanning retail, welfare services, and food service before transitioning into system development.

Currently serves as an in-house systems engineer, responsible for internal database development and system improvement initiatives across the company.

View posts by this author