Skip to content

[Bug]: Expect assertions with escalating retry intervals skip final check before deadline, wasting up to 1000ms of timeout budget #40281

@drost

Description

@drost

Version

1.59.1

Steps to reproduce

  1. Create a new project: npm init playwright@latest repro

  2. Add the following test:

import { test, expect } from '@playwright/test';

test('toBeVisible misses element that appears before timeout', async ({ page }) => {
// Page starts empty; after 1900ms, inject a visible element
await page.setContent('

');
await page.evaluate(() => {
setTimeout(() => {
document.getElementById('target')!.style.display = 'block';
}, 1900);
});

// Timeout of 2500ms should be enough — element appears at 1900ms.
// But the retry schedule [0, 100, 250, 500, 1000, 1000, ...] means:
// check at 0, 100, 350, 850, 1850, (next: 2850)
// The check at 1850ms misses it (element appears at 1900ms).
// The deadline at 2500ms fires during the 1000ms sleep before the next check.
// The assertion times out without ever seeing the element.
await expect(page.locator('#target')).toBeVisible({ timeout: 2500 });
});

  1. Run:
    npx playwright test

  2. The test fails with a timeout, even though the element became visible 600ms before the deadline.

Expected behavior

The assertion should pass. The element is visible at 1900ms and the timeout is 2500ms — there are 600ms of remaining budget. The assertion should perform at least one final check before giving up.

Actual behavior

The assertion times out at 2500ms without checking the DOM again after its last retry at 1850ms. The retryWithProgressAndTimeouts loop in packages/playwright-core/src/server/frames.ts sleeps for the full 1000ms interval while racing against the deadline via progress.race(...). When the deadline fires mid-sleep, it aborts immediately without a final check.

The effective retry schedule is [0, 100, 250, 500, 1000, 1000, ...] (cumulative: 0, 100, 350, 850, 1850, 2850, ...). Any timeout between 1851ms and 2849ms wastes up to 999ms of budget.

Additional context

The root cause is in retryWithProgressAndTimeouts:

const timeout = timeouts[Math.min(timeoutIndex++, timeouts.length - 1)];
if (timeout) {
const actionPromise = new Promise((f) => setTimeout(f, timeout));
await progress.race(/* ... */ actionPromise); // deadline aborts here mid-sleep
}

Possible fix:

  • Perform one final action() call before throwing TimeoutError

This affects all locator assertions that use [retryWithProgressAndTimeouts] with [100, 250, 500, 1000] intervals: toBeVisible, toBeHidden, toContainText, toHaveText, etc.

Environment

System:
    OS: macOS 26.3.1
    CPU: (10) arm64 Apple M2 Pro
    Memory: 184.58 MB / 32.00 GB
  Binaries:
    Node: 23.11.0 - /opt/homebrew/bin/node
    npm: 11.12.1 - /Users/daniel.rost/Develop/Projects/Angular/dialog_pitch_pwa/online-diagnosis-pwa/node_modules/.bin/npm
  IDEs:
    VSCode: 1.116.0 - /usr/local/bin/code
  Languages:
    Bash: 3.2.57 - /bin/bash
  npmPackages:
    @playwright/test: 1.59.1 => 1.59.1

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions