Playwright's onceDialog Has a Silent Handler Leak
Table of Contents
In the original onceDialog post, I recommended onceDialog as the fix for permanent handler leaks from onDialog. That advice holds — when you’re certain the dialog will fire. But there’s a case it doesn’t cover that bit us in production: when the dialog never fires. Then onceDialog doesn’t auto-remove, because it never executed. Your handler sits there, dormant, waiting for the next dialog from a completely different action — and when it fires, it dismisses a dialog your code was counting on.
The Setup
We had a page object for an account management app. One method activated a tab that sometimes triggered an unexpected browser dialog — a server-side validation warning that only appeared under certain data conditions. The team added a onceDialog guard to catch it:
public void activate() { if (!isActive()) { AtomicBoolean dialogDetected = new AtomicBoolean(false); AtomicReference<String> dialogInfo = new AtomicReference<>("");
page.onceDialog(dialog -> { String info = String.format("Type: %s, Message: %s", dialog.type(), dialog.message()); log.error("Unexpected dialog during tab activation: {}", info); dialogDetected.set(true); dialogInfo.set(info); dialog.dismiss(); });
contentFrame().locator(ACCOUNT_LIST_TAB).click();
if (dialogDetected.get()) { throw new RuntimeException( "Unexpected dialog during activation: " + dialogInfo.get()); } waitAccountListPage(); }}This looks correct. If a dialog fires during the click, the handler catches it, dismisses it, and flags the error. If no dialog fires, onceDialog should handle cleanup since it’s a “once” handler. Right?
The Bug: Dangling Handlers Eat Future Dialogs
Here’s what actually happens when no dialog fires during the click: nothing. The onceDialog handler stays registered. It hasn’t fired, so it hasn’t auto-removed. It’s sitting in the page’s event listener queue, waiting.
Three steps later in the same test — or in a completely different test sharing the same page context — someone clicks “Save” and the app shows a legitimate confirmation dialog. The dangling handler intercepts it, dismisses it, and throws a RuntimeException blaming the tab activation. The save never completes. The test fails with an error message pointing to the wrong method.
RuntimeException: Unexpected dialog during activation: Type: confirm, Message: Save changes?
Stack trace points to AccountListPage.activate()1. activate() registered onceDialog handler2. No dialog fired during tab click — handler stayed registered3. Test continued to a different page action4. User clicked Save → app showed "Save changes?" confirm dialog5. Dangling handler from step 1 intercepted the Save dialog6. Handler dismissed it and threw — blaming activate()The developer sees an error about “tab activation” when the real problem is that a Save confirmation got silently dismissed. The data was never saved. The test fails, but the error message is a lie.
The Fix: onDialog + offDialog With finally
The solution is to use onDialog (not onceDialog) with the same handler reference, and remove it explicitly in a finally block. This gives you deterministic cleanup regardless of whether the dialog fired, didn’t fire, or an exception interrupted the flow.
public void activate() { if (!isActive()) { AtomicBoolean dialogDetected = new AtomicBoolean(false); AtomicReference<String> dialogInfo = new AtomicReference<>("");
Consumer<Dialog> guardHandler = dialog -> { String info = String.format("Type: %s, Message: %s", dialog.type(), dialog.message()); log.error("Unexpected dialog during tab activation: {}", info); dialogDetected.set(true); dialogInfo.set(info); dialog.dismiss(); };
page.onDialog(guardHandler); try { contentFrame().locator(ACCOUNT_LIST_TAB).click(); waitAccountListPage(); } finally { page.offDialog(guardHandler); }
if (dialogDetected.get()) { throw new RuntimeException( "Unexpected dialog during activation: " + dialogInfo.get()); } log.info("Account List tab activated successfully"); }}The key difference: onDialog registers your actual handler reference, so offDialog can find and remove it. The finally block guarantees cleanup in all three scenarios:
| Scenario | onceDialog (before) | onDialog + offDialog (after) |
|---|---|---|
| Dialog fires during click | Handles it, auto-removes | Handles it, removed in finally |
| No dialog fires | Stays registered — eats next dialog | Removed in finally — no leak |
| Exception during wait | Handler leaks | finally guarantees cleanup |
The Pattern: Scoped Event Listeners
This isn’t unique to dialog handlers. Any time you register an event listener for a specific action, you need to scope its lifetime to that action. The try/finally pattern works for any Playwright event:
// Generic pattern for any scoped Playwright event listenerConsumer<T> handler = event -> { /* handle */ };page.onSomeEvent(handler);try { performAction();} finally { page.offSomeEvent(handler);}Think of it like resource management. You wouldn’t open a database connection without a finally to close it. Event listeners are the same — they’re resources with a lifecycle, and leaving them open past their intended scope causes the same class of bugs.
When onceDialog Is Still Fine
This isn’t a blanket “never use onceDialog.” It works well when you’re certain the dialog will fire — like dismissing a delete confirmation right before a destructive click. The handler fires, auto-removes, and there’s no dangling risk.
The trap is using onceDialog as a guard — a “just in case” listener for dialogs that might appear. Guards must be cleaned up whether they fire or not, and onceDialog only cleans up when it fires.
Rule of thumb:
- Certain the dialog will fire →
onceDialogis fine - Dialog might or might not fire →
onDialog+offDialog+finally - Global safety net in base class →
onDialogin setup, scoped to test lifecycle
Related Posts
Migrating Off Cypress? Here's When to Keep It
An honest take on Cypress vs Playwright migrations from an SDET who's done three, including the signals that tell you not to migrate your suite just yet.
XPath text() vs Dot — Why Your Text Match Fails
The real difference between XPath text(), dot, contains(), and normalize-space() for test automation — with examples that explain real flaky failures.
The Async Trap Behind Flaky Playwright Tests
The 3 async mistakes that cause flaky Playwright tests after a Selenium migration, and how we fixed a 23% intermittent failure rate in our CI pipeline.