Halmurat T.
Halmurat T.

Senior SDET

Home Blog Books ask About

The Dispatch

Weekly QA notes from the trenches.

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

© 2026 Halmurat T.

Automation 24
  • Selenium
  • Playwright
  • Appium
  • Cypress
AI Testing 5
CI/CD 6
  • GitHub Actions
  • Slack Reporting
QA Strategy 4
Case Studies 5
Blog/Automation
AutomationHalmurat T./January 27, 2026/5 min

Playwright's onceDialog Has a Silent Handler Leak

Filed underplaywright/framework-design/debugging/design-patterns
Playwright's onceDialog Has a Silent Handler Leak

Table of Contents
  • The Setup
  • The Bug: Dangling Handlers Eat Future Dialogs
  • The Fix: onDialog + offDialog With finally
  • The Pattern: Scoped Event Listeners
  • When onceDialog Is Still Fine

On this page

  • The Setup
  • The Bug: Dangling Handlers Eat Future Dialogs
  • The Fix: onDialog + offDialog With finally
  • The Pattern: Scoped Event Listeners
  • When onceDialog Is Still Fine

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:

src/test/java/pages/AccountListPage.java
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.

What the developer sees
RuntimeException: Unexpected dialog during activation: Type: confirm, Message: Save changes?
Stack trace points to AccountListPage.activate()
What actually happened
1. activate() registered onceDialog handler
2. No dialog fired during tab click — handler stayed registered
3. Test continued to a different page action
4. User clicked Save → app showed "Save changes?" confirm dialog
5. Dangling handler from step 1 intercepted the Save dialog
6. 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.

[ WARNING ]

The worst part: you can’t reliably remove a onceDialog handler. Internally, Playwright wraps your callback before registering it. Calling page.offDialog(yourHandler) won’t match the internal wrapper, so the handler stays registered regardless. This isn’t a bug — it’s just how once-style listeners work. But it means you can’t use onceDialog for scoped guard patterns.

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.

src/test/java/pages/AccountListPage.java
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:

ScenarioonceDialog (before)onDialog + offDialog (after)
Dialog fires during clickHandles it, auto-removesHandles it, removed in finally
No dialog firesStays registered — eats next dialogRemoved in finally — no leak
Exception during waitHandler leaksfinally 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:

src/test/java/core/ScopedListener.java
// Generic pattern for any scoped Playwright event listener
Consumer<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.

[ TIP ]

Audit your codebase for any page.onDialog() call inside a test method or page object that doesn’t have a corresponding offDialog(). Each one is a potential handler leak. The same applies to onConsoleMessage, onRequest, onResponse, and other Playwright event listeners.

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 → onceDialog is fine
  • Dialog might or might not fire → onDialog + offDialog + finally
  • Global safety net in base class → onDialog in setup, scoped to test lifecycle
§ Further Reading 03 of 03
01Automation

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.

Read →
02Automation

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.

Read →
03Automation

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.

Read →

Don't miss a thing

Subscribe to get updates straight to your inbox.

HT

No spam · Unsubscribe anytime

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

§ Colophon

Halmurat T. — Senior SDET writing about test automation, CI/CD, and QA strategy from 10+ years in the enterprise trenches.

Set in
IBM Plex Sans, Lora, and IBM Plex Mono.
Built with
Astro, MDX, Tailwind CSS & Expressive Code. Served by Vercel.
Privacy
No cookies. No tracking scripts on the main thread — analytics run sandboxed via Partytown.
Source
github.com/Halmurat-Uyghur
Terminal
Try /ask to query Halmurat's notes in a shell prompt.

© 2026 Halmurat T. · Written in plain text, shipped in plain time.

Search
Esc

Search is not available in dev mode.

Run npm run build then npm run preview:local to test search locally.