DOMParser Silently Fails on Invalid XML — How to Actually Detect It
Most parsers in JavaScript follow the same contract: feed them something broken, they throw. JSON.parse() does it. new URL() does it. atob() does it. DOMParser does not. Call new DOMParser().parseFromString(brokenXml, "text/xml") on malformed XML and nothing throws — you get back a real Document object, your try/catch block never runs, and unless you go looking for it, the error is invisible until something downstream chokes on the garbage data. This walkthrough covers exactly what DOMParser does instead of throwing, the one check that reliably catches it, and a check that looks correct but silently misses more than half of real-world cases — every result verified live in this browser's own DOMParser, with a working example in XML ↔ JSON Converter.
Document, and on failure that document contains a <parsererror> element somewhere in its tree describing what went wrong. The only reliable way to detect a parse failure is to check for that element after every call: doc.getElementsByTagName("parsererror").length > 0. Checking doc.documentElement.nodeName alone is not enough — verified below, it misses real errors.
Quick reference
Every row below was produced by pasting the exact input into this browser's own DOMParser and reading back the real result — not assumed from documentation. All eight cases share one thing: none of them threw an exception.
| Input | What Chromium's parsererror says | Root element after parsing |
|---|---|---|
Unclosed tag: <root><child></root> | Opening and ending tag mismatch: child line 1 and root | Stays root — naive check misses this |
Mismatched tags: <a><b></a></b> | Opening and ending tag mismatch: b line 1 and a | Stays a — naive check misses this |
Two root elements: <a></a><b></b> | Extra content at the end of the document | Stays a — naive check misses this |
Unescaped &: <a>Tom & Jerry</a> | xmlParseEntityRef: no name | Stays a — naive check misses this |
Empty string: "" | Document is empty | Becomes html |
JSON passed by mistake: {"a": 1} | Start tag expected, '<' not found | Becomes html |
Unquoted attribute: <a id=5> | AttValue: " or ' expected | Becomes html |
1. What actually happens instead of throwing
The DOM spec's parseFromString() is defined to never fail loudly. On success it returns a Document matching your input. On failure it also returns a Document — just one whose tree includes a <parsererror> element with a human-readable description of the problem, rendered the way a browser would render an error page. Code that expects an exception and never checks the return value keeps running as if parsing succeeded.
const parser = new DOMParser();
const doc = parser.parseFromString("<root><child></root>", "text/xml");
// No exception was thrown. This line runs.
console.log("Parsed fine, apparently");
// But the tree actually contains an error:
doc.getElementsByTagName("parsererror")[0]?.textContent;
// "This page contains the following errors:
// error on line 1 at column 21: Opening and ending tag mismatch:
// child line 1 and root
// Below is a rendering of the page up to the first error."
This is by design, not a bug — DOMParser predates widespread use of exceptions for this kind of failure in the DOM, and every major engine (Chromium, WebKit, Gecko) still follows the same never-throw contract today.
See it fail live — no error thrown, but the tool still catches it
Paste this unclosed-tag XML into XML ↔ JSON Converter and click Convert. It doesn't crash and it doesn't silently produce garbage — it shows a clear error, because the tool checks for parsererror internally:
2. The check that actually works
MDN's own recommendation, and the one that held up against every case tested here, is a single line run after every parse:
function parseXml(xmlString) {
const doc = new DOMParser().parseFromString(xmlString, "text/xml");
const errorNode = doc.getElementsByTagName("parsererror")[0];
if (errorNode) {
throw new Error("Invalid XML: " + errorNode.textContent);
}
return doc;
}
This is exactly the check this site's own XML ↔ JSON Converter uses internally before attempting to convert anything — it's why the example above shows a clean error instead of trying to convert a half-parsed tree into JSON.
getElementsByTagName("parsererror") matches by tag name alone, so it also matches a user-authored element genuinely named <parsererror> in otherwise-valid XML — parser.parseFromString("<root><parsererror>status</parsererror></root>", "text/xml") parses without error, but the check above still reports it as invalid. The browser's own error node lives in a specific namespace (Chromium and WebKit use http://www.w3.org/1999/xhtml), so the collision-proof version scopes the lookup to it: doc.getElementsByTagNameNS("http://www.w3.org/1999/xhtml", "parsererror") — confirmed live to return 0 for the same input that trips the unscoped check. Worth the extra specificity if your input schema could ever legitimately contain a parsererror element; the plain tag-name version is fine otherwise.
3. The check that looks right but silently fails
A pattern that shows up in a lot of older answers checks the parsed document's root element directly instead of searching for parsererror:
// This looks reasonable. It is not reliable.
function isValidXml(xmlString) {
const doc = new DOMParser().parseFromString(xmlString, "text/xml");
return doc.documentElement.nodeName !== "parsererror";
}
<parsererror> as one of its children — it does not replace the root. doc.documentElement.nodeName for "<root><child></root>" is still "root", not "parsererror", so isValidXml() above returns true for input that actually failed to parse. It only happens to work for the subset of errors severe enough that Chromium discards the root entirely and substitutes an <html> wrapper — an empty string, a value that doesn't start with < at all, a missing quote on the very first attribute. Four of the seven cases in the reference table above — the ones involving a structurally recognizable but still-broken document — pass this check when they shouldn't.
The safe version doesn't inspect the root at all; it searches the whole tree for parsererror, the same way the working check in section 2 does. If you're maintaining code with the documentElement.nodeName pattern today, it's worth swapping it out — it isn't a style preference, it's a real gap that lets malformed XML through undetected.
4. Why this matters more than it seems
The failure mode isn't a crash — it's silent data corruption downstream. Code that walks the parsed tree assuming it's well-formed will either throw a confusing, unrelated error several function calls later (a TypeError on a property that doesn't exist, because the tree shape isn't what was expected), or worse, quietly extract wrong values from whatever fragment did parse before the error. Neither failure mode points back to the actual problem: the XML was invalid, and nothing said so at the time.
Debugging checklist
- After every
parseFromString()call, checkdoc.getElementsByTagName("parsererror").length > 0— never rely on the absence of a thrown exception as proof the XML was valid. - If you're using
doc.documentElement.nodeName === "parsererror"anywhere in an existing codebase, replace it — verified above, it misses a majority of real structural errors. - Read
parsererror.textContentfor a human-readable description before deciding how to handle the failure; it names the exact line and column. - Remember this applies to
text/xmlandapplication/xmlparsing specifically —text/htmlparsing throughDOMParserhas separate, much more forgiving error-recovery behavior and generally won't produce aparsererrorat all.
Related to this: if your data is JSON rather than XML, the equivalent silent-failure trap doesn't exist — JSON.parse() throws reliably on malformed input, covered in Unexpected Token in JSON. For a broader look at when XML still makes sense over JSON in the first place, see the JSON vs YAML vs XML comparison guide.
🔄 Paste your XML and see exactly what's wrong
XML ↔ JSON Converter checks for parse errors correctly before converting anything, and now accepts a ?input= deep link so you can share a broken example directly. Everything runs locally in your browser.