Q6 of 42 · Playwright
What is a Locator and how does it differ from an ElementHandle?
Short answer
Short answer: A Locator is a *query description* — it re-runs every time you act on it, giving auto-retry. An ElementHandle is a *snapshot reference* to a single DOM node at a point in time. Locators are the modern, recommended API; ElementHandles are legacy and prone to staleness.
Detail
Playwright shipped with ElementHandle (similar to Selenium's WebElement) and later introduced Locator as the preferred abstraction.
ElementHandle is a reference to a specific DOM node, captured at the moment page.$('selector') returned. If the DOM updates (re-render, virtual list scroll, animation), the handle becomes stale and operations on it fail. The classic React staleness problem.
Locator is a query plus a strategy for re-running it. page.locator('selector') doesn't query the DOM until you call an action (.click(), .textContent()). Each action re-queries, with auto-wait for actionability. There's no staleness.
// ElementHandle (legacy) — captures once, can go stale
const button = await page.$('[data-test=submit]');
await page.reload();
await button.click(); // ❌ probably stale
// Locator — re-queries on each use
const button = page.locator('[data-test=submit]');
await page.reload();
await button.click(); // ✅ re-queries the DOM, finds the new button
Locators support chaining: page.locator('[data-test=cart-row]').nth(0).locator('[data-test=qty]') reads the first cart row's quantity input.
The semantic locators are also Locators: page.getByRole('button', { name: 'Submit' }), page.getByTestId('submit'), page.getByText('Welcome'). Same retry semantics, more readable.
When ElementHandle is still useful: rare cases where you need to pass a DOM reference into page.evaluate for browser-side computation. locator.elementHandle() converts when you need to. Otherwise prefer Locator everywhere.
// EXAMPLE
locator-vs-handle.spec.ts
import { test, expect } from '@playwright/test';
test('Locator re-queries on each action', async ({ page }) => {
await page.goto('/dashboard');
// Locator: a query description, no DOM query yet
const cartCount = page.getByTestId('cart-count');
await expect(cartCount).toHaveText('0');
// Action that updates the DOM
await page.getByRole('button', { name: 'Add to cart' }).click();
// Same locator — re-queries, finds the updated count
await expect(cartCount).toHaveText('1');
});