Skip to content

Blog / Playwright

Playwright

How to Test Toast Messages in Playwright Without Flaky Timing

Toasts appear and vanish on their own schedule. Here is how to assert them reliably in Playwright.

October 23, 2026 · Blendra Team · 10 min read

A Playwright toast message test is a race. The toast shows up a moment after the click, then removes itself a few seconds later, whether your test has looked at it or not. Most flaky toast tests lose that race in a handful of known ways. This guide shows each one with a test that runs, then the assertions that win: find the toast by role, assert its text right away, check that it goes away, and take control of its timer with page.clock when it lives too long to wait for.

Every example runs against one small demo page with a toaster, loaded with page.setContent. Blocks marked "Fragment" are excerpts from full tests. Everything was run with @playwright/test 1.61.1 and Chromium in October 2026, and the passing tests still passed with each one repeated five times.

Key takeaways

  • Find a toast with getByRole('status') (or 'alert' for errors), then narrow it with filter({ hasText }). The name option does not match a status by its text.
  • Assert the toast as the very next line after the action that triggers it. toHaveText retries for up to 5 seconds, but only from the moment it starts.
  • toBeHidden() proves the toast went away. It fails for a toast that fades to opacity: 0 and stays in the page.
  • For toasts that live longer than your expect timeout, install page.clock, pause it, and move time with runFor instead of waiting.
  • With several toasts on screen, assert them as a list with toHaveText([...]), or pick one with filter.

The demo page

Save this as tests/toast-page.ts. Each button waits 400 ms, like a request would, then shows a toast. Toasts remove themselves after 3 seconds unless the pointer is over them. "Archive" is a deliberate bug: its toast fades out but never leaves the page.

// A small settings page with a toaster. Load it with page.setContent(toastPage).
// Every toast is a role="status" (or role="alert") element that removes itself
// after `ms` milliseconds, and pauses its timer while the pointer is over it.
export const toastPage = `<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Toast demo</title>
  <style>
    #toaster { position: fixed; right: 16px; bottom: 16px; display: grid; gap: 8px; }
    .toast { padding: 12px 16px; border-radius: 8px; background: #071229; color: #fff; }
    .toast { transition: opacity 300ms; }
  </style>
</head>
<body>
  <h1>Settings</h1>
  <button id="save">Save</button>
  <button id="delete">Delete</button>
  <button id="order">Place order</button>
  <button id="sync-all">Sync all</button>
  <button id="export">Export</button>
  <button id="archive">Archive</button>
  <button id="report">Run report</button>
  <section id="toaster" aria-label="Notifications"></section>
  <script>
    function toast(text, { role = 'status', ms = 3000, fade = false } = {}) {
      const el = document.createElement('div');
      el.className = 'toast';
      el.setAttribute('role', role);
      el.textContent = text + ' ';
      const close = document.createElement('button');
      close.setAttribute('aria-label', 'Dismiss');
      close.innerHTML = '<svg width="12" height="12" aria-hidden="true"><path d="M1 1l10 10M11 1L1 11" stroke="currentColor"/></svg>';
      close.onclick = () => el.remove();
      el.append(close);
      document.getElementById('toaster').append(el);
      // fade: true is the buggy kind that fades out but never leaves the DOM
      const hide = () => (fade ? (el.style.opacity = '0') : el.remove());
      let left = ms, started, timer;
      const start = () => { started = Date.now(); timer = setTimeout(hide, left); };
      el.onmouseenter = () => { clearTimeout(timer); left -= Date.now() - started; };
      el.onmouseleave = start;
      start();
    }
    // Each button waits 400 ms first, like a request to the server would.
    const after400 = (fn) => () => setTimeout(fn, 400);
    document.getElementById('save').onclick = after400(() => toast('Saved'));
    document.getElementById('delete').onclick = after400(() =>
      toast('Could not delete: the file is locked', { role: 'alert', ms: 5000 }));
    document.getElementById('order').onclick = after400(() =>
      toast('Order #' + (1000 + Math.floor(Math.random() * 9000)) + ' placed'));
    document.getElementById('sync-all').onclick = after400(() =>
      ['Profile synced', 'Billing synced', 'Team synced'].forEach((t) => toast(t)));
    document.getElementById('export').onclick = after400(() =>
      toast('Export ready', { ms: 10000 }));
    document.getElementById('archive').onclick = after400(() =>
      toast('Archived', { fade: true }));
    document.getElementById('report').onclick = after400(() => {
      toast('Report started', { ms: 2000 });
      setTimeout(() => document.body.insertAdjacentHTML('beforeend', '<h2>Report ready</h2>'), 3000);
    });
  </script>
</body>
</html>`;

The config is the default Chrome project with the list reporter and nothing else. No server is needed.

Find the toast: role status or alert

A toast is a live region. A success message usually has role="status", which MDN describes as advisory information and gives an implicit aria-live="polite" (MDN: status role). An error that needs attention now usually has role="alert", which is assertive. If you use a toast library, check which role it renders. The react-hot-toast docs, for example, show role: 'status' and 'aria-live': 'polite' in their ariaProps option (react-hot-toast: toast()).

So start with the role, the same way you would for a button:

// Fragment
const toast = page.getByRole('status').filter({ hasText: 'Saved' });
const error = page.getByRole('alert');

Two details matter here.

Don't use the name option for the text. A status takes its accessible name from aria-label or aria-labelledby, not from its content. So page.getByRole('status', { name: 'Saved' }) matched nothing in the demo, even with "Saved" on screen. Use filter({ hasText: 'Saved' }) instead, which matches text anywhere inside the element, case-insensitively.

Scope it when other live regions exist. An app can have more than one status region: a cart counter, a "saving..." indicator, a screen-reader announcer. If your toaster has a label, scope to it: page.getByRole('region', { name: 'Notifications' }).getByRole('status'). A <section> with an aria-label gets the region role.

If your toasts have no role at all, a test id or a class locator works (page.getByTestId('toast'), page.locator('.toast')). It is also worth a ticket: a toast without a live-region role is silent for screen-reader users.

Assert a Playwright toast message before it disappears

Here is the test that works. It asserts the toast on the line right after the click:

import { test, expect } from '@playwright/test';
import { toastPage } from './toast-page';

test.beforeEach(async ({ page }) => {
  await page.setContent(toastPage);
});

test('Save shows a toast, then it goes away', async ({ page }) => {
  await page.getByRole('button', { name: 'Save' }).click();

  const toast = page.getByRole('status').filter({ hasText: 'Saved' });
  await expect(toast).toHaveText('Saved');
  await expect(toast).toBeHidden();
});

toHaveText is a web-first assertion. It starts polling as soon as it is called and keeps going until the text matches or the expect timeout (5 seconds by default) runs out (Playwright assertions). The toast appears at 0.4 s, so the assertion passes at 0.4 s, long before the toast expires at 3.4 s.

A timeline of a toast shown from 0.4 to 3.4 seconds, with toHaveText passing at 0.4 seconds and toBeHidden at 3.4 seconds, and a second toast that expires before a late check starts

The retry window only helps if the assertion starts while the toast is still alive. Here are the ways tests lose the race. Each mistake below was run as a test.fail() test, so each one is proven to fail.

A one-shot read. isVisible(), textContent() and count() answer once, right now. Called straight after the click, isVisible() returned false, because the toast was still 400 ms away:

// Fragment: this test fails, which is the point
await page.getByRole('button', { name: 'Save' }).click();
expect(await page.getByRole('status').isVisible()).toBe(true); // false

A slow check first. "Run report" shows a 2-second toast at 0.4 s and a "Report ready" heading at 3.4 s. Wait for the heading first, and the toast is gone before you look for it:

// Fragment: this test fails, which is the point
await page.getByRole('button', { name: 'Run report' }).click();
await expect(page.getByRole('heading', { name: 'Report ready' })).toBeVisible();
await expect(page.getByRole('status')).toHaveText('Report started', { timeout: 2_000 });

The same thing happens with anything slow between the action and the toast check: waiting for a response, a navigation, a table to fill. The fix is order. Toast first, then the slow things.

An anchored regex. For text that changes per run, such as an order number, use a regular expression. Playwright normalizes whitespace when you pass a string, but matches a regex against the raw text. The demo's toast text ends with a space before the close button, so /^Order #\d{4} placed$/ failed with unexpected value "Order #2953 placed ". Leave the anchors off unless you need them:

// Fragment
await page.getByRole('button', { name: 'Place order' }).click();
await expect(page.getByRole('status')).toHaveText(/Order #\d{4} placed/);

Catch a toast you cannot wait for

Sometimes the order can't change. The toast fires during a redirect, or a helper you don't own does the slow part. Then record toasts as they are added, and assert the log afterwards. An init script runs in every new document before the page's own scripts:

import { test, expect } from '@playwright/test';
import { toastPage } from './toast-page';

test('catch a toast even if it is gone before you look', async ({ page }) => {
  // Init scripts run on every new document, before the page's own scripts.
  await page.addInitScript(() => {
    const seen: string[] = ((window as any).__toasts = []);
    new MutationObserver((records) => {
      for (const record of records) {
        for (const node of record.addedNodes) {
          if (node instanceof HTMLElement && node.matches('[role="status"], [role="alert"]')) {
            seen.push(node.textContent?.trim() ?? '');
          }
        }
      }
    }).observe(document, { childList: true, subtree: true });
  });

  // setContent() does not run init scripts, so serve the demo page at a URL.
  await page.route('https://app.test/', (route) =>
    route.fulfill({ contentType: 'text/html', body: toastPage }),
  );
  await page.goto('https://app.test/');

  await page.getByRole('button', { name: 'Run report' }).click();
  await expect(page.getByRole('heading', { name: 'Report ready' })).toBeVisible();

  // The toast expired a second ago, but the log kept it.
  await expect
    .poll(() => page.evaluate(() => (window as any).__toasts))
    .toContain('Report started');
});

Know the trade-offs before you use it. The log proves the toast was added, not that anyone could see it. It records the text at the moment the node was inserted, so a toast whose text is filled in later needs a characterData observer too. And the log lives in window, so a full page load starts a new, empty one. Treat this as a fallback, not the default.

Check that it goes away

toBeHidden() passes when the locator matches nothing or matches only invisible elements (LocatorAssertions). That is why the first test ends with it. Three things can still go wrong.

The toast outlives the expect timeout. The "Export ready" toast stays for 10 seconds. await expect(toast).toBeHidden() gives up after 5, so the test fails even though the app is fine. Either pass a timeout that covers the toast (toBeHidden({ timeout: 12_000 })) or control the clock, as in the next section. A longer timeout is simple but makes the test slow. The clock makes it instant.

The toast fades but stays. Playwright treats opacity: 0 as visible. The demo's "Archive" toast fades to 0 and stays in the page, and toBeHidden() failed after 5 seconds with Expected: hidden, Received: visible. It also stays in the accessibility tree: getByRole('status') still found it. That is a real bug to report, because screen readers can still reach an invisible message. Until it is fixed, you can assert the fade itself:

// Fragment
await page.getByRole('button', { name: 'Archive' }).click();

const toast = page.getByRole('status').filter({ hasText: 'Archived' });
await expect(toast).toHaveText('Archived');
await expect(toast).toHaveCSS('opacity', '0');

The user closes it. If your toast has a close button, test that path too. It's the one users take when they want the message gone:

// Fragment
await page.getByRole('button', { name: 'Save' }).click();

const toast = page.getByRole('status').filter({ hasText: 'Saved' });
await toast.getByRole('button', { name: 'Dismiss' }).click();
await expect(toast).toHaveCount(0);

toHaveCount(0) is stricter than toBeHidden(): it demands the element left the DOM, not just that it can't be seen.

Control timers with page.clock

page.clock (added in Playwright 1.45) replaces Date, setTimeout, setInterval, requestAnimationFrame, performance and related timer APIs in the page with fake ones that your test drives (Clock guide, Clock API). Since a toast's lifetime is almost always a setTimeout, the clock lets you check a 10-second toast in milliseconds:

import { test, expect } from '@playwright/test';
import { toastPage } from './toast-page';

test.beforeEach(async ({ page }) => {
  const start = new Date('2026-10-22T09:00:00');
  await page.clock.install({ time: start });
  await page.setContent(toastPage);
  // From here on, page time moves only when the test says so.
  await page.clock.pauseAt(new Date('2026-10-22T09:00:05'));
});

test('a 10-second toast, checked without waiting 10 seconds', async ({ page }) => {
  await page.getByRole('button', { name: 'Export' }).click();
  await page.clock.runFor(400); // the fake request

  const toast = page.getByRole('status').filter({ hasText: 'Export ready' });
  await expect(toast).toBeVisible();

  await page.clock.runFor(9_999);
  await expect(toast).toBeVisible(); // 1 ms before the end

  await page.clock.runFor(1);
  await expect(toast).toBeHidden();
});

test('the timer pauses while the pointer is over the toast', async ({ page }) => {
  await page.getByRole('button', { name: 'Save' }).click();
  await page.clock.runFor(400);

  const toast = page.getByRole('status').filter({ hasText: 'Saved' });
  await toast.hover();
  await page.clock.runFor(10_000);
  await expect(toast).toBeVisible();

  await page.mouse.move(0, 0); // move away and the remaining time runs out
  await page.clock.runFor(3_000);
  await expect(toast).toBeHidden();
});

What to know before you copy this:

  • Install first. The docs say install must come before any other clock call, and recommend installing before the page loads. The clock covers the whole browser context.
  • install alone does not stop time. In a check without pauseAt, the demo's 400 ms timer still fired in real time (the toast appeared within a second). Call pauseAt once the page has loaded, and then time moves only through runFor, fastForward or resume.
  • Every timer is now yours. The fake 400 ms "request" in the demo is a setTimeout, so it waits for runFor too. A real fetch is not a timer and is not faked: let it finish with a web-first assertion, then move the clock.
  • runFor vs fastForward. runFor advances time and fires every timer that falls due along the way. fastForward jumps ahead like a laptop waking from sleep and fires due timers at most once. For a toast, runFor is the closer match to real time passing.
  • Hover pauses are a hidden flake source. If your toast pauses on hover and the click that triggered it left the pointer where the toast appears, the toast never leaves. Move the mouse away before you assert that it is gone.

The second test proves the pause logic exactly: 10 seconds of hover and the toast is still there, then 3 seconds after the pointer leaves and it is gone.

Several toasts at once

Toasts stack. With three on screen, a bare getByRole('status') is ambiguous, and any assertion that needs one element fails at once:

Error: strict mode violation: getByRole('status') resolved to 3 elements:
    1) <div class="toast" role="status">…</div> aka getByText('Profile synced')
    2) <div class="toast" role="status">…</div> aka getByText('Billing synced')
    3) <div class="toast" role="status">…</div> aka getByText('Team synced')

