Version
1.60.0 (regression vs 1.59.1)
Environment
@playwright/test 1.60.0
- Reproduced on bundled Firefox 150; the mechanism is injected JS, so Chromium and WebKit are affected too (in our CI the same tests timed out on all three browsers).
- Node 18+, Linux.
Steps to reproduce
Minimal, self-contained repro with scripts: https://github.com/egfx-notifications/pw-concurrent-expect-repro
A page builds a normal-sized DOM (~3,000 nodes) plus N elements that start hidden and become visible after a short delay. The test then waits for all N with toBeVisible, either concurrently (Promise.all) or sequentially.
./scripts/1-install.sh
./scripts/2-measure-stock.sh # sequential (control) vs concurrent
./scripts/3-patch-playwright.sh # neutralise the per-poll aria snapshot
./scripts/4-measure-patched.sh
Expected
Awaiting many auto-retrying assertions concurrently should be roughly as fast as awaiting them sequentially.
Actual
The concurrent case is dramatically slower and scales linearly with DOM size (Firefox 150, N=40 assertions, targets hidden ~1.5s):
| DOM (~nodes) |
concurrent (Promise.all) |
sequential (control) |
| ~900 |
5.0 s |
~1.8 s |
| ~3,000 |
13.3 s |
~1.8 s |
| ~6,000 |
24.7 s |
~1.8 s |
On a real app page (a soccer standings table with 18 teams, asserting each row's logo becomes visible) this turned a ~0.8 s step into ~48 s.
Root cause
Introduced in #40390 (commit d8378c3). InjectedScript.expect() now calls _ariaSnapshotForExpect() on every poll attempt, eagerly, so a failure message is ready in case the matcher ends up failing:
async expect(element, options, elements) {
const core = await this._expectCore(element, options, elements);
const ariaSnapshot = this._ariaSnapshotForExpect(element, options); // every poll
...
}
For a not-yet-visible / missing element, _ariaSnapshotForExpect renders a full-page aria tree of document.body:
// Element missing or hidden — fall back to a full-page snapshot for context.
return this._renderAriaSnapshot(this.document.body, { mode: 'default' });
So when N auto-retrying assertions poll concurrently while their targets are still hidden, every poll of every assertion renders a full-page aria snapshot, saturating the page main thread — which in turn delays whatever makes the elements appear, so the assertions poll even longer.
Neutralising that single call (replacing const ariaSnapshot = this._ariaSnapshotForExpect(element, options); with const ariaSnapshot = void 0; in the shipped coreBundle.js) collapses the concurrent case back to the sequential time — verified by scripts 3/4 in the repro (concurrent ~13.3 s → ~1.9 s, nothing else changed). The call is still unconditional on main at the time of writing.
Suggested fix
Compute the aria snapshot lazily — only when the matcher reaches its final failure/timeout — rather than on every poll attempt.
Workaround (consumer side)
Avoid firing many auto-retrying assertions concurrently during a state transition: await them sequentially, or wait once for completion (e.g. a single toBeVisible on the last element) before asserting the rest in bulk.
Version
1.60.0 (regression vs 1.59.1)
Environment
@playwright/test1.60.0Steps to reproduce
Minimal, self-contained repro with scripts: https://github.com/egfx-notifications/pw-concurrent-expect-repro
A page builds a normal-sized DOM (~3,000 nodes) plus N elements that start hidden and become visible after a short delay. The test then waits for all N with
toBeVisible, either concurrently (Promise.all) or sequentially.Expected
Awaiting many auto-retrying assertions concurrently should be roughly as fast as awaiting them sequentially.
Actual
The concurrent case is dramatically slower and scales linearly with DOM size (Firefox 150, N=40 assertions, targets hidden ~1.5s):
Promise.all)On a real app page (a soccer standings table with 18 teams, asserting each row's logo becomes visible) this turned a ~0.8 s step into ~48 s.
Root cause
Introduced in #40390 (commit d8378c3).
InjectedScript.expect()now calls_ariaSnapshotForExpect()on every poll attempt, eagerly, so a failure message is ready in case the matcher ends up failing:For a not-yet-visible / missing element,
_ariaSnapshotForExpectrenders a full-page aria tree ofdocument.body:So when N auto-retrying assertions poll concurrently while their targets are still hidden, every poll of every assertion renders a full-page aria snapshot, saturating the page main thread — which in turn delays whatever makes the elements appear, so the assertions poll even longer.
Neutralising that single call (replacing
const ariaSnapshot = this._ariaSnapshotForExpect(element, options);withconst ariaSnapshot = void 0;in the shippedcoreBundle.js) collapses the concurrent case back to the sequential time — verified by scripts 3/4 in the repro (concurrent ~13.3 s → ~1.9 s, nothing else changed). The call is still unconditional onmainat the time of writing.Suggested fix
Compute the aria snapshot lazily — only when the matcher reaches its final failure/timeout — rather than on every poll attempt.
Workaround (consumer side)
Avoid firing many auto-retrying assertions concurrently during a state transition:
awaitthem sequentially, or wait once for completion (e.g. a singletoBeVisibleon the last element) before asserting the rest in bulk.