perf: use createElement instead of createElementNS for HTML elements#18262
Conversation
`createElement(tag)` hits a fast path in Blink that skips the namespace lookup `createElementNS` always performs. Measured ~89% faster in headless Chromium (~634k → ~1.2M ops/sec) on the new browser bench harness (sveltejs#18261). No measurable change in JSDOM. Affects every place Svelte constructs a DOM element internally: `<svelte:element>`, `run_scripts`, the per-component `<style>` injector, the `{@html}` wrapper, and the `<template>` element used to clone string templates. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 14a137a The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Mirrors what the HTML branch does. The trailing `undefined` argument to `createElementNS` hits a measurably slower path in V8/Blink than the bare 2-arg form (~10% slower for SVG creation in the benches in sveltejs#18261). SVG/MathML creation is much colder than HTML, but the fix is one line and makes the wrapper symmetric across both namespaces. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The why is in the PR description and commit history; the code is short enough that it doesn't need an inline narrative. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dummdidumm
left a comment
There was a problem hiding this comment.
I vaguely remember we had it similar to this before but then Rich (?) simplified it to the existing code. I would be ok with making it more elaborate again (but we need a comment explaining this is due to perf reasons)
Per @dummdidumm's review: prevent future simplification by documenting that the branched form exists for perf reasons (Blink's createElement fast path + the trailing-undefined penalty on both APIs). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
added a comment now |
Rich-Harris
left a comment
There was a problem hiding this comment.
hard to argue with those numbers
This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated. # Releases ## svelte@5.56.0 ### Minor Changes - feat: allow declarations in the template ([#18282](#18282)) ### Patch Changes - perf: use `createElement` instead of `createElementNS` for HTML elements ([#18262](#18262)) - perf: store `current_sources` as a `Set` for O(1) membership checks ([#18278](#18278)) - perf: deduplicate identical hoisted templates within a component ([#18320](#18320)) - perf: hoist `rest_props` exclude list as a module-scope `Set` ([#18252](#18252)) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Summary
The current wrapper always calls
document.createElementNS(namespace ?? NAMESPACE_HTML, tag, options)— even for HTML elements (the >99% case), and even whenoptionswould beundefined. Two effects compound:createElement— Blink has a fast path that skips the namespace lookupcreateElementNSalways performs.undefinedargument — V8/Blink take a slower path forcreateElementNS(ns, tag, undefined)(andcreateElement(tag, undefined)) than for the bare 2-arg form. This applies symmetrically to the SVG/MathML branch, where the wrapper now also avoids theundefined3rd arg.The wrapper dispatches to the fastest call shape for every input —
{HTML, non-HTML}×{with is, without is}× noundefinedever.Affects
Every place Svelte constructs a DOM element internally:
<svelte:element>,run_scripts, the per-component<style>injector, the{@html}wrapper, and the<template>element used to clone string templates.Numbers
Measured in headless Chromium (Chromium 145) using the browser bench harness from #18261, 2–3 runs, median. Lower per-call is better; higher hz is better.
Raw call shapes (what each shape costs in the browser)
createElement(tag)createElement(tag, undefined)createElementNS(NS_HTML, tag)createElementNS(NS_HTML, tag, undefined)createElement(tag, { is })createElementNS(SVG_NS, tag)createElementNS(SVG_NS, tag, undefined)createElementNS(SVG_NS, tag, { is })Two stable effects fall out:
undefinedis consistently slower than the bare form — ~26% oncreateElement, ~31% oncreateElementNS(HTML), ~10% oncreateElementNS(SVG).createElementskips a namespace lookup thatcreateElementNSalways performs — ~32% delta for equal-shape calls (createElement(tag)vscreateElementNS(NS_HTML, tag)).Per-case impact of this PR
is)is(dominant path)createElementNS(NS_HTML, tag, undefined)createElement(tag)iscreateElementNS(NS_HTML, tag, { is })createElement(tag, { is })iscreateElementNS(ns, tag, undefined)createElementNS(ns, tag)iscreateElementNS(ns, tag, { is })No measurable change in JSDOM (both shapes route through the same JS implementation there).
The headline gain — ~92% on the dominant HTML-no-
ispath — combines both effects roughly equally: dropping theundefined(~46%) and switching tocreateElement(~32%).Wrapper-vs-raw was also verified: the new wrapper measured to within ±2% of raw
document.createElement(tag)for the HTML-no-iscase, so the function-call indirection adds no measurable overhead.See #18261 for the benchmark and methodology.
Test plan
<svelte:element xmlns={null}>correctly falls back to HTML (covered by existingdynamic-element-dynamic-namespacetest)createElementNSisoption still honoured on both branches🤖 Generated with Claude Code