Benchmarks

How fast each release of @tradik/xslt-processor is, and how the XSLT 3.0 engine @tradik/xslt3 compares, on the same stylesheets and the same machine. Start with All versions: one chart and one table with every version. The sections after it are the detailed runs those numbers come from.

Contents

All versions

Against 1.1.3, on the same 9 XSLT 1.0 stylesheets (geometric mean): 1.2.0 1.40× faster · 1.3.0 1.49× faster · xslt3 1.0.0 2.89× faster.

Every version runs the same XSLT 1.0 stylesheets on jsdom. Lower is faster; the fastest version of each row is in bold. 1.2.1 changed no library code, so 1.2.0 stands for both.

Median time of 9 XSLT 1.0 scenarios on 1.1.3, 1.2.0, 1.3.0, xslt3 1.0.0; further left is faster.
Scenario1.1.31.2.01.3.0xslt3 1.0.0
100 MB text result7.14 s3.94 s3.93 s824 ms
Issue #9 catalogue (3 MB HTML)3.73 s2.83 s2.68 s2.67 s
Identity transform, 5 MB2.88 s2.13 s2.10 s2.02 s
Sort 20,000 by two keys548 ms407 ms411 ms166 ms
apply-templates item[@id], 8,000240 ms178 ms157 ms103 ms
following-sibling::x[1], 8,000168 ms113 ms106 ms23 ms
Muenchian grouping, 8,000157 ms144 ms134 ms138 ms
xsl:number level="any", 8,000153 ms96 ms83 ms34 ms
call-template depth 3,000error62 ms58 ms15 ms
Speed vs 1.1.3 (geometric mean)1.00×1.40×1.49×2.89×

Peak memory (maximum resident set size of the process, including Node.js, jsdom and the parsed documents):

Scenario1.1.31.2.01.3.0xslt3 1.0.0
100 MB text result1557 MB650 MB659 MB287 MB
Issue #9 catalogue (3 MB HTML)922 MB636 MB661 MB614 MB
Identity transform, 5 MB993 MB658 MB666 MB702 MB
Sort 20,000 by two keys326 MB307 MB308 MB292 MB
apply-templates item[@id], 8,000297 MB277 MB278 MB260 MB
following-sibling::x[1], 8,000279 MB260 MB258 MB208 MB
Muenchian grouping, 8,000267 MB264 MB262 MB282 MB
xsl:number level="any", 8,000269 MB239 MB230 MB243 MB
call-template depth 3,000error217 MB215 MB192 MB

The numbers come from two runs on the same machine, Node.js and jsdom: 1.1.3 and 1.2.0 from the 1.1.3 vs 1.2.0 run (30 September 2026), 1.3.0 and @tradik/xslt3 from the XSLT engines run (2 October 2026). The first times stylesheet import and transformation together; for the second the table adds its compile and transform medians. Differences of a few percent between the two runs are noise, not a change.

1.1.3 vs 1.2.0

How much faster and leaner 1.2.0 is than the released 1.1.3 on the same inputs: speed-up per scenario, time per scenario and peak memory, each chart followed by its data table.

1.81× faster on 100 MB text result, string · 1.39× geometric mean over 10 scenarios · 58% less peak memory on 100 MB text result, string (1557 MB to 650 MB) · call-template depth 3,000: runs on 1.2.0, error on 1.1.3.

Method

  • Machine: AMD Ryzen 9 7950X 16-Core Processor, 32 logical cores, 31 GB RAM, Linux 6.18.40.1-microsoft-standard-WSL2 (linux)
  • Node.js 25.9.0; DOM: jsdom 30.1.1 (library runs and both CLIs), @xmldom/xmldom 0.9.12 (xmldom CLI row)
  • Runs: 2 warm-up + 7 measured per version and scenario, each pair in its own process; a run over 120 s counts as a timeout. Scenarios slower than 10 s per run use 1 warm-up + 3 runs
  • Recorded 2026-09-30; the whole run took 6.2 minutes

1.2.0 is the working tree; 1.1.3 is extracted from its git tag (git archive v1.1.3) into the system temporary directory and installed with npm ci --ignore-scripts. Every version and scenario runs in its own child process, so the versions never share a heap, a JIT or a module cache. Library scenarios parse the inputs once with jsdom and time new XSLTProcessor() + importStylesheet() + transformToString() (or reading transformToStream() to the end), with a garbage collection before each run. CLI scenarios time the whole xslt in.xml t.xsl -o out.html process, start-up included. Peak memory is the process's maximum resident set size (process.resourceUsage().maxRSS), so it includes Node.js, jsdom and the parsed documents. The inputs are generated deterministically by scripts/benchmark/inputs.mjs; nothing is downloaded.

Reproduce:

npm ci --ignore-scripts && npm run build
npm run bench                       # writes scripts/benchmark/results.json
node scripts/benchmark/charts.mjs   # redraws docs/benchmarks/*.svg and the tables below

npm run bench -- --runs 3 --only catalog,sort runs a subset. The charts and tables on this page are generated from scripts/benchmark/results.json.

Speed-up

1.2.0 is faster than 1.1.3 in 10 of 10 compared scenarios; the largest speed-up is 1.81× (100 MB text result, string).
Scenario1.1.3 median1.2.0 medianSpeed-up
100 MB text result, string7.14 s3.94 s1.81×
xsl:number level="any", 8,000153 ms96 ms1.59×
following-sibling::x[1], 8,000168 ms113 ms1.50×
Identity transform, 5 MB2.88 s2.13 s1.35×
apply-templates item[@id], 8,000240 ms178 ms1.35×
Sort 20,000 by two keys548 ms407 ms1.34×
Issue #9 catalogue (3 MB HTML)3.73 s2.83 s1.32×
CLI end to end, jsdom4.86 s3.70 s1.31×
call-template depth 90031 ms24 ms1.30×
Muenchian grouping, 8,000157 ms144 ms1.09×
call-template depth 3,000error62 msn/a
100 MB text result, stream1.2.0 only3.89 sn/a
CLI end to end, XSLT_DOM=xmldom1.2.0 only1.42 sn/a
Standalone binary start-up1.2.0 only28 msn/a

Time per scenario

Median wall time of each scenario for both versions; the slowest is 100 MB text result, string (7.14 s on 1.1.3).
Scenario1.1.3 medianp95minwarm-up + runs1.2.0 medianp95minwarm-up + runs
Issue #9 catalogue (3 MB HTML)3.73 s3.84 s3.67 s2 + 72.83 s2.88 s2.79 s2 + 7
apply-templates item[@id], 8,000240 ms253 ms227 ms2 + 7178 ms181 ms163 ms2 + 7
Muenchian grouping, 8,000157 ms179 ms149 ms2 + 7144 ms157 ms139 ms2 + 7
xsl:number level="any", 8,000153 ms178 ms147 ms2 + 796 ms99 ms90 ms2 + 7
following-sibling::x[1], 8,000168 ms173 ms159 ms2 + 7113 ms115 ms104 ms2 + 7
Sort 20,000 by two keys548 ms577 ms531 ms2 + 7407 ms432 ms401 ms2 + 7
Identity transform, 5 MB2.88 s2.92 s2.78 s2 + 72.13 s2.17 s2.08 s2 + 7
call-template depth 90031 ms33 ms28 ms2 + 724 ms25 ms23 ms2 + 7
call-template depth 3,000error62 ms77 ms60 ms2 + 7
100 MB text result, string7.14 s7.20 s7.02 s2 + 73.94 s3.96 s3.88 s2 + 7
CLI end to end, jsdom4.86 s4.89 s4.82 s2 + 73.70 s3.73 s3.65 s2 + 7
100 MB text result, stream1.2.0 only3.89 s3.95 s3.82 s2 + 7
CLI end to end, XSLT_DOM=xmldom1.2.0 only1.42 s1.43 s1.41 s2 + 7
Standalone binary start-up1.2.0 only28 ms30 ms25 ms2 + 7

Peak memory

Peak resident set size of the process of each scenario; the largest is 100 MB text result, string (1.1.3: 1557 MB, 1.2.0: 650 MB).
Scenario1.1.3 peak RSS1.2.0 peak RSSChange
Issue #9 catalogue (3 MB HTML)922 MB636 MB-31%
apply-templates item[@id], 8,000297 MB277 MB-7%
Muenchian grouping, 8,000267 MB264 MB-1%
xsl:number level="any", 8,000269 MB239 MB-11%
following-sibling::x[1], 8,000279 MB260 MB-7%
Sort 20,000 by two keys326 MB307 MB-6%
Identity transform, 5 MB993 MB658 MB-34%
call-template depth 900181 MB185 MB+2%
call-template depth 3,000error217 MBn/a
100 MB text result, string1557 MB650 MB-58%
CLI end to end, jsdom921 MB627 MB-32%
100 MB text result, stream1.2.0 only550 MBn/a
CLI end to end, XSLT_DOM=xmldom1.2.0 only453 MBn/a
Standalone binary start-up1.2.0 onlynot measuredn/a

Scenarios

ScenarioWhat it does
Issue #9 catalogue10,500 courses grouped by category with keys (Muenchian), sorted by title, labels looked up by key from localised strings, format-number(), (a) or (b) tests as in #9; 3 MB of HTML
apply-templates8,000 items, every second one matched by match="item[@id]", the others by match="item"
Muenchian grouping8,000 items in 200 groups: generate-id() = generate-id(key(...)[1]), count() and sum() over each group
xsl:number level="any"8,000 elements numbered with count="i[@k='1']"
following-sibling::x[1]A loop over 8,000 siblings reading the next one
Sort20,000 items by a text key, then a numeric key descending
Identity transform@*|node() copied recursively through a 5 MB document
call-template depthRecursive named template, 900 and 3,000 nested template instantiations (the root template included)
100 MB text result100 million characters of method="text" output, returned as one string, and in 1.2.0 also read from transformToStream()
CLI end to endxslt in.xml t.xsl -o out.html on the catalogue, with jsdom; 1.2.0 also with XSLT_DOM=xmldom
Standalone binary start-upxslt --version with the host's single executable (1.2.0 only; built by make binaries when missing). Its memory is not measured: the executable ignores the --import hook used to read it

Notes and caveats

  • jsdom dominates wall time. Every DOM access of the engine crosses jsdom's wrappers; parsing the inputs is excluded from library runs but not from CLI runs. With XSLT_DOM=xmldom the same CLI transformation is much faster, see the CLI rows.
  • Numbers vary by machine. Compare factors, not absolute times, and rerun npm run bench on your own hardware; run-to-run spread is visible in the p95 and min columns.
  • What changed in 1.2.0 (see the CHANGELOG):
    • Serialization walks the result tree on an explicit stack and writes bounded chunks that are joined once (task 0024 and the chunked serializer of task 0010): the identity transform, the catalogue and the 100 MB text result are faster, and the 100 MB result needs well under half the peak memory of 1.1.3.
    • transformToStream() (task 0010) serializes on demand, so the consumer never holds the whole result as one string: the streamed 100 MB row peaks lower than the string row of the same version.
    • Templates run from an explicit work stack (task 0005): 3,000 nested templates work, where 1.1.3 stops at about 1,000 to 1,400 levels with Template recursion too deep.
    • Document order is computed once per transformation (DocumentOrderIndex, task 0007) instead of calling compareDocumentPosition for each comparison.
    • The CLI can use @xmldom/xmldom (task 0007), which starts faster and parses faster than jsdom. jsdom stays the default because it expands entities declared in an internal DTD subset and xmldom does not.
    • Sorting nodes in document order no longer reads every attribute's index in its element, and name tests read the document's content type only when it matters. Before that fix Muenchian grouping was the one scenario slower than 1.1.3 (about 0.88×); it is now faster too.
  • Most of the large algorithmic fixes (linear template matching, keys, xsl:number, axis::x[n]) already shipped in 1.1.2 and 1.1.3, so both versions here are within a small factor of each other on those scenarios; the gains of 1.2.0 are in serialization, memory, recursion depth and the CLI.

XPath 1.0 vs XPath 3.1

XPath 1.0 of this package (src/, parseXPath() / evaluateXPath()) against XPath 3.1 of @tradik/xslt3 (packages/xslt3, compileXPath() / evaluateXPath()), on the same expressions over the same DOM.

On jsdom, xslt3 is 3.21× faster than the 1.0 package (geometric mean of the time ratio over 11 scenarios; from 0.17× the time on (//item)[1000] to 0.67× on ancestor::* from 1,000 deep nodes) · on xmldom, xslt3 is 2.65× faster than the 1.0 package (geometric mean of the time ratio over 11 scenarios; from 0.18× the time on //item to 0.83× on translate/concat/substring × 20,000).

XPath method

  • Machine: AMD Ryzen 9 7950X 16-Core Processor, 32 logical cores, 31 GB RAM, Linux 6.18.40.1-microsoft-standard-WSL2 (linux)
  • Node.js 25.9.0; DOM: jsdom 30.1.1, @xmldom/xmldom 0.9.12
  • Engines: 1.0 package 1.3.0 (src/index.js), xslt3 1.0.0 (packages/xslt3/src/index.js)
  • Runs: 2 warm-up + 7 measured per phase (compiled, then one-shot), each scenario, DOM and engine in its own process; a run over 120 s counts as a timeout. Phases slower than 10 s per run use 1 warm-up + 3 runs
  • Recorded 2026-10-02; the whole run took 2.66 minutes

The document is generated by scripts/benchmark/xpathScenarios.mjs: 20,000 item elements (an id, a category, a price from 0.00 to 99.99 and a name; an a child in every second item, a b child in every third, a chain of 12 nested elements in every 20th), about 2.4 MB, parsed once per process with jsdom or @xmldom/xmldom (not timed). Each scenario is measured twice:

  • compiled: the expression is compiled once (parseXPath() in the 1.0 package, compileXPath() in xslt3), then each run evaluates it (a new XPathEvaluator and XPathContext per evaluation in the 1.0 package, as evaluateXPath() does; compiled.evaluate(node) in xslt3). Neither engine keeps state between evaluations, so each builds its own document order index;
  • one-shot: each run calls evaluateXPath(expression, node), which parses and evaluates.

Scenarios marked × 20,000 or "from 1,000 deep nodes" evaluate the expression once per element in a JavaScript loop, so they also measure the per-call cost. The 1.0 package caps a node-set at 10,000 nodes by default (XPathLimits.MAX_RESULT_SIZE, a guard for untrusted expressions) and evaluateXPath() takes no option for it, so the benchmark raises the exported limit to the XSLT engine's 5,000,000. Peak memory is the process's maximum resident set size, so it includes Node.js, the DOM and the parsed document; one process runs both phases.

Reproduce:

npm ci --ignore-scripts
npm run bench -- --suite xpath                # writes scripts/benchmark/results-xpath.json
node scripts/benchmark/charts.mjs --suite xpath   # redraws docs/benchmarks/xpath-*.svg and the tables below

npm run bench -- --suite xpath --dom jsdom --runs 3 --only sum,union runs a subset. Before measuring, a pre-check evaluates every scenario once per engine and DOM and stops the run when the engines disagree (item count and a hash of every item, nodes by name and id, numbers by value), or when an XPath 3.1-only expression fails; --allow-mismatch measures anyway and records the differences.

Pre-check

Both engines returned the same result (item count and a hash of every item) for every shared scenario on every DOM, and every xslt3-only expression evaluated without an error.

XPath time ratio

On jsdom, xslt3 is 1.49× faster than the 1.0 package on ancestor::* from 1,000 deep nodes, its largest time ratio (0.67×), and 5.90× faster on (//item)[1000] (0.17×); the dashed line marks equal speed.
ScenarioExpressionjsdomjsdom one-shotxmldomxmldom one-shot
ancestor::* from 1,000 deep nodesancestor::*0.67×0.49×0.55×0.43×
translate/concat/substring × 20,000concat(translate(name, 'abcdef', 'ABCDEF'), '-', substring(@id, 2, 3))0.59×0.42×0.83×0.51×
//a | //b//a | //b0.39×0.41×0.43×0.43×
//item[last()]//item[last()]0.36×0.34×0.44×0.48×
following-sibling::item[1] × 20,000following-sibling::item[1]0.30×0.31×0.36×0.28×
//item[contains(name, 'abc')]//item[contains(name, 'abc')]0.29×0.28×0.37×0.35×
count(//item[@price > 50])count(//item[@price > 50])0.27×0.26×0.36×0.35×
sum(//item/@price)sum(//item/@price)0.26×0.28×0.39×0.33×
//item[@id = 'i10000']//item[@id = 'i10000']0.25×0.26×0.41×0.40×
//item//item0.18×0.19×0.18×0.17×
(//item)[1000](//item)[1000]0.17×0.17×0.18×0.18×

XPath time on jsdom

Median time of each compiled expression on jsdom: xslt3 (XPath 3.1) is slower than the 1.0 package (XPath 1.0) in 0 of 11 scenarios.
Scenario1.0 packagexslt3xslt3 ÷ 1.01.0 one-shotxslt3 one-shotxslt3 ÷ 1.0 one-shot
//item123 ms23 ms0.18×116 ms23 ms0.19×
//item[@id = 'i10000']154 ms39 ms0.25×154 ms40 ms0.26×
//item[last()]82 ms29 ms0.36×85 ms29 ms0.34×
(//item)[1000]132 ms22 ms0.17×129 ms22 ms0.17×
count(//item[@price > 50])190 ms51 ms0.27×191 ms50 ms0.26×
sum(//item/@price)208 ms54 ms0.26×205 ms57 ms0.28×
//item[contains(name, 'abc')]178 ms52 ms0.29×179 ms50 ms0.28×
//a | //b204 ms79 ms0.39×202 ms83 ms0.41×
following-sibling::item[1] × 20,00068 ms21 ms0.30×106 ms33 ms0.31×
ancestor::* from 1,000 deep nodes11 ms7.2 ms0.67×17 ms8.2 ms0.49×
translate/concat/substring × 20,000188 ms111 ms0.59×281 ms117 ms0.42×

XPath time on xmldom

Median time of each compiled expression on xmldom: xslt3 (XPath 3.1) is slower than the 1.0 package (XPath 1.0) in 0 of 11 scenarios.
Scenario1.0 packagexslt3xslt3 ÷ 1.01.0 one-shotxslt3 one-shotxslt3 ÷ 1.0 one-shot
//item74 ms13 ms0.18×73 ms13 ms0.17×
//item[@id = 'i10000']61 ms25 ms0.41×61 ms24 ms0.40×
//item[last()]44 ms20 ms0.44×41 ms20 ms0.48×
(//item)[1000]69 ms13 ms0.18×76 ms14 ms0.18×
count(//item[@price > 50])94 ms33 ms0.36×92 ms32 ms0.35×
sum(//item/@price)95 ms38 ms0.39×98 ms32 ms0.33×
//item[contains(name, 'abc')]87 ms32 ms0.37×87 ms30 ms0.35×
//a | //b113 ms48 ms0.43×113 ms48 ms0.43×
following-sibling::item[1] × 20,00052 ms19 ms0.36×95 ms27 ms0.28×
ancestor::* from 1,000 deep nodes9.0 ms4.9 ms0.55×13 ms5.7 ms0.43×
translate/concat/substring × 20,00099 ms83 ms0.83×183 ms93 ms0.51×

XPath 3.1-only expressions

What XPath 1.0 cannot express at all, on xslt3 alone.

Median time of the compiled XPath 3.1 expressions that XPath 1.0 cannot express; the slowest is distinct-values grouping + sum (652 ms on jsdom).
ScenarioExpressionjsdomjsdom one-shotxmldomxmldom one-shot
for + let over //itemfor $x in //item return let $p := number($x/@price) return if ($p > 50) then string($x/@id) else ()64 ms65 ms47 ms43 ms
distinct-values grouping + sumfor $c in distinct-values(//item/@cat) return sum(//item[@cat = $c]/@price)652 ms704 ms564 ms574 ms
sort() with a key functionsort(//item, (), function($i) { number($i/@price) })78 ms80 ms64 ms65 ms
map:merge of 20,000 mapsmap:merge(//item ! map { string(@id): number(@price) })77 ms74 ms50 ms49 ms
string-join(//item/@id)string-join(//item/@id, ',')45 ms46 ms30 ms27 ms
//item[matches(name, ...)]//item[matches(name, '^[a-c]{3}')]54 ms53 ms31 ms32 ms
tokenize(string-join(//name))tokenize(string-join(//name, ' '), '\s+')39 ms38 ms28 ms26 ms
format-number × 20,000//item ! format-number(number(@price), '#,##0.00')119 ms125 ms97 ms95 ms
fold-left sum of //item/@pricefold-left(//item/@price, 0, function($a, $b) { $a + $b })61 ms61 ms38 ms36 ms

XPath peak memory

Scenario1.0 package, jsdomxslt3, jsdom1.0 package, xmldomxslt3, xmldom
//item364 MB312 MB279 MB263 MB
//item[@id = 'i10000']347 MB309 MB236 MB265 MB
//item[last()]347 MB320 MB273 MB264 MB
(//item)[1000]356 MB305 MB281 MB264 MB
count(//item[@price > 50])353 MB308 MB241 MB266 MB
sum(//item/@price)363 MB317 MB247 MB266 MB
//item[contains(name, 'abc')]352 MB312 MB245 MB264 MB
//a | //b360 MB322 MB272 MB270 MB
following-sibling::item[1] × 20,000343 MB322 MB284 MB280 MB
ancestor::* from 1,000 deep nodes341 MB309 MB251 MB278 MB
translate/concat/substring × 20,000361 MB335 MB292 MB287 MB
for + let over //itemn/a334 MBn/a283 MB
distinct-values grouping + sumn/a339 MBn/a286 MB
sort() with a key functionn/a308 MBn/a266 MB
map:merge of 20,000 mapsn/a332 MBn/a276 MB
string-join(//item/@id)n/a309 MBn/a265 MB
//item[matches(name, ...)]n/a310 MBn/a265 MB
tokenize(string-join(//name))n/a311 MBn/a264 MB
format-number × 20,000n/a328 MBn/a285 MB
fold-left sum of //item/@pricen/a319 MBn/a266 MB

XPath notes

  • On jsdom the engines are close; xslt3 is slower on most single expressions. Both spend most of their time walking jsdom's wrappers. A CPU profile of xslt3 shows two costs the 1.0 package avoids: childrenOf() iterates node.childNodes, and in jsdom every indexed childNodes access goes through a Proxy (the 1.0 package walks firstChild / nextSibling for exactly this reason, see src/xpath/axes.js); and //a | //b and //item[last()] number the whole tree, attributes included, for document order on every evaluation (DocumentOrder.index(), 16% of the union's profile). Typed atomic values (each untyped @price cast to xs:double, count() as an xs:integer BigInt) did not show up as a major cost in the profiles of count(//item[@price > 50]) and //item[last()]: the tree walk does.
  • xslt3 is faster on per-node loops. Its compiled evaluation of following-sibling::item[1] and ancestor::* costs less per call than a new XPathEvaluator and XPathContext; one-shot it is slower, because parsing an XPath 3.1 expression (a much larger grammar) costs more: translate/concat/substring compiled 20,000 times takes about twice as long as in the 1.0 package. Compile once and reuse the expression.
  • On xmldom the 1.0 package's standalone XPath API used to be the outlier. Before 1.3.0 every expression that returned or sorted a large node-set (//item, (//item)[1000], sum(//item/@price), //a | //b) took 7 to 33 s there, because XPathEvaluator sorted with xmldom's compareDocumentPosition, which is written in JavaScript and walks ancestors on every comparison. 1.3.0 numbers the tree once per evaluation unless the DOM's method is native (task 0036): the same expressions now take 60 to 115 ms with the 1.0 package and 13 to 50 ms with xslt3.
  • XPath 3.1-only expressions run in the same range as the shared ones. The slowest is distinct-values grouping written as a nested loop (50 categories × 20,000 items, 0.65 s on jsdom, 0.56 s on xmldom), which per-evaluation caching of descendant results (task 0037) brought down from 7.8 s; in XSLT 3.0 xsl:for-each-group or a map built in one pass avoids the nested loop altogether.
  • Same answers. Results are compared by count and by a hash of every item; numbers compare by value, so count() (a JS number in 1.0, an xs:integer BigInt in 3.1) and sum() (both IEEE doubles, added in document order) must agree to the last bit, and do.
  • Memory is dominated by the DOM: jsdom processes peak at 300 to 370 MB and xmldom processes at 230 to 290 MB with either engine.

XSLT 1.0 vs XSLT 3.0 engines

The XSLT 1.0 engine of this package (src/, XSLTProcessor) against the XSLT 3.0 engine of @tradik/xslt3 (packages/xslt3, compileStylesheet() + transform() + serialize()), in three groups:

  1. The same XSLT 1.0 stylesheets as the 1.1.3 vs 1.2.0 suite, run by both engines; they declare version="1.0", so xslt3 runs them in backwards-compatible mode.
  2. The same tasks rewritten idiomatically in XSLT 2.0/3.0, run by xslt3: is rewriting a 1.0 stylesheet worth it?
  3. XSLT 3.0-only tasks that 1.0 cannot express directly: grouping, regular expressions, iteration with state, JSON in and out, maps, higher-order functions, accumulators, merging.

On jsdom, xslt3 is 2.13× faster than the 1.0 package (geometric mean of the time ratio over 9 scenarios; from 0.21× the time on 100 MB text result to 1.02× on Muenchian grouping, 8,000) · on xmldom, xslt3 is 1.94× faster than the 1.0 package (geometric mean of the time ratio over 9 scenarios; from 0.16× the time on following-sibling::x[1], 8,000 to 1.13× on Muenchian grouping, 8,000) · rewritten in XSLT 2.0/3.0, the same tasks run 1.66× faster on xslt3 than their 1.0 stylesheets (geometric mean over 6 tasks on jsdom; 3.91× faster on Muenchian grouping, 8,000).

XSLT engines method

  • Machine: AMD Ryzen 9 7950X 16-Core Processor, 32 logical cores, 31 GB RAM, Linux 6.18.40.1-microsoft-standard-WSL2 (linux)
  • Node.js 25.9.0; DOM: jsdom 30.1.1, @xmldom/xmldom 0.9.12
  • Engines: 1.0 package 1.3.0 (src/index.js), xslt3 1.0.0 (packages/xslt3/src/index.js)
  • Runs: 2 warm-up + 7 measured per phase (compile, then transform), each scenario, DOM and engine in its own process; a run over 120 s counts as a timeout. Phases slower than 10 s per run use 1 warm-up + 3 runs
  • Recorded 2026-10-02; the whole run took 8.25 minutes

Inputs are generated deterministically, the 1.0 ones by scripts/benchmark/inputs.mjs (the same bytes as the 1.1.3 vs 1.2.0 suite), the rewrites by xsltRewrites.mjs and the 3.0-only ones by xslt30Inputs.mjs and xsltJson.mjs. Each worker process parses the source and the stylesheet once (not timed) with jsdom or @xmldom/xmldom, installed globally as the xslt CLI does, and measures two phases:

  • compile: new XSLTProcessor() + importStylesheet() in the 1.0 package, compileStylesheet() in xslt3;
  • transform: one transformation with the compiled stylesheet, including serialization to a string (transformToString() in the 1.0 package; transform() + serialize() with the stylesheet's output parameters in xslt3). The charts and the time tables show this phase.

Peak memory is the process's maximum resident set size, so it includes Node.js, the DOM and the parsed documents; one process runs both phases.

Reproduce:

npm ci --ignore-scripts
node scripts/benchmark/run.mjs --suite xslt        # writes scripts/benchmark/results-xslt.json
node scripts/benchmark/charts.mjs --suite xslt     # redraws docs/benchmarks/xslt-*.svg and the tables below

node scripts/benchmark/run.mjs --suite xslt --dom jsdom --runs 3 --only catalog,catalogRewrite runs a subset (the scenario ids are in xsltScenarios.mjs); --no-startup skips the bundle and import measurements.

XSLT engines pre-check

Before anything is timed, every scenario runs once per engine and DOM and the outputs are compared: both engines on each 1.0 stylesheet, and each rewrite against the 1.0 package's output of the original stylesheet; every 3.0-only stylesheet must run, and xslt3 must write the same output on every DOM. Two serializer defaults are removed before comparing, because they are not results: the line break the 1.0 package writes after the XML declaration (as libxslt does), and the <!DOCTYPE html> that XSLT 3.0 writes for the html output method (HTML5 by default). Anything else stops the run, unless --allow-mismatch records it.

Both engines wrote the same output for every XSLT 1.0 stylesheet on every DOM, every rewrite reproduced the 1.0 package's output of the original stylesheet, every XSLT 3.0-only stylesheet ran without an error, and xslt3 wrote the same output on every DOM.

XSLT 1.0 stylesheets on both engines

On jsdom, xslt3 is 1.02× slower than the 1.0 package on Muenchian grouping, 8,000, its largest time ratio (1.02×), and 4.77× faster on 100 MB text result (0.21×); the dashed line marks equal speed.
Scenario1.0 package, jsdomxslt3, jsdomxslt3 ÷ 1.0, jsdom1.0 package, xmldomxslt3, xmldomxslt3 ÷ 1.0, xmldom
Issue #9 catalogue (3 MB HTML)2.68 s2.66 s1.00×972 ms1.04 s1.07×
apply-templates item[@id], 8,000157 ms102 ms0.65×70 ms44 ms0.62×
Muenchian grouping, 8,000133 ms136 ms1.02×71 ms80 ms1.13×
xsl:number level="any", 8,00083 ms33 ms0.40×30 ms22 ms0.72×
following-sibling::x[1], 8,000106 ms23 ms0.21×51 ms7.9 ms0.16×
Sort 20,000 by two keys410 ms165 ms0.40×192 ms116 ms0.61×
Identity transform, 5 MB2.10 s2.02 s0.96×904 ms702 ms0.78×
call-template depth 3,00057 ms14 ms0.24×31 ms11 ms0.34×
100 MB text result3.93 s824 ms0.21×1.53 s293 ms0.19×

Rewritten in XSLT 2.0/3.0

Each task keeps its source document and must produce the same output; only the stylesheet changes:

TaskXSLT 1.0XSLT 2.0/3.0 rewrite
Catalogue, groupingMuenchian method: key() + generate-id()xsl:for-each-group group-by
Numberingxsl:number level="any" count="i[@k='1']"a running count in xsl:iterate
Sort by two keystwo xsl:sortsort() with a key function returning both keys
Identitythe identity templatexsl:mode on-no-match="shallow-copy"
Recursionxsl:call-template calling itself 3,000 deepxsl:iterate
Median time of each task on jsdom: the 1.0 package and xslt3 on the XSLT 1.0 stylesheet, and xslt3 on the idiomatic XSLT 2.0/3.0 rewrite; the largest gain is on Muenchian grouping, 8,000, where the rewrite (xsl:for-each-group) is 3.91× faster than the 1.0 stylesheet on xslt3.
TaskRewrite uses1.0 package, jsdomxslt3 1.0 stylesheet, jsdomxslt3 rewrite, jsdom1.0 package, xmldomxslt3 1.0 stylesheet, xmldomxslt3 rewrite, xmldom
Issue #9 catalogue (3 MB HTML)xsl:for-each-group2.68 s2.66 s1.74 s972 ms1.04 s647 ms
Muenchian grouping, 8,000xsl:for-each-group133 ms136 ms35 ms71 ms80 ms19 ms
xsl:number level="any", 8,000xsl:iterate83 ms33 ms27 ms30 ms22 ms15 ms
Sort 20,000 by two keyssort() with a key410 ms165 ms152 ms192 ms116 ms92 ms
Identity transform, 5 MBon-no-match2.10 s2.02 s1.70 s904 ms702 ms500 ms
call-template depth 3,000xsl:iterate57 ms14 ms6.4 ms31 ms11 ms2.9 ms

XSLT 3.0-only scenarios

Median time of the XSLT 3.0 scenarios that XSLT 1.0 cannot express directly; the slowest is json-to-xml, 5 MB JSON (3.05 s on jsdom).
Scenariojsdomjsdom compilexmldomxmldom compile
group-adjacent, 50,000216 ms1.0 ms91 ms0.8 ms
analyze-string, 5 MB log409 ms0.9 ms184 ms0.6 ms
xsl:iterate totals, 100,000384 ms1.0 ms250 ms0.8 ms
json-to-xml, 5 MB JSON3.05 s1.0 ms1.10 s0.7 ms
parse-json, 5 MB JSON286 ms0.9 ms272 ms0.7 ms
xml-to-json, 5 MB JSON1.69 s0.7 ms483 ms0.6 ms
serialize as JSON, 29,000 maps249 ms1.1 ms168 ms0.8 ms
100,000-entry map + lookups323 ms1.0 ms310 ms0.7 ms
sort() with a key, 20,00084 ms0.9 ms63 ms0.8 ms
fold-left into a map, 20,000141 ms1.2 ms94 ms0.9 ms
Accumulators, 5 MB692 ms1.3 ms487 ms1.2 ms
xsl:merge, 2 × 50,000346 ms1.1 ms241 ms1.0 ms
ScenarioWhat it does
group-adjacent50,000 log entries grouped into runs of the same type
analyze-stringthe WARN and ERROR lines of a 5 MB log picked with a regular expression (flags="m")
xsl:iterate totalsan exact xs:decimal running total of 100,000 amounts
json-to-xml, parse-jsonthe same summary of 37,500 orders (5 MB of JSON, a string parameter) through the XML representation of JSON and through maps and arrays
xml-to-jsonthe XML representation of the same 5 MB of JSON written as JSON
serialize as JSON29,000 records as an array of maps, written with serialize(..., map { 'method': 'json' })
map + lookupsmap:merge of 100,000 entries, then 100,000 lookups
sort() with a key, fold-lefthigher-order functions over 20,000 items: a sort by price; per-category totals folded into a map
Accumulatorsa record counter and an xs:decimal sum over the 5 MB identity document
xsl:mergetwo sources of 50,000 items each, sorted by an integer key

XSLT engines compile time

Scenario1.0 package, jsdomxslt3, jsdom1.0 package, xmldomxslt3, xmldom
Issue #9 catalogue (3 MB HTML)0.7 ms2.5 ms0.5 ms2.2 ms
apply-templates item[@id], 8,0000.5 ms0.8 ms0.3 ms0.6 ms
Muenchian grouping, 8,0000.5 ms1.1 ms0.3 ms0.8 ms
xsl:number level="any", 8,0000.5 ms0.8 ms0.3 ms0.5 ms
following-sibling::x[1], 8,0000.5 ms0.7 ms0.3 ms0.4 ms
Sort 20,000 by two keys0.5 ms0.9 ms0.3 ms0.7 ms
Identity transform, 5 MB0.6 ms1.1 ms0.4 ms0.8 ms
call-template depth 3,0000.4 ms0.8 ms0.3 ms0.5 ms
100 MB text result0.4 ms0.8 ms0.3 ms0.5 ms
Cataloguen/a2.3 msn/a1.9 ms
Grouping, 8,000n/a0.8 msn/a0.6 ms
Numbering, 8,000n/a0.8 msn/a0.5 ms
Sort, 20,000n/a0.9 msn/a0.8 ms
Identity, 5 MBn/a0.7 msn/a0.5 ms
Depth 3,000n/a0.8 msn/a0.5 ms
group-adjacent, 50,000n/a1.0 msn/a0.8 ms
analyze-string, 5 MB logn/a0.9 msn/a0.6 ms
xsl:iterate totals, 100,000n/a1.0 msn/a0.8 ms
json-to-xml, 5 MB JSONn/a1.0 msn/a0.7 ms
parse-json, 5 MB JSONn/a0.9 msn/a0.7 ms
xml-to-json, 5 MB JSONn/a0.7 msn/a0.6 ms
serialize as JSON, 29,000 mapsn/a1.1 msn/a0.8 ms
100,000-entry map + lookupsn/a1.0 msn/a0.7 ms
sort() with a key, 20,000n/a0.9 msn/a0.8 ms
fold-left into a map, 20,000n/a1.2 msn/a0.9 ms
Accumulators, 5 MBn/a1.3 msn/a1.2 ms
xsl:merge, 2 × 50,000n/a1.1 msn/a1.0 ms

Start-up and bundle size

Each package bundled as one minified ES module for browsers, the way the website bundles @tradik/xslt3 for its playground (site/scripts/vendor.mjs), and the time a fresh Node.js process takes to import() it, from source and from that bundle (median of the runs, one process each).

EngineBundlegzipBrotliNode.js import(), sourceNode.js import(), bundle
1.0 package (src/index.js)147.0 kB46.5 kB40.7 kB29 ms6.1 ms
xslt3 (packages/xslt3/src/index.js)339.0 kB116.7 kB99.9 kB77 ms21 ms

XSLT engines peak memory

Scenario1.0 package, jsdomxslt3, jsdom1.0 package, xmldomxslt3, xmldom
Issue #9 catalogue (3 MB HTML)661 MB614 MB485 MB443 MB
apply-templates item[@id], 8,000278 MB260 MB187 MB203 MB
Muenchian grouping, 8,000262 MB282 MB150 MB150 MB
xsl:number level="any", 8,000230 MB243 MB128 MB146 MB
following-sibling::x[1], 8,000258 MB208 MB152 MB113 MB
Sort 20,000 by two keys308 MB292 MB237 MB234 MB
Identity transform, 5 MB666 MB702 MB542 MB566 MB
call-template depth 3,000215 MB192 MB106 MB100 MB
100 MB text result659 MB287 MB465 MB233 MB
Cataloguen/a599 MBn/a426 MB
Grouping, 8,000n/a214 MBn/a115 MB
Numbering, 8,000n/a229 MBn/a114 MB
Sort, 20,000n/a282 MBn/a232 MB
Identity, 5 MBn/a658 MBn/a523 MB
Depth 3,000n/a188 MBn/a76 MB
group-adjacent, 50,000n/a343 MBn/a275 MB
analyze-string, 5 MB logn/a338 MBn/a286 MB
xsl:iterate totals, 100,000n/a378 MBn/a328 MB
json-to-xml, 5 MB JSONn/a1391 MBn/a2018 MB
parse-json, 5 MB JSONn/a382 MBn/a322 MB
xml-to-json, 5 MB JSONn/a1298 MBn/a851 MB
serialize as JSON, 29,000 mapsn/a423 MBn/a393 MB
100,000-entry map + lookupsn/a358 MBn/a302 MB
sort() with a key, 20,000n/a283 MBn/a198 MB
fold-left into a map, 20,000n/a291 MBn/a164 MB
Accumulators, 5 MBn/a521 MBn/a477 MB
xsl:merge, 2 × 50,000n/a410 MBn/a364 MB

SaxonJS

SaxonJS 3, Saxonica's XSLT 3.0 processor for JavaScript, is the reference the milestone in XSLT3.md names. On npm it is saxonjs-he with the xslt3-he compiler (3.0.0-beta2; saxon-js and xslt3 are SaxonJS 2). Its licence (Saxonica's "SaxonJS licence", version 2.0, December 2024, shipped as LICENSE.txt) allows use and redistribution in binary form but says nothing about publishing benchmark results, and the package is a preview beta. This page therefore publishes no SaxonJS numbers, and SaxonJS is not a dependency of this repository. To compare locally, install it outside the repository and point the benchmark at it; its results are written to the system temporary directory, never to results-xslt.json:

npm install --prefix /tmp/saxon saxonjs-he xslt3-he
node scripts/benchmark/run.mjs --suite xslt --saxon-dir /tmp/saxon

Each stylesheet is then compiled to SEF with xslt3-he -export -nogo (timed separately), and SaxonJS.transform() of the SEF is timed on a tree SaxonJS parsed itself; its outputs are compared with xslt3's, ignoring whitespace between tags (SaxonJS indents HTML by default).

XSLT engines notes

  • xslt3 runs the 1.0 stylesheets faster than the 1.0 package on both DOMs: 2 to 7 times on sorting, following-sibling::x[1] in a loop, recursion, xsl:number level="any" and the 100 MB text result (which also peaks at 290 MB of memory instead of 660 MB on jsdom). The catalogue, the identity transform and Muenchian grouping are within 15%, because building and serializing the result tree dominates them. xsl:number level="any" was quadratic in xslt3 before release (8,000 nodes took 41 s); it now remembers the last number per instruction and takes 35 ms (task 0038).
  • Rewriting pays off where the 1.0 idiom was a workaround.xsl:for-each-group instead of Muenchian keys is almost 4 times faster on the grouping scenario and 1.5 times on the catalogue; on-no-match="shallow-copy" saves 20% (jsdom) to 30% (xmldom) on the identity transform; sort() with a key function costs about the same as two xsl:sort keys.
  • JSON: parse-json() reads 5 MB of JSON 10 times faster than json-to-xml() on jsdom and with a quarter of the memory: the XML representation of the JSON is a DOM tree of hundreds of thousands of elements, and the json-to-xml processes peak at 1.4 GB (jsdom) to 2 GB (xmldom), mostly in the DOM library itself.
  • Accumulators over a 29,000-element document take 0.7 s on jsdom and 0.5 s on xmldom.
  • jsdom or xmldom: both engines usually run 1.5 to 3 times faster on @xmldom/xmldom than on jsdom; the ranking of the engines does not change.
  • Compiling a stylesheet takes 0.5 to 2 ms with either engine, so reusing a compiled stylesheet matters only for small transformations.
  • Start-up: the xslt3 bundle is 2.5 times the 1.0 package's (117 kB against 47 kB gzip) and takes 2.6 times as long to import from source in Node.js (78 ms against 30 ms); the 1.0 package does not load it unless asked to (see XSLT3.md, "Opt-in bridge").

This page is generated from docs/BENCHMARKS.md in the repository. Corrections are welcome as a pull request to that file.

Built with ssg