Faster, With Guardrails

Back in March, I wrote about JSSE, a JavaScript engine an agent built from scratch in Rust. It was correct, but slow. In May, I wrote about making it faster without touching its architecture, and promised that post was not about bytecode. JSSE v0.9.0 was tagged on September 23, so it is time to take stock.
In this post, I will go through where JSSE stands six months after its first release, how it compares with other engines, and the guardrails that allowed an agent to make it faster without breaking it. The short version: v0.9.0 runs 47 of the 48 JetStream workloads, up from 22 in March. On the 22 that both versions run, it is 2.92x faster (geometric mean). And it still passes every one of the 99,911 test262 scenarios we run.
I still haven't written a single line of JSSE. What I did build is the guardrails and, as it turns out, I think the guardrails are the actual story. Agent-driven performance work is possible, however it is only trustworthy if the agent cannot quietly trade correctness for speed.
Still 100%
test262 is not a fixed target: it keeps growing. The March build passed the suite as it stood then[1] but, if we run it against today's checkout, it fails 1,039 of 99,911 scenarios. Most of those are tests for features that landed in the meantime, namely iterator helpers, Promise.allKeyed, immutable ArrayBuffers and Atomics.waitAsync. v0.9.0 passes all of them, both on the default tree-walker and with the experimental bytecode VM.
Let's put this in context. Every engine below ran the same test262 checkout, through the same runner, with the same 120-second timeout. Tick the boxes to add or remove engines from the comparison.
test262 table
| Engine | Pass | Run | Pass rate |
|---|---|---|---|
| JSSE v0.9.0 | 99,911 | 99,911 | 100% |
JSSE v0.9.0 --bytecode | 99,911 | 99,911 | 100% |
| JSSE March (v0.1.0) | 98,872 | 99,911 | 98.96% |
| Node v26.9.0 (V8) | 95,353 | 99,081 | 96.24% |
| engine262 | 10,342 | 11,752 (10% sample) | ≈88.0% |
| Boa v0.22 | 84,767 | 99,911 | 84.84% |
Does this mean JSSE is more conformant than V8? No! Node goes through a test adapter with a basic $262.agent implementation and skips 830 module scenarios, so some of its failures belong to the harness and not to V8. The claim here is much narrower: an engine built and optimized by an agent sits at the top of this table, and it didn't slip while getting faster.
The Guardrails
Performance work is where an agent is at its most dangerous. A fast path that skips a step of the spec will happily pass the benchmark and break something three clauses away. Therefore, nothing merges without getting past three kinds of guardrail.
Correctness. Every PR runs the full test262 suite locally and compares the result against a pass-list baseline read from main. A single regression blocks the merge. The agent cannot move the baseline, it cannot touch the test262 or spec submodules, and it is told to implement the spec, not the test (special-casing a test file is not a fix). In addition, CI re-runs a seeded sample of test262 and test262-extra, a first-party suite for spec behaviour that test262 doesn't reach, in both execution modes.
Measurement. Performance claims need receipts. Benchmarks run under a fixed protocol: the same machine, five process runs, the median, a load gate, a pinned JetStream commit, and benchmark scripts that don't change between versions. The results land in the repository under docs/perf/ as data, and that includes the null results. When the bytecode VM failed to speed up tweetnacl, that got written up too.
Scope. The work runs through Symphonika, an orchestrator I have been building that turns GitHub issues into agent runs (more on it in a future post, soon). Each issue goes through a fixed pipeline: plan, implement, code review, simplify, wait for CI and review threads, autofix and, finally, merge. Each step has a gate. The plan must cite the spec clauses it relies on, and an implementation that doesn't push a commit never gets to open a PR. In essence: one issue, one branch, one PR. Between May and September that came to about 700 commits across some 270 PRs.
What does a guardrail catching something look like? On August 24, a correctness fix made JSON.stringify honour Proxy property descriptors. It was spec-correct and test262 was happy. However, it also sent every key of every ordinary object through the Proxy path, allocating a descriptor object per property, and JetStream's json-stringify-inspector got almost three times slower. The benchmark snapshot of September 18 flagged it as slower than March, an issue got filed, and the fix (read [[GetOwnProperty]] directly for non-proxy objects) was released two days later.
Note that the guardrail didn't prevent the regression. It made it visible, more than three weeks after it landed. Measurement is periodic, not per-PR, and closing that gap is next on my list.
Where the Speed Came From
Unlike in May, most of the gains this time around came from architectural changes:
- NaN-boxed values. A
JsValuewent from a roughly 32-byte enum to one 64-bit word. - Generational GC. A non-moving nursery with a remembered set and a write barrier, so short-lived objects die cheap.
- Arena allocation for objects.
- Polymorphic inline caches for property reads and call sites. They are not hidden classes yet: an entry matches one object, not a shape shared across objects.
- A bytecode VM, behind
--bytecode.
The bytecode VM is the one that hasn't paid off yet, and some back-of-the-envelope numbers explain why. Entering a compiled function costs about 350 ns, and each bytecode op saves about 22 ns over the tree-walker, so the break-even point is around 16 ops. On mandreel, the average compiled function is 15.9 ops long, right at the break-even point, and while 96.5% of calls run compiled, only 13% of the work does. That is why it is off by default: it is 3.8% better on JetStream (geometric mean), up to 1.7x on navier-stokes, and slower on a few workloads.
The Numbers
Let's look at the numbers, starting with nine small scripts. Each one is timed as a whole process (so startup is included), and we take the median of five runs. The comparison that matters here is with Boa, another engine written in Rust that doesn't JIT. Node is available in the chart but it lives in a different universe, and its ~0.1 s times are mostly process startup anyway.
Micro-benchmark table (seconds)
| Benchmark | JSSE March | JSSE v0.9.0 | v0.9.0 --bytecode | Boa v0.22 | Node v26.9.0 | engine262 |
|---|---|---|---|---|---|---|
| loop | 3.18 | 2.09 | 2.10 | 2.07 | 0.12 | timeout |
| fib | 31.72 | 4.01 | 3.58 | 1.34 | 0.13 | timeout |
| string | 5.47 | 1.04 | 1.06 | 0.53 | 0.13 | 14.20 |
| array | timeout | 27.54 | 27.72 | 0.33 | 0.12 | 102.78 |
| object | 0.92 | 0.67 | 0.66 | 0.61 | 0.25 | 58.30 |
| regex | 5.97 | 0.10 | 0.10 | 0.15 | 0.10 | 8.49 |
| closures | 15.70 | 5.28 | 5.72 | 2.37 | 0.20 | timeout |
| json | 0.27 | 0.18 | 0.18 | 0.31 | 0.12 | 8.44 |
| opmix | 70.69 | 25.76 | 20.19 | 5.69 | 0.12 | timeout |
Against March, v0.9.0 is 1.4x faster on loop, 7.9x on fib and a whopping 57x on regex, while array went from a timeout to 27.5 s. Against Boa, it roughly ties on loops and object access, wins on regex and JSON, and trails by 2x to 4x on calls, closures, strings and opmix. And then there's array, but more on that below.
JetStream is a more realistic set of workloads. v0.9.0 passes 47 of 48 (mandreel still times out), whereas March passed 22. Here is each of those 22, comparing v0.9.0 against March:
JetStream table (ms, median of 3)
| Workload | March | v0.9.0 | Speedup | --bytecode | Speedup |
|---|---|---|---|---|---|
| FlightPlanner | 16,993 | 1,149 | 14.79x | 1,096 | 15.51x |
| ai-astar | 54,206 | 5,026 | 10.79x | 4,849 | 11.18x |
| hash-map | 114,009 | 14,314 | 7.97x | 13,905 | 8.20x |
| earley-boyer | 31,348 | 5,484 | 5.72x | 5,230 | 5.99x |
| UniPoker | 20,899 | 4,723 | 4.42x | 4,588 | 4.56x |
| bigint-bigdenary | 17,858 | 4,241 | 4.21x | 4,169 | 4.28x |
| delta-blue | 22,067 | 6,151 | 3.59x | 5,579 | 3.96x |
| raytrace-private-class-fields | 51,397 | 14,537 | 3.54x | 14,619 | 3.52x |
| octane-code-load | 138 | 40 | 3.45x | 60 | 2.30x |
| richards | 30,200 | 10,655 | 2.83x | 10,159 | 2.97x |
| raytrace | 36,919 | 13,323 | 2.77x | 11,624 | 3.18x |
| Box2D | 16,661 | 6,992 | 2.38x | 6,502 | 2.56x |
| gbemu | 57,875 | 27,039 | 2.14x | 25,453 | 2.27x |
| crypto | 11,694 | 5,491 | 2.13x | 4,482 | 2.61x |
| pdfjs | 23,051 | 11,524 | 2.00x | 11,474 | 2.01x |
| navier-stokes | 8,882 | 4,822 | 1.84x | 2,811 | 3.16x |
| stanford-crypto-aes | 13,812 | 7,831 | 1.76x | 7,817 | 1.77x |
| stanford-crypto-sha256 | 8,839 | 5,197 | 1.70x | 4,428 | 2.00x |
| stanford-crypto-pbkdf2 | 8,187 | 4,817 | 1.70x | 4,075 | 2.01x |
| gaussian-blur | 53,281 | 31,433 | 1.70x | 22,870 | 2.33x |
| json-parse-inspector | 1,326 | 1,176 | 1.13x | 1,201 | 1.10x |
| json-stringify-inspector | 537 | 561 | 0.96x | 563 | 0.95x |
The speedups go from 14.8x on FlightPlanner down to 0.96x on json-stringify-inspector which, if you remember the regression above, has recovered but is still 4% short of March. The geometric mean is 2.92x and the median 2.58x. On top of that, twenty-five workloads that didn't run in March run now[2].
What's Still Bad
Arrays. bench_array pushes 100,000 numbers, maps them and reduces them. It takes 27.5 s in JSSE and 0.33 s in Boa. While writing this post, the agent and I found out why: building a result array in map, filter or slice is quadratic. Each element is written into the dense storage and then also defined as a named property, and that path does a linear scan over the object's property list. push is linear, map is not. It's the kind of bug that survives because test262 checks what the answer is, not how long it takes to get there.
Bytecode barely helps. Entry cost and coverage gaps eat the gains (a single labelled statement is enough for a whole function to fall back to the tree-walker).
No hidden classes. Property access is cached per object, not per shape.
mandreel still times out.
None of this is surprising, and the guardrails are what allow me to say so with a straight face: every number here is in the repository, including the embarrassing ones. The result is far from perfect, but I am proud of it.
What's Next
The list is not short. First, fix arrays. Then, make bytecode pay for itself by cutting the entry cost and closing the coverage gaps, so that it can be on by default. After that, real shapes. And, of course, the Symphonika post, since the orchestrator is now doing more of the work than I am.
All numbers come from docs/perf/2026-09-25/engine-comparison.json[3].
Corrections or Comments?
Comments are open below. As usual, I am happy to receive corrections, and if you spot something odd in the numbers, open an issue on JSSE.
Almost. In May, @ivankra found that JSSE's test runner was classifying some failures as passes, so March's "100%" was really a little less. Fixing the runner was a guardrail too. ↩︎
The script that drives JetStream was fixed along the way as well. A control run of an earlier engine build under the new runner suggests about a third of those 25 pass because of runner fixes rather than engine changes. Speedups only compare workloads that pass in both builds. ↩︎
test262 ran on my laptop; load affects timings there, not pass/fail. Performance ran on a shared build machine with someone else's jobs running, so expect a few percent of noise. Only v0.9.0 was measured on September 25; the other engines were measured a week earlier on the same machine with the same protocol. ↩︎
Comments