Every web app that accepts user HTML has the same dependency buried somewhere in its node_modules: DOMPurify. It's about 14KB gzipped, it works, and for the last decade it's been the only sane option for preventing cross-site scripting when you need to render untrusted markup. That era is ending — slowly.
Chrome 146 (stable March 2026) and Firefox 148 (February 2026) now ship the Sanitizer API natively. Two methods — setHTML() and parseHTML() — handle the same job your favorite sanitization library does, with zero bundle cost and a fundamentally different architecture under the hood.
What the API actually looks like
The old pattern everyone knows:
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(dirtyHTML);
element.innerHTML = clean;
Two steps: sanitize a string, then parse that string into the DOM. The browser processes the markup twice — once inside DOMPurify (via a detached document), and again when you assign to innerHTML. It's wasteful, and it's been the best we had.
The new approach:
element.setHTML(dirtyHTML);
One line. The browser parses the markup once, strips dangerous content during that single pass, and inserts the result directly into the target element. No intermediate string bouncing around. No double-parsing penalty.
By default, setHTML() strips <script>, <iframe>, <embed>, <object>, event handlers like onclick and onload, and other known XSS vectors. You don't configure anything — the safe defaults just work.
Customizing what gets through
The default behavior is opinionated, which is exactly what you want from a security primitive. But if you need to allow specific elements or attributes, pass a Sanitizer instance:
const sanitizer = new Sanitizer({
elements: ['p', 'b', 'i', 'a', 'img'],
attributes: ['href', 'src', 'alt', 'class']
});
element.setHTML(userContent, { sanitizer });
You either allowlist (elements, attributes) or blocklist (removeElements, removeAttributes), but never both in the same config object. That's an intentional constraint to keep behavior unambiguous — no wrestling with precedence rules.
The critical design decision: even with custom configuration, setHTML() never permits script-executing content. You can write elements: ['script'] in your config all day long — the safe method ignores it. The security baseline is baked into the browser engine, not your application code.
If you genuinely need to bypass that baseline (and you almost certainly don't), there's setHTMLUnsafe(). The name is the warning label.
How it stacks up against DOMPurify
| DOMPurify | Native Sanitizer | |
|---|---|---|
| Bundle size | ~14KB gzipped | 0 (built-in) |
| Parse cycles | 2 (sanitize string → innerHTML) | 1 (parse + clean + insert) |
| Config granularity | Very high (hooks, URI schemes, custom rules) | Moderate (allow/remove lists) |
| Shadow DOM | Partial | First-class via shadowRoot.setHTML() |
| Browser coverage | Everything | Chrome 146+, Firefox 148+ |
| Safari | Yes | Not yet |
That last row is the whole problem.
DOMPurify gives you hooks to intercept nodes mid-sanitization, custom URI scheme handling, and fine-grained control accumulated over a decade of edge cases. The browser's version covers roughly 80% of real-world use cases with stronger security guarantees — but it doesn't touch the weird corners that mature libraries have workarounds for.
Safari is the blocker
WebKit's team has expressed "positive interest" in the specification. That's WebKit-speak for "we like it, don't ask when." No implementation work has started as of April 2026.
For public-facing sites, you're stuck with feature detection and a fallback:
if ('Sanitizer' in window) {
target.setHTML(untrusted);
} else {
target.innerHTML = DOMPurify.sanitize(untrusted);
}
Which means you're still bundling DOMPurify. The weight savings evaporate when both code paths need to coexist. This isn't a migration — it's a conditional optimization.
Three scenarios where the native version wins right now
Electron and Chrome Extensions. You control the rendering engine. Rip out the npm package, call setHTML(), ship it. This is the cleanest win available today. If your app already locks to Chromium, there's no reason to keep a userland sanitizer around.
Progressive enhancement in existing apps. Feature-detect the native API. When it's available, skip the library's parse step and get a small performance bump. When it's not, fall back to what you already ship. Users on modern Chrome and Firefox get slightly faster rendering; everyone else notices nothing. Not earth-shattering, but free.
Internal tools and B2B dashboards. Plenty of enterprise products already exclude Safari from their support matrix. If that's your situation, the built-in sanitizer is strictly better: one parse, zero dependencies, a security model maintained by a browser vendor's security team rather than a community-maintained package.
What I'd actually do today
Don't rip DOMPurify out of an existing production app. Not yet. The payoff isn't there when Safari still needs the fallback, and DOMPurify has been battle-tested for edge cases that the native API hasn't faced at scale.
But if you're starting a new project — particularly one inside Electron, a Chrome extension, or targeting a known browser environment — reach for setHTML() first. The API surface is tiny. The security defaults are better than what most developers configure manually. And when Safari eventually ships their implementation, you delete one if branch and move on with your life.
The browser keeps slowly absorbing the ecosystem's greatest hits. fetch() replaced $.ajax. structuredClone() replaced Lodash's deep clone. The Temporal API is retiring Moment and date-fns. Now setHTML() is coming for your sanitization library. The pattern is always the same: the native version is less configurable, more secure, and arrives about three years after everyone needed it.