Treat the stack as a list, or pick one toast out of it:

import { test, expect } from '@playwright/test';
import { toastPage } from './toast-page';

test.beforeEach(async ({ page }) => {
  await page.setContent(toastPage);
});

test('three toasts at once, in order', async ({ page }) => {
  await page.getByRole('button', { name: 'Sync all' }).click();

  const toasts = page.getByRole('region', { name: 'Notifications' }).getByRole('status');
  await expect(toasts).toHaveText(['Profile synced', 'Billing synced', 'Team synced']);
  await expect(toasts).toHaveCount(0);
});

test('pick one toast out of the stack', async ({ page }) => {
  await page.getByRole('button', { name: 'Sync all' }).click();

  const billing = page.getByRole('status').filter({ hasText: 'Billing synced' });
  await billing.getByRole('button', { name: 'Dismiss' }).click();

  await expect(page.getByRole('status')).toHaveText(['Profile synced', 'Team synced']);
});

toHaveText with an array checks the count and the order, and retries both. If the order isn't part of the requirement, assert each toast with its own filter({ hasText }) instead, so a reordering library update doesn't break the test.

Toasts in recorded tests (Blendra)

Picking the right toast and asserting it at the right moment are the two halves of this post. Blendra helps with both when you record a flow in Chrome: it lists the toasts it saw after each step, and a check you make from one runs right after that step. After each step, the recorder keeps watching the page for at least 1.8 seconds, and longer while the page is still changing, up to 6 seconds. Clicking the next step ends that window early, so give a slow toast time to appear before you move on. Anything with role="status" or role="alert", an aria-live region, a dialog, or an element whose class names a toast, snackbar or notification is kept as evidence. Blendra stores the toast's short visible text, never your page's HTML.

In the Checks tab, tick the toast that matters, and it becomes a check that the text (or the element) is visible. That check runs right after the step that produced the toast, which is the "toast first" rule from this post. For a message that changes per run, like an order number, a Contains or Pattern match keeps the check from failing on the number. With either match, the exported Playwright TypeScript takes the first visible element whose text matches, because toasts stack. Playwright Python and Cypress exports are in beta, on the Pro+ and Team plans. The Blendra docs cover checks and exports in more detail.

FAQ

Why does my toast test pass locally and fail in CI?

The toast's timer runs on wall-clock time, and your test runs slower in CI. Any work between the action and the toast assertion eats into the toast's lifetime. Assert the toast on the next line after the action. If the toast is very short, or must be checked after something slow, use page.clock or the init-script log above.

Should I use toHaveText or toContainText for a toast?

Use toHaveText when you know the whole message, so "Saved with errors" doesn't pass a check for "Saved". Use toContainText when the toast mixes your text with text you don't control, such as a timestamp. Both retry. Neither counts the close button's aria-label, because that isn't text content.

Is a role="alert" toast the same as a JavaScript alert()?

No. A role="alert" toast is ordinary page content, so locators and expect work on it. A native alert(), confirm() or prompt() is a browser dialog. Playwright dismisses those automatically unless you register page.on('dialog') before the action (Dialogs).

Can I make toasts last longer in tests instead?

You can, if the app gives you a switch for it, but then you're testing a different setup from the one users see. The clock keeps the real durations and still makes the test fast. Raising the expect timeout for one assertion ({ timeout: 12_000 }) is the simple middle ground.

For more on how web-first assertions retry, read Playwright assertions. For what actions do and don't wait for, read Playwright auto-waiting.

AssertionsFlaky testsPlaywrightTypeScript

Start with the flow you test by hand every release.

Start free →