Our reason for writing this: we are Geonode and we sell proxies to people who collect data, and XML is what a great deal of that data arrives as — sitemaps, RSS and Atom feeds, SOAP responses, product catalogues. The honest note is that a parse failure is almost never a network problem. If your XML parser is complaining, print the first 200 characters of what you received before changing any code. Nine times out of ten it is an HTML error page, and the parser is reporting accurately that you were not sent XML.
In the Browser: DOMParser
Built in, no dependencies.
const parser = new DOMParser();
const doc = parser.parseFromString(xmlString, "application/xml");
const titles = doc.querySelectorAll("item > title");
titles.forEach(t => console.log(t.textContent));
MDN documents the accepted MIME types as text/html, text/xml, application/xml, application/xhtml+xml and image/svg+xml. It returns a Document "with a contentType property matching the given mimeType", which may be an HTMLDocument or an XMLDocument depending on what you asked for.
Use application/xml for XML. Passing text/html invokes the HTML parser, which is forgiving in ways that will quietly change your document — it will not respect XML's case sensitivity, and it will happily accept things XML forbids.
Once parsed, you have a DOM. Everything you know about DOM traversal applies: querySelector, querySelectorAll, getElementsByTagName, children, textContent.
The Error Trap: It Does Not Throw
The behaviour that catches everyone the first time.
Feed DOMParser malformed XML and it does not raise an exception. MDN is explicit: "the returned XMLDocument will contain a <parsererror> node describing the parsing error", and the error "may also be reported to the browser's JavaScript console".
So you must check:
const doc = parser.parseFromString(xmlString, "application/xml");
const errorNode = doc.querySelector("parsererror");
if (errorNode) {
throw new Error(`XML parse failed: ${errorNode.textContent}`);
}
Without that check, a malformed document produces a Document object containing an error message where your data should be. Your subsequent querySelectorAll returns nothing, and the symptom looks like a selector problem rather than a parse failure.
Wrap it once:
function parseXml(text) {
const doc = new DOMParser().parseFromString(text, "application/xml");
const err = doc.querySelector("parsererror");
if (err) {
throw new Error(
`XML parse failed: ${err.textContent.trim()}. ` +
`First 200 chars: ${text.slice(0, 200)}`
);
}
return doc;
}
The text.slice(0, 200) is the part that saves time. A parse error tells you the input was invalid; the first 200 characters tell you it was an HTML error page.
In Node: Choosing a Library
Node has no built-in XML parser, so this is a dependency decision. Weekly download figures give a rough sense of adoption — these are from the npm registry, checked September 2026.
| Library | Weekly downloads | Style |
|---|---|---|
sax | ~88.9M | Streaming, event-based |
fast-xml-parser | ~85.0M | XML to plain JavaScript objects |
@xmldom/xmldom | ~48.7M | DOM implementation for Node |
xml2js | ~44.5M | XML to objects, callback and promise APIs |
xpath | ~12.2M | XPath queries, pairs with xmldom |
fast-xml-parser is the pragmatic default for most work. It converts XML into ordinary JavaScript objects, which means you navigate with dot notation rather than DOM methods:
import { XMLParser } from "fast-xml-parser";
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@" });
const obj = parser.parse(xmlString);
console.log(obj.rss.channel.item[0].title);
The trade-off to understand: object conversion is lossy in one specific way. An element that appears once becomes an object; the same element appearing twice becomes an array. So channel.item is an array for a feed with three items and an object for a feed with one — and code that assumes an array breaks on the single-item case. Most libraries offer an option to always produce arrays for named elements, and turning it on for anything you will iterate is worth doing before it bites you.
@xmldom/xmldom gives you a real DOM in Node, which matters if you want the same code to work in both environments or if you need XPath. Pair it with the xpath package:
import { DOMParser } from "@xmldom/xmldom";
import xpath from "xpath";
const doc = new DOMParser().parseFromString(xmlString, "text/xml");
const titles = xpath.select("//item/title/text()", doc);
sax is a streaming parser that emits events as it reads. It is the answer for documents too large to hold in memory — a multi-gigabyte sitemap index, a bulk catalogue export — where a DOM approach simply will not fit.
xml2js is long-established and widely used. Its API is a little dated but entirely serviceable, and there is a great deal of existing code using it.
Namespaces: The Part That Breaks Real Documents
The most common reason a working selector suddenly finds nothing.
Many real XML formats declare namespaces:
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://example.com/</loc></url>
</urlset>
That xmlns puts every element in a default namespace. In a namespace-aware parser, <loc> is not simply loc — it is loc in the sitemaps namespace, and a plain getElementsByTagName("loc") may find nothing.
Three ways to handle it, in increasing order of correctness.
Use the namespace-aware methods:
const NS = "http://www.sitemaps.org/schemas/sitemap/0.9";
const locs = doc.getElementsByTagNameNS(NS, "loc");
Use a wildcard namespace when you do not care which one:
const locs = doc.getElementsByTagNameNS("*", "loc");
Configure your library to ignore namespaces. Most object-mapping libraries have an option to strip namespace prefixes, which produces plain loc keys. Convenient, and it will silently merge two genuinely different elements that happen to share a local name — acceptable for a sitemap, dangerous for a document mixing vocabularies.
Note that querySelector behaves differently from getElementsByTagNameNS here: CSS selectors have their own namespace syntax that is awkward and rarely used, so for namespaced XML the NS methods or XPath are more reliable.
XPath in JavaScript
Available in browsers through document.evaluate, and in Node through the xpath package with xmldom.
const result = doc.evaluate(
"//item/title/text()",
doc,
null,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
null
);
for (let i = 0; i < result.snapshotLength; i++) {
console.log(result.snapshotItem(i).nodeValue);
}
The API is verbose enough that most people wrap it once and forget it. What makes it worth the trouble is that XPath expresses things CSS selectors cannot — matching on text content, navigating to ancestors, and positional logic relative to siblings.
Two constraints. Browsers implement XPath 1.0, so no matches(), no lower-case(), no ends-with(). And namespaces require a resolver function — the third argument — which maps prefixes to namespace URIs. Passing null works only for documents without namespaces, which excludes most real feeds.
For namespaced documents in a browser:
const resolver = prefix => ({ sm: "http://www.sitemaps.org/schemas/sitemap/0.9" }[prefix] || null);
const result = doc.evaluate("//sm:loc/text()", doc, resolver, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
Note that you invent the prefix — sm here — regardless of what the document uses, because XPath 1.0 has no concept of a default namespace.
Converting XML to JSON, and What Gets Lost
The most common thing people actually want, and it is worth understanding that the conversion is not lossless.
XML and JSON have different data models. XML has attributes, elements, text nodes, comments, processing instructions, namespaces and ordering. JSON has objects, arrays, strings, numbers, booleans and null. Four things do not survive the trip cleanly.
Attributes versus child elements. <item id="1"><name>x</name></item> has an attribute and a child, and JSON has no distinction between them. Libraries handle this by prefixing attribute keys — commonly @ or $ — which you configure and then must remember:
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@" });
// { item: { "@id": "1", name: "x" } }
Repeated elements become arrays inconsistently. Covered above and worth repeating because it is the single most common bug in this area: one occurrence gives an object, two give an array. Set the library's "always array" option for every element you intend to iterate.
Mixed content has no clean representation. <p>Hello <b>world</b>!</p> interleaves text and elements. Converted to an object, the text fragments and their positions relative to the child element are difficult to represent, and most libraries either concatenate the text or drop parts of it. If your XML contains prose markup, object conversion is the wrong approach — keep the DOM.
Order is not guaranteed. JSON object keys have no defined order in the data model, so a document where element sequence carries meaning loses that information. Arrays preserve order; sibling elements of different names do not.
And everything becomes a string unless you ask otherwise. XML has no types, so <price>42.50</price> is text. Most libraries offer numeric coercion, which is convenient and will happily turn a leading-zero product code into a number and a version string into a float. For anything that is an identifier rather than a quantity, turn coercion off.
The practical guidance: object conversion is right for data-shaped XML — feeds, catalogues, configuration, API responses — where elements are records and fields. Keep a DOM for document-shaped XML, where markup is embedded in prose and structure carries meaning.
Security: XXE and Injection
Two distinct risks, both real.
XML External Entity processing. XML can declare entities that reference external resources, including local files and network URLs. A parser that resolves them can be made to read files from the server or make requests on an attacker's behalf. This is a classic and still-common vulnerability class.
The mitigation is to disable external entity and DTD processing in whatever parser you use. Browser DOMParser does not resolve external entities, so the browser case is safe by default. Node libraries vary, and this is worth checking rather than assuming — if you are parsing XML from an untrusted source in Node, confirm your parser's entity handling before shipping.
Injection when re-inserting into the DOM. MDN warns that parseFromString "is an 'injection sink' and a potential vector for XSS attacks if the input comes from an attacker". The nuance matters: with text/html, "<script> elements are marked as non-executable and event handlers aren't called" — but scripts "will run if the parsed document is later injected into the visible DOM".
So parsing is safe; inserting the result is not. MDN's recommendations are to pass TrustedHTML objects rather than strings, enforce trusted types via the CSP require-trusted-types-for directive, and sanitise with a library such as DOMPurify through a TrustedTypePolicy.
The simple rule: never insert parsed untrusted markup into the live DOM without sanitising it, and prefer textContent over innerHTML when you only need the text.
Practical Patterns
Parsing an RSS or Atom feed:
const doc = parseXml(await res.text());
const items = [...doc.getElementsByTagNameNS("*", "item")].map(item => ({
title: item.getElementsByTagNameNS("*", "title")[0]?.textContent?.trim(),
link: item.getElementsByTagNameNS("*", "link")[0]?.textContent?.trim(),
date: item.getElementsByTagNameNS("*", "pubDate")[0]?.textContent?.trim(),
}));
The wildcard namespace handles both RSS and Atom without branching, and the optional chaining handles feeds with missing fields — which is most of them.
Parsing a sitemap, including index files:
const doc = parseXml(xml);
const isIndex = doc.documentElement.localName === "sitemapindex";
const locs = [...doc.getElementsByTagNameNS("*", "loc")].map(n => n.textContent.trim());
// if isIndex, these are sitemap URLs to fetch; otherwise they are page URLs
Checking localName on the document element is the reliable way to distinguish the two, since both contain <loc> elements and only the wrapper differs.
Handling a large document — use a streaming parser rather than building a DOM:
import sax from "sax";
const stream = sax.createStream(true, { trim: true });
let current = null;
stream.on("opentag", node => { if (node.name === "loc") current = ""; });
stream.on("text", t => { if (current !== null) current += t; });
stream.on("closetag", name => { if (name === "loc") { emit(current); current = null; } });
Constant memory regardless of document size, at the cost of writing a small state machine.
People Also Ask
How do I parse XML in JavaScript?
In a browser, use the built-in DOMParser: new DOMParser().parseFromString(xml, "application/xml") returns a Document you can query with DOM methods. In Node there is no built-in parser, so you install one — fast-xml-parser for object conversion, @xmldom/xmldom for a real DOM.
Why does DOMParser not throw on invalid XML?
By design. Instead of an exception, the returned document contains a <parsererror> node describing the failure. You have to check for it explicitly with doc.querySelector("parsererror"), or a malformed document will silently produce empty query results.
Does Node.js have a built-in XML parser?
No. Unlike JSON, XML requires a dependency. The widely used options are fast-xml-parser and xml2js for converting to objects, @xmldom/xmldom for a DOM implementation, and sax for streaming very large documents.
Why does my XML selector find nothing?
Usually namespaces. A document declaring xmlns puts every element in that namespace, and a plain getElementsByTagName may not match. Use getElementsByTagNameNS with the namespace URI, or "*" as a wildcard, or configure your library to ignore namespaces.
How do I use XPath with XML in JavaScript?
In browsers, document.evaluate with an XPathResult type — verbose enough to wrap once. In Node, the xpath package paired with @xmldom/xmldom. Note that browsers implement XPath 1.0 only, and namespaced documents need a resolver function mapping prefixes to URIs.
Is parsing XML in JavaScript a security risk?
Two risks. XML External Entity processing can make a parser read local files or issue requests — browser DOMParser does not resolve external entities, but Node libraries vary and should be checked. And inserting parsed untrusted markup into the live DOM can execute scripts, so sanitise before insertion.
How do I parse a very large XML file?
Use a streaming parser such as sax, which emits events as it reads rather than building a document in memory. This handles files of any size in constant memory, at the cost of writing a small state machine to track where you are.
Why does my parsed XML sometimes give an array and sometimes an object?
Because object-mapping libraries produce an array only when an element repeats. A feed with three items gives you an array; the same feed with one item gives you an object. Most libraries have an option to always produce arrays for named elements — turn it on for anything you iterate.
Wrapping Up
The environment decides most of this. In a browser you have DOMParser and no dependency; in Node you pick a library, and the pick shapes how you write everything downstream.
Two behaviours account for most of the time people lose. DOMParser reports failures with a parsererror node instead of an exception, so a malformed document yields empty results that look like a selector problem — check for that node, and log the first 200 characters of the input, because it is usually an HTML error page. And namespaces silently break plain tag-name lookups on exactly the documents you most want to parse: sitemaps, feeds and SOAP responses all declare them.
Beyond that, match the tool to the size. Object mapping for ordinary documents, a real DOM when you need XPath or shared browser and server code, and a streaming parser when the file is too large to hold. And if the source is untrusted, check your Node parser's external entity handling before you ship — that one has been a vulnerability class for two decades and still is.
