A Practical Guide to .nth() and :nth-child() for writing reliable Playwright tests

When writing automated tests in Playwright, selecting the correct element is often more challenging than it first appears. Modern web applications generate dynamic content, inject elements conditionally, and frequently update their DOM structure. Choosing the wrong strategy can introduce fragile tests that fail with even minor structural changes.
A common scenario is when testing web applications, we often deal with lists of elements — menus, links, buttons, or table rows. Sometimes, we want to select the second link in a navigation bar or the third button in a list or a specific row in a table.
Playwright provides multiple ways to do this:
Using Playwright-specific locators with .nth()
Using CSS pseudo-classes like :nth-child()
Although they seem similar, .nth() and :nth-child() approaches operate on fundamentally different principles. One depends strictly on DOM hierarchy, while the other works within the result set of matched elements. Understanding this distinction is essential for writing stable automation tests that don’t break when minor layout changes occur.
In this article, we’ll explore how .nth() and :nth-child() differ, when to use each one, and how this understanding improves the reliability of your Playwright test suite.
How .nth() Works in Playwright
Playwright’s .nth() method lets you pick an element based on its position among elements that match your locator.
Things to understand about .nth():
Indexing starts at zero, so .nth(0) is the first element.
It only counts elements that match your locator, ignoring other tags or elements.
Because of this, it is very reliable on dynamic pages where extra elements might appear.
For Example:
When the HTML looks like:
<ul>
<a href="#">Placeholder</a>
<a href="">Username</a>
<a href="/logout">Sign out</a>
</ul>If we want to select the username link, we might write the Playwright locator as:
const usernameLink = page.locator('ul a').nth(1)Output : This selects the second <a> element — “Username.”
How Playwright Executes .nth()
This locator statement does not immediately resolve to a single element. Instead, page.locator(‘ul a’) creates a locator representing all <a> elements inside <ul>, and .nth() narrows that result to a specific match.
An important detail to understand is that .nth() does not immediately query the DOM when it is written. Playwright locators are lazy and are resolved only when an action is performed such as .click(), .fill(), or .innerText().
This built-in waiting mechanism makes .nth() particularly reliable in dynamic applications where elements may not be immediately available.
Now, a common question at this stage is: Why does it select the second element from the filtered result set? Why not the first? I specified .nth(1), so shouldn’t it fetch the first element?
The reason is, .nth() uses zero-based indexing. That means counting starts from 0, not 1.
So:
.nth(0) → first match (i.e. Placeholder, in the above example)
.nth(1) → second match (i.e. Username)
.nth(2) → third match (i.e. Sign out)
Even if a new <span> or <div> is added before it, this will still select the correct <a>. Hence we can say that .nth() is more reliable even when the DOM changes.
Understanding :nth-child() in CSS
:nth-child() is a CSS pseudo-class, not specific to Playwright. It selects an element based on its exact position among all children of its parent.
Things to understand about .nth-child():
Indexing is one-based, so :nth-child(1) is the first child.
It counts all children, not just the ones matching a particular tag.
If other elements (like <span> or <div>) are added, it may no longer select the element you expect.
For Example:
When the HTML looks like:
<ul>
<span>Logo</span> <!-- 1st child -->
<a href="#">Placeholder</a> <!-- 2nd child -->
<a href="">Username</a> <!-- 3rd child -->
<a href="/logout">Sign out</a> <!-- 4th child -->
</ul>If we want to select the Username link, we might write the Playwright locator as:
const usernameLink = page.locator('ul a:nth-child(2)');Output : It will select second child from the parent i.e. <a> of " Placeholder" not "Username". But why?
Because, in the locator we specified for this case :nth-child(2), which means - Select the element that is the second child of its parent. It does not mean “second <a>.”
So in this case, If we want Username, we must select the element that is the third child. And the Playwright locator for it will look like:
const usernameLink = page.locator('ul a:nth-child(3)');Now it will work and select Username — because <a href="">Username</a> is literally the third child of <ul>.
Now Let’s Introduce a Small DOM Change
In real-world applications, markup often evolves. Designers might add icons, badges, or labels. Suppose the HTML changes like this:
<ul>
<span class="nav-label">Account</span> <!-- New element -->
<div class="icon-wrapper"></div> <!-- Another new element -->
<a href="#">Placeholder</a>
<a href="">Username</a>
<a href="/logout">Sign out</a>
</ul>Now, if try to locate the Username link using the same locator as used above:
const usernameLink = page.locator('ul a:nth-child(3)');This locator is still pointing to the third child but now the third child is "Placeholder" instead of "Username".
And hence by using a strict strucutral locator like nth-child() our test would likely fail or select element in case of slight DOM changes.
Now, look at the Playwright version of locator used above (nth):
const usernameLink = page.locator('ul a').nth(1)The output is still "Username", even after the DOM changed by adding new elements. This is the power of using .nth().
Why .nth() still works in this case? Here’s what happens:
page.locator('ul a') collects only the <a> elements
.nth(1) selects the second match from that filtered list
The <span> and <div> are completely ignored
Even after adding new elements, .nth(1) still correctly selects <a href="">Username</a>, because .nth() operates on the matched result set, not on the raw DOM structure.
When .nth() Can Fail
While .nth() is generally safer than :nth-child(), it is not bulletproof.
.nth() depends on the order of matching elements. If that order changes, your test may silently start targeting the wrong element. In the above example, if the order of <a> elements changes, .nth() will select a different element.
When to avoid .nth()?
.nth() becomes potentially fragile in the interfaces where:
The order of elements is dynamic or user-driven
Items are sorted, filtered, or paginated
New elements may be inserted into the same group
Rendering order changes due to asynchronous updates
In such cases, the selector may still resolve successfully, but it may interact with the wrong element — which can be harder to detect than a test failure.
Conclusion
Throughout this discussion, we’ve seen that :nth-child() is tied directly to DOM structure and uses one-based indexing, while .nth() operates on the filtered set of matched elements and uses zero-based indexing.
That distinction affects how stable your tests will be when the UI evolves. Small structural changes can break :nth-child(), while changes in element order can affect .nth().
The important learning is not that one is universally better than the other, but that both are position-based strategies. Position can be useful, but it should be chosen deliberately. When the position represents stable meaning in the interface, .nth() can be a practical and readable solution. When structure is guaranteed and predictable, :nth-child() may still be appropriate.
Reliable Playwright tests come from understanding the behavior behind your selectors. The more intentional you are with element selection, the fewer surprises you’ll face as the application grows and changes.
Mastering selector strategy is a small skill that makes a significant difference in long-term test stability!
Happy Learning :)


