skip to content

In Playwright, what is the difference between a Locator and an ElementHandle?

level: juniorimportance: must knowfreq 76%

answer

  1. Recipe versus pointer
  2. Re-resolved on every use
  3. One DOM node, captured once
  4. Detached node after a re-render
  5. Handles are discouraged in the docs

basics

~10 s

A Playwright Locator stores a recipe for finding an element and re-runs it on every use. An ElementHandle points at one DOM node captured once, so it breaks when the page re-renders that node.

solid answer

~40 s

`page.getByRole('row', { name: 'KAT-118' })` returns a `Locator`, which holds only the query. Every action, read or assertion made through it re-runs that query against the live DOM, with auto-waiting and a single-match rule. `page.$()` or `locator.elementHandle()` returns an `ElementHandle`: a reference to one specific DOM node, resolved once. If the issue board re-renders that row, the handle still addresses the discarded node and the next action throws `Element is not attached to the DOM`. A handle also keeps its node alive against garbage collection until `dispose()` is called, and is auto-disposed when its frame navigates. The API docs mark `ElementHandle` as discouraged and reserve it for extensive DOM traversal on a static page; locators cover ordinary actions and assertions.

code

typescript · 17 lines
typescript
import { test, expect } from '@playwright/test';

test('the board refresh invalidates a handle but not a locator', async ({ page }) => {
  await page.goto('/board');

  const row = page.getByRole('row', { name: 'KAT-118' });
  const handle = await row.elementHandle();

  await page.getByRole('button', { name: 'Refresh board' }).click();

  // The locator re-runs its query and finds the newly rendered row.
  await expect(row).toBeVisible();

  // The handle still points at the node the refresh replaced.
  await handle.click(); // Error: Element is not attached to the DOM
  await handle.dispose();
});

go deeper

for a junior

Be able to give the one-line difference: a locator stores how to find the element and looks it up again each time, while a handle points at one DOM node captured once.

for a middle

Explain when each lookup happens. Construction does nothing; every action, read and assertion re-runs the query. That is exactly why a locator survives a re-render and a handle does not.

for a senior

Show the judgment: page objects hold locators, never handles, and a detached-node failure is read as a handle-lifetime bug rather than a timing problem to be waited away.

for a principal

Own the convention across the suite. Locators are the default vocabulary, an ElementHandle needs a written reason and a short lifetime, and review keeps handles out of shared page objects.

## Two ways to name an element Playwright gives a test two objects for talking about an element, and they hold completely different things. A **`Locator` is a description** -- the selector plus any chaining, kept on the test side and re-run against the live DOM every single time you use it. An **`ElementHandle` is a reference** -- a pointer, held in the Playwright process, to one specific DOM node that existed in the browser at the instant the handle was created. Constructing a locator does no work at all. `page.getByRole('row', { name: 'KAT-118' })` performs no round trip, waits for nothing, and throws nothing if the issue board has not rendered that row yet. The query runs when you *use* the locator. ## What re-resolution buys you Because the query is re-run per call, each of these performs its own fresh lookup: - an action such as `click()` or `fill()`; - a read such as `textContent()` or `count()`; - a web-first assertion such as `expect(row).toBeVisible()`. Each lookup is retried until the element is found and ready, or until the timeout expires. That is why a locator absorbs the ordinary churn of a single-page app: the board can re-render a row between your assertion and your click and the click still lands, because the click resolves the selector again. Even when the node is swapped out in the middle of an action, the action log records `element was detached from the DOM, retrying` and Playwright resolves once more inside the same timeout budget. ## What a handle pins down An `ElementHandle` comes from `locator.elementHandle()`, `page.$()`, `page.waitForSelector()` or `page.evaluateHandle()`. From then on it means *that node* and nothing else: - if the framework unmounts the row and mounts a replacement, the handle still addresses the discarded node, and the next action through it throws `Element is not attached to the DOM`; - if the node merely changes its text or class, the handle stays valid and reads the new value -- it tracks identity, not content; - the handle keeps that node out of the browser's garbage collector until you call `dispose()`, and is auto-disposed when its owning frame navigates; - it carries no auto-waiting for existence, because existence was settled when the handle was made. ## Side by side | | `Locator` | `ElementHandle` | |---|---|---| | Holds | the query | one DOM node | | Created by | `page.getByRole()`, `page.locator()` | `page.$()`, `locator.elementHandle()` | | Resolves | on every use | once, at creation | | After a re-render | finds the new node | throws `Element is not attached to the DOM` | | Cleanup | none needed | `dispose()`, or frame navigation | | Status in the API docs | recommended | discouraged | ## Which one to reach for 1. Default to a locator for every action, read and assertion. That covers essentially all normal test code, and it is what the docs mean by making tests wait-for-selector-free. 2. Reach for a handle only in the narrow cases the docs still allow: extensive DOM traversal on a page that is not changing, or passing a live element into `page.evaluate()` next to other arguments. 3. If you do take a handle, keep its lifetime down to a statement or two and dispose it when you are finished. The version of this that hurts in a real suite is a handle stored in a page-object field, because the field outlives the render that invalidated it. Store the locator instead: it is inert, cheap to keep around, and still correct after any number of re-renders. ## The failure this distinction prevents ```ts // Locator: the selector is evaluated again for the click. const row = page.getByRole('row', { name: 'KAT-118' }); await page.getByRole('button', { name: 'Refresh board' }).click(); await row.getByRole('button', { name: 'Assign' }).click(); // fine // Handle: frozen on the node that the refresh threw away. const frozen = await page.$('#issue-KAT-118'); await page.getByRole('button', { name: 'Refresh board' }).click(); await frozen.click(); // Error: Element is not attached to the DOM ``` The two blocks look equivalent and are not. The first describes an element; the second remembers one. On a board that re-renders on every poll, only the first survives, and that is the whole reason locators are the default vocabulary of a Playwright suite.

  • Does an ElementHandle break when the element's text changes, or only when the node itself is replaced?
    Only when the node is replaced or removed. A handle references the DOM node, so text, class and attribute changes read back fine and `handle.textContent()` returns the new value. It breaks when the framework unmounts that node in favour of a different one, or when the frame navigates, which auto-disposes it.
  • Is building a Playwright locator an async call that touches the page?
    No. `page.getByRole(...)` and `page.locator(...)` are synchronous and perform no browser round trip -- they just build a description. Nothing is queried until you act, read or assert through the locator, which is why constructing one for an element that has not rendered yet never throws.

A locator is a street address you look up on each visit; a handle is a photograph of the house, still perfectly clear after the house has been demolished.

saying these in an interview costs you the question

  • A locator is just a cached reference to the element
  • Handles are faster, so prefer them for actions
  • A handle goes stale whenever the element's text changes
  • Building a locator queries the DOM immediately
  • Disposing a handle removes the element from the page