skip to content

Why does Playwright mark page.$() and page.$$() as discouraged?

level: middleimportance: should knowfreq 46%

answer

  1. One query, no retry
  2. Null and empty array, immediately
  3. Aliases of querySelector and querySelectorAll
  4. The array is a snapshot, not live
  5. They hand back handles to dispose

basics

~20 s

Both query once and return handles to whatever exists at that instant: page.$() resolves to null when nothing matches and page.$$() to an empty array, with no waiting. Locators re-resolve and auto-wait instead, so these two calls reintroduce flakiness.

solid answer

~40 s

`page.$()` is the alias for `page.querySelector()` and `page.$$()` for `page.querySelectorAll()`, and both are flagged discouraged in the API docs. They perform a single query: `page.$()` resolves to `null` if nothing matches at that moment, `page.$$()` to `[]`. Neither retries, so on an issue board that renders asynchronously the next line either dereferences a `null` or acts on a node the following render throws away. What they return are `ElementHandle`s, which add the staleness risk of a pinned node and the duty to `dispose()`. The array from `page.$$()` is a snapshot, not a live list -- rows added later never join it. The locator equivalents build a query that re-resolves with auto-waiting on every use, and `expect(locator)` polls. `page.$()` does take a `strict` option, but the real advice is not to reach for either.

code

typescript · 10 lines
typescript
// Discouraged: one query, no waiting, handle semantics.
const maybeRow = await page.$('#issue-KAT-118');
await maybeRow?.click();            // does nothing at all when the row has not rendered

// Snapshot, not a live list: rows added later never appear here.
const rows = await page.$$('.issue-row');
console.log(rows.length);

// Preferred: re-resolved and auto-waited on every use.
await page.locator('#issue-KAT-118').click();

go deeper

for a junior

Remember the shape of the result: null or an empty array right away, with no waiting. If you see these calls in a test, a locator is almost always the better line.

for a middle

Explain why the single query is the problem on an asynchronously rendered page, and that the array from the plural form is a frozen snapshot of handles.

for a senior

Treat their presence as a migration signal in a suite, usually inherited habit, and convert them to locators and web-first assertions rather than bolting waits around them.

for a principal

Set the boundary for the whole suite: which discouraged APIs are banned outright, which need a comment justifying a static page, and how that is enforced consistently.

## Two aliases for an older shape `page.$(selector)` is the JavaScript alias for `page.querySelector()`, and `page.$$(selector)` for `page.querySelectorAll()`. Both are marked **discouraged** in the Playwright API documentation, which points you at locators instead. They are not deprecated or broken -- they do exactly what their DOM namesakes do, which is the problem. ## They query once, and only once - `page.$('#issue-list')` resolves to `null` when nothing matches *right now*. It does not poll, and the docs point at a locator wait if you need one. - `page.$$('.issue-row')` resolves to `[]` when nothing matches right now, and to a fixed-length array otherwise. - Neither call retries, so on a board that renders asynchronously the very next line either dereferences a `null` or acts on a node the next render is about to discard. - `page.$()` accepts a `strict` option to opt into single-match resolution; `page.$$()` has no such notion, because returning many is its whole job. The array from `page.$$()` deserves its own warning: it is a snapshot of handles, not a live list. Rows added after the call never appear in it, and rows replaced by a re-render are still in it as handles to detached nodes. ## What comes back carries handle semantics Everything true of `ElementHandle` in general is now true of your result: 1. It points at one node, so a re-render invalidates it and the next action throws `Element is not attached to the DOM`. 2. It pins that node against garbage collection until `dispose()`, or until the frame navigates. 3. It gives you no auto-waiting on later use for the element to exist -- existence was decided at query time. ## The comparison people actually need | | `page.$('#assign')` | `page.locator('#assign')` | |---|---|---| | When the query runs | immediately, once | on every use | | Nothing matches | resolves to `null` | waits, then times out on use | | Value returned | `ElementHandle` or `null` | a `Locator` description | | After a re-render | detached node | re-resolved node | | Async construction | yes, must be awaited | no, synchronous | The last row is a quiet ergonomic win: `page.$()` is asynchronous, so it forces an `await` and a null check into a place where a locator needs neither. ## What to write instead - For an action or a read: build a locator and use it directly. - For "is it there yet": use a web-first assertion such as `expect(locator).toBeVisible()`, which polls. - For "how many are there": `locator.count()`, which re-queries, rather than the length of a `page.$$()` snapshot. - For a genuine DOM computation over many nodes: `locator.evaluateAll()`, which resolves the selector at call time and hands the array of elements to a function inside the page. ## Where they still fit The documentation reserves `ElementHandle` for the rare case of extensive DOM traversal on a static page, and `page.$()` / `page.$$()` are the shortest way to get one when the page truly is static -- a rendered report, a finished export dialog, a fixture page you control. Even then, the discipline is the same: create, use and dispose inside one step. Anywhere the issue board can re-render underneath you, these two calls are a flake generator with a familiar-looking name.

  • If page.$() does not wait, what is the locator-shaped way to wait for an element to appear?
    Assert it. `await expect(page.getByRole('row', { name: 'KAT-118' })).toBeVisible()` polls until the row is there or the assertion times out, and it fails with a useful message. Actions do the same implicitly, so most of the time no explicit wait is needed at all.
  • Is there any case where page.$$() is still the right call?
    Only where the page is genuinely static and you need real handles for extensive DOM traversal -- a finished report or a fixture page. Even then, create, use and dispose within one step. For counting or reading text from many nodes, locator-based calls re-query and are safer.

saying these in an interview costs you the question

  • page.$ waits for the element like a locator does
  • page.$$ returns a live list that updates
  • They are deprecated and will be removed
  • A null result means the selector is wrong
  • Handles from page.$ do not need disposing