Skip to content

perf: use createElement instead of createElementNS for HTML elements#18262

Merged
Rich-Harris merged 5 commits into
sveltejs:mainfrom
MathiasWP:perf-create-element-fast-path
May 28, 2026
Merged

perf: use createElement instead of createElementNS for HTML elements#18262
Rich-Harris merged 5 commits into
sveltejs:mainfrom
MathiasWP:perf-create-element-fast-path

Conversation

@MathiasWP

@MathiasWP MathiasWP commented May 21, 2026

Copy link
Copy Markdown
Contributor

Summary

The current wrapper always calls document.createElementNS(namespace ?? NAMESPACE_HTML, tag, options) — even for HTML elements (the >99% case), and even when options would be undefined. Two effects compound:

  1. Route HTML elements through createElement — Blink has a fast path that skips the namespace lookup createElementNS always performs.
  2. Omit the trailing undefined argument — V8/Blink take a slower path for createElementNS(ns, tag, undefined) (and createElement(tag, undefined)) than for the bare 2-arg form. This applies symmetrically to the SVG/MathML branch, where the wrapper now also avoids the undefined 3rd arg.

The wrapper dispatches to the fastest call shape for every input — {HTML, non-HTML} × {with is, without is} × no undefined ever.

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)

hz per-call
createElement(tag) ~1,210k ~0.83 µs
createElement(tag, undefined) ~901k ~1.11 µs
createElementNS(NS_HTML, tag) ~913k ~1.10 µs
createElementNS(NS_HTML, tag, undefined) ~630k ~1.59 µs
createElement(tag, { is }) ~484k ~2.07 µs
createElementNS(SVG_NS, tag) ~333k ~3.01 µs
createElementNS(SVG_NS, tag, undefined) ~303k ~3.30 µs
createElementNS(SVG_NS, tag, { is }) ~245k ~4.08 µs

Two stable effects fall out:

  • Trailing undefined is consistently slower than the bare form — ~26% on createElement, ~31% on createElementNS (HTML), ~10% on createElementNS (SVG).
  • createElement skips a namespace lookup that createElementNS always performs — ~32% delta for equal-shape calls (createElement(tag) vs createElementNS(NS_HTML, tag)).

Per-case impact of this PR

Case (namespace, is) Old wrapper call Old hz New wrapper call New hz Speedup
HTML, no is (dominant path) createElementNS(NS_HTML, tag, undefined) ~630k createElement(tag) ~1,210k ~92% (1.92×)
HTML, with is createElementNS(NS_HTML, tag, { is }) (slow) createElement(tag, { is }) ~484k similar to above (just the NS-skip)
SVG/MathML, no is createElementNS(ns, tag, undefined) ~303k createElementNS(ns, tag) ~333k ~10%
SVG/MathML, with is createElementNS(ns, tag, { is }) ~245k same ~245k no change

No measurable change in JSDOM (both shapes route through the same JS implementation there).

The headline gain — ~92% on the dominant HTML-no-is path — combines both effects roughly equally: dropping the undefined (~46%) and switching to createElement (~32%).

Wrapper-vs-raw was also verified: the new wrapper measured to within ±2% of raw document.createElement(tag) for the HTML-no-is case, so the function-call indirection adds no measurable overhead.

See #18261 for the benchmark and methodology.

Test plan

  • All 5881 runtime tests still pass (runtime-runes + runtime-legacy)
  • <svelte:element xmlns={null}> correctly falls back to HTML (covered by existing dynamic-element-dynamic-namespace test)
  • SVG/MathML namespaces still go through createElementNS
  • Custom-element is option still honoured on both branches

🤖 Generated with Claude Code

`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-bot

changeset-bot Bot commented May 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 14a137a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
svelte Patch

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

MathiasWP and others added 3 commits May 21, 2026 14:56
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 dummdidumm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@MathiasWP

Copy link
Copy Markdown
Contributor Author

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)

added a comment now

@Rich-Harris Rich-Harris left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hard to argue with those numbers

@Rich-Harris Rich-Harris merged commit a991605 into sveltejs:main May 28, 2026
16 of 17 checks passed
@github-actions github-actions Bot mentioned this pull request May 28, 2026
Rich-Harris pushed a commit that referenced this pull request May 29, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants