Why Old Web Tech Still Matters
Front-end development has a habit of layering abstractions over time. New frameworks and tooling often shield developers from the underlying browser APIs, and that’s not necessarily a bad thing. But it means that a generation of engineers may never directly encounter certain long-standing web technologies, simply because they’ve never had a reason to look beneath the framework layer.
This isn’t about knowing everything. The web platform is immense, so it’s reasonable to be unaware of a given API. The problem arises when we *do* need it — perhaps for a one-off edge case — and don’t know it exists. A colleague recently asked about detecting when a user navigates away from a tab. The answer is straightforward with beforeunload, pageHide, or visibilityChange. I knew about these not because I studied them in advance, but because I’d run into the same need in an earlier project.
Those forgotten APIs are often exactly what modern frameworks abstract away. Consider the CSSOM: you might assume any front-end developer working with CSS and JavaScript has hands-on CSSOM experience. In practice, that isn’t the case. I once worked on a React project that required a stylesheet to load only on pages for a selected payment provider. A developer assigned to the task had never dynamically loaded a stylesheet, since React doesn’t require a CSSOM-level approach to accomplish it.
When discussions surface about pruning older parts of the browser stack, it’s worth examining what would actually be lost. The XML technology suite is one such area. XSLT appears in proposals for removal from browsers, but within that stack there’s a more useful piece worth keeping: XPath. It finds use cases today that extend far beyond XML documents, including practical situations like the one above.
Filling the Gaps Where CSS Selectors Fall Short
XPath is a query language for locating nodes or attributes in a tree structure that has a single root element. While XSLT happens to rely on it, XPath is the central API worth keeping. It’s appealing because CSS selectors can’t find every element — nor can they locate an element based on its current position in the DOM. XPath can.
No one is suggesting we abandon CSS selectors for XPath. The two have different strengths. CSS conveniently handles class-name-based queries. XPath provides a broader match by default: a query looking for the string myClass in the class attribute will also match .myClass2, because it’s checking for a substring in any attribute, not a tokenised class list. That granularity isn’t always useful, which is why both query languages are necessary.
The issue is that JavaScript’s document.evaluate for XPath isn’t compatible with familiar query selector methods. The following is a simple hand-rolled compatibility layer. It’s not meant for production — only for learning how to combine the two query approaches practically. It attaches two methods to the document object: queryCSSSelectors (essentially querySelectorAll) and queryXPaths.
Both methods run only when the current results array is of type nodes; otherwise they return an empty queryResult. When the amend property is true, the method updates its own results rather than replacing them. This allows chaining and mixing queries.
Queries That Save Multiple Steps
A typical task like extracting a list’s text values usually involves multiple steps: query for li elements, transform the result to an array, map that array, then pull out each element’s text node. XPath condenses this significantly.
Take the following markup:
onetwothree
Querying with //li/text() returns those text nodes directly as ["one","two","three"]. The text() term in XPath looks like a function call, and it is one. It returns the text node of each matched element.
Attribute extraction is equally direct. On a page containing a link with the label “Sign In” and an href pointing to /login.html, a query targeting the anchor whose text matches that label will give you the attribute value you need:
//a[text()="Sign In"]/@href
That query finds all a elements containing exactly the text “Sign In” and returns their href attribute. The result is ["/login.html"].
These two examples highlight why XPath deserves attention even if you rarely touch XML today. For certain lookups — retrieving text nodes, pulling attributes based on ancestor or sibling relationships, or locating nodes by DOM position — XPath condenses what would otherwise be several JavaScript steps into a single expression.
XPath Functions Worth Knowing
Most XPath functions fall into a small set of categories: text checks, string manipulation, math, and boolean logic. A handful come up constantly in real queries:
starts-with— returnstruewhen a string begins with another, e.g.starts-with(@href, 'http:').contains— returnstruewhen a string appears anywhere inside another, e.g.contains(text(), "Smashing Magazine").count— returns the number of matches for a path, e.g.count(//*[starts-with(@href, 'http:')]).substring— extracts a portion of a string by position, like its JavaScript namesake but with the string passed as an argument:substring("my text", 2, 4)yields"y t".substring-before— returns everything before a delimiter, or an empty string when absent:substring-before("my text", " ")gives"my".substring-after— the mirror image:substring-after("my text", " ")gives"text".normalize-space— trims leading/trailing whitespace and collapses internal runs of whitespace to single spaces.not— inverts a boolean.true/false— explicit boolean literals.concat— joins multiple strings; unlike JavaScript, it takes all arguments explicitly rather than acting as a method.string-length— returns the character count of its argument.translate— replaces characters according to a mapping:translate("abcdef", "abc", "XYZ")outputsXYZdef.
The usual numeric utilities — floor, ceiling, round, sum — work as you would expect from any programming language. A working reference for all of these is shown in the demo below.
See the Pen [XPath Numerical functions [forked]](https://codepen.io/smashingmag/pen/emZmgzX) by Bryan Rasmussen.
Be aware that most string and numeric functions accept a single input. Since XPath is meant for querying, a function like count(...) is applied to the first node matching its path argument, not to all matches:
//li[floor(text()) > 250]/@val
Type conversion functions (boolean, number, string, node) exist, though JavaScript’s own coercion quirks mean you should reach for them sparingly — occasionally converting a string to a number for a comparison is legitimate.
Most functions operate on plain strings or numbers as easily as on DOM nodes. A function like substring-after can pull text from an href attribute or just process a literal:
const testSubstringAfter = document.queryXPaths("substring-after('hello world',' ')");
A query like that returns ["world"]. The demo below exercises several functions against non-DOM values:
See the Pen [queryXPath [forked]](https://codepen.io/smashingmag/pen/qEZERqd) by Bryan Rasmussen.
The translate Function’s Quirk
One surprising aspect of translate: characters in the second argument that have no counterpart in the third argument are deleted from the output. For example, applying this query:
translate('Hello, My Name is Inigo Montoya, you killed my father, prepare to die','abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ,','*')
…produces a string with only the translated characters and stray spaces:
[" * * ** "]
Here, a maps to *; every other character with no target mapping is stripped entirely, leaving only whitespace between the asterisks.
When the mapping covers every source character, the problem disappears:
translate('Hello, My Name is Inigo Montoya, you killed my father, prepare to die','abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ,','**************************************************')")
"***** ** **** ** ***** ******* *** ****** ** ****** ******* ** ***"
JavaScript has no built-in equivalent to translate. For simple cases, replaceAll with regular expressions works, but a fuller translation requires a wrapper. The demo below exposes XPath’s translate as a JavaScript function:
See the Pen [translate function [forked]](https://codepen.io/smashingmag/pen/ZYWYLyZ) by Bryan Rasmussen.
A classic use case is a Caesar Cipher with a three-character offset — very modern encryption for 48 B.C.:
translate("Caesar is planning to cross the Rubicon!",
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
"XYZABCDEFGHIJKLMNOPQRSTUVWxyzabcdefghijklmnopqrstuvw")
Input "Caesar is planning to cross the Rubicon!" becomes "Zxbpxo fp mixkkfkd ql zolpp qeb Oryfzlk!".
A more playful example: the metal function below adds umlauts to any eligible letter via translate:
See the Pen [metal function [forked]](https://codepen.io/smashingmag/pen/YPqPNrN) by Bryan Rasmussen.
const metal = (str) => {
return translate(str, "AOUaou","ÄÖÜäöü");
}
Given "Motley Crue rules, rock on dudes!", it returns "Mötley Crüe rüles, röck ön düdes!" — with ample parody potential.
Combining CSS and XPath
CSS selectors understand classes natively; XPath only performs string comparisons on the class attribute. For most cases, string matching works — until someone names classes .primaryLinks and .primaryLinks2 and you accidentally match both. Real-world codebases do contain such surprises.
The markup below demonstrates XPath run against a context node that isn’t the document root:
See the Pen [css and xpath together [forked]](https://codepen.io/smashingmag/pen/ogxgBpz) by Bryan Rasmussen.
The CSS query .relatedarticles a selects two a elements inside a div.relatedarticles. Three queries produce unexpected results when run against those elements as context:
//text()— returns all text in the whole document.//a/text()— returns text from all links in the document../a/text()— returns nothing.
These outcomes are not bugs. A leading // always walks the entire document regardless of context node. This is precisely where XPath outdoes CSS: you can traverse from a node up to an ancestor, over to a sibling, and down into its descendants — CSS cannot. The ./ prefix scopes the query to children of the current node, with . denoting the current node and / descending to a child (element, attribute, or text, per the following path segment). Since the a elements matched by CSS have no child a element, that query comes up empty.
Successful queries against those same elements:
.//text()./text()normalize-space(./text())
The last one exist here because of the HTML structure:
<a href="https://www.smashingmagazine.com/2018/04/feature-testing-selenium-webdriver/">
Automating Your Feature Testing With Selenium WebDriver
</a>
Raw text nodes contain leading and trailing line feeds; normalize-space cleans them up.
Single-Result Gotcha
Any XPath function that returns something other than a boolean applies to the first node of its input XPath only. The demo shows the results of several such functions:
See the Pen [xpath functions examples [forked]](https://codepen.io/smashingmag/pen/JoXYGeN) by Bryan Rasmussen.
A query like:
document.queryXPaths("substring-after(//a/@href,'https://')");
…returns a single string:
"www.smashingmagazine.com/2018/04/feature-testing-selenium-webdriver/"
That’s expected: these functions return one string or number, not arrays. To get each result, map the function across the node set yourself:
document.queryCSSSelectors("a").queryXPaths("substring-after(./@href,'https://')");
That returns two strings:
["www.smashingmagazine.com/2018/04/feature-testing-selenium-webdriver/","www.smashingmagazine.com/2022/11/automated-test-results-improve-accessibility/"]
Function nesting works just as it does in JavaScript. Given a known URL pattern for Smashing Magazine articles, you can extract pieces in a single pass:
`translate(
substring(
substring-after(./@href, ‘www.smashingmagazine.com/')
,9),
'/','')`
That pipeline takes everything in href after www.smashingmagazine.com/, removes the first nine characters, then deletes the trailing forward slash via translate. The result:
["feature-testing-selenium-webdriver","automated-test-results-improve-accessibility"]
Where XPath Earns Its Keep
Test automation is a natural fit. CSS cannot traverse upward or across the DOM, but XPath can target any element from any position in the tree. Modern builds frequently mangle CSS class names, whereas XPath matches on structure and text content, which tend to be more stable. There’s even published research on constructing resilient XPath expressions; brittle selectors that break purely because a class got renamed are a well-known testing headache.
XPath also excels at extracting specific locators among many candidates. Both CSS and XPath offer multiple routes to the same matching elements, but XPath’s added precision can narrow results — for example, selecting an h2 inside a div that immediately follows a sibling div containing an image with data-testID="leader":
<div>
<div>
<h1>don't get this headline</h1>
</div>
<div>
<h2>Don't get this headline either</h2>
</div>
<div>
<h2>The header for the leader image</h2>
</div>
<div>
<img data-testID="leader" src="image.jpg"/>
</div>
</div>
The full query reads:
document.queryXPaths(`
//div[
following-sibling::div[1]
/img[@data-testID='leader']
]
/h2/
text()
`);
Here is a live prototype of that logic:
See the Pen [Complex H2 Query [forked]](https://codepen.io/smashingmag/pen/zxqxNev) by Bryan Rasmussen.
In short, few element selectors are as flexible.
XSLT 1.0 Deprecation
The Chrome team has announced plans to remove XSLT 1.0 support from the browser. That matters because XSLT 1.0 relies on XPath 1.0 for document transformation, and XPath 1.0 is the version found in most browsers today. When XSLT goes, a key part of that XPath implementation goes with it.
XPath itself is unlikely to disappear entirely, especially since it remains a solid tool for writing automated tests. But the deprecation has already sparked plenty of discussion, including a lively Hacker News thread where developers debate the decision and explore workarounds, such as using JavaScript as a shim for XSLT-style transformations.
Some have suggested browsers adopt SaxonJS, a JavaScript port of the Saxon engine that supports XSLT, XQuery, and XPath. That idea carries weight: SaxonJS implements current versions of these specifications, while no browser goes beyond version 1.0 of XPath or XSLT, and none supports XQuery natively.
Saxonica’s Norm Tovey-Walsh, however, tempered expectations. He noted that while Saxonica would welcome a conversation with any browser vendor interested in using SaxonJS as a starting point for modern XML integration, dropping the current JavaScript build into a browser unchanged is not realistic. A vendor building from the inside could approach integration at a far deeper level. His comments came about a week before the deprecation announcement.
“If any browser vendor was interested in taking SaxonJS as a starting point for integrating modern XML technologies into the browser, we’d be thrilled to discuss it with them.”
— Norm Tovey-Walsh
“I would be very surprised if anyone thought that taking SaxonJS in its current form and dropping it into the browser build unchanged would be the ideal approach. A browser vendor, by nature of the fact that they build the browser, could approach the integration at a much deeper level than we can ‘from the outside’.”
— Norm Tovey-Walsh
The Takeaway
XPath remains a great example of older technology in the browser stack that still offers real utility. The deprecation of XSLT 1.0 may remove one piece, but XPath’s role in testing is a strong argument for its longevity. Interest often spikes when a feature is slated for removal, and that may lead to renewed attention on the specification — or at least on the tools that build on it.
For those looking to dig deeper:
- “Enhancing the Resiliency of Automated Web Tests with Natural Language” (ACM Digital Library) by Maroun Ayli, Youssef Bakouny, Nader Jalloul, and Rima Kilany — a paper rich with XPath examples for resilient test writing.
- XPath on MDN — a solid technical reference for understanding how XPath works.
- XPath Tutorial on ZVON — a practical, example-driven tutorial.
- XPather — an interactive tool for experimenting with XPath expressions directly.




