Measured against Lua and QuickJS
Every other number on this site is absolute — so many milliseconds, so many rows. This one gives them context: the same scenarios run through two other C interpreters compiled to WebAssembly, under the same measurement discipline. It exists to find the headroom, not to win, so the losses are published beside the wins.
The rules, before the numbers
A comparison page is marketing unless it binds itself first. Two rules:
Each language gets idiomatic code. The same algorithm, written the way that language's programmer would actually write it — not Ring written as if it were Lua. Losses are published with the wins. Four of the nine rows below go to somebody else, and each one says why.
The contenders
| Engine | wasm size | Errors arrive as |
|---|---|---|
| Ring 1.27 — RingScript | 397 KB | a returned {ok:false} |
| Lua 5.4 — wasmoon | 272 KB | a thrown exception (its contract) |
| QuickJS — quickjs-emscripten | 519 KB | a returned handle |
Same weight class, same architecture: a C interpreter compiled to wasm, holding resident state between evaluations. Lua is the closest structural twin. QuickJS is the strongest small interpreter in the business, so it marks the ceiling rather than the average.
The headline is not speed
It is that nothing breaks. Both suites this project takes most seriously, run through all three engines:
Nobody leaks on the page workload, nobody dies on garbage, everybody keeps working. That is an unremarkable-looking table and it is the one that matters: it is the difference between a demo and something you would leave running in a branch office for a year.
It was not always true here. Before this project's hardening work, RingScript would have failed the endurance row — it leaked a class per evaluation, which is precisely this shape of defect. That table is what the work bought: a seat at a table where mature interpreters sit.
Ring does accept more garbage than the others — 22 of 1,200 against 3 and 7 — because its grammar treats more stray text as a valid program. Accepting garbage quietly is not a crash, but it is worth knowing.
The scoreboard
Minimum of many runs, Intel Core 5 210H, Node 22. This is one recorded run — the table and the data file behind it are the same run, so neither can drift from the other.
| Scenario (min ms) | ring | lua | js | Winner |
|---|---|---|---|---|
| fresh evaluator | 3.32 | 0.15 | 0.14 | peers |
| assign a global | 0.040 | 0.002 | 0.002 | lua |
| 10,000-iteration loop | 0.650 | 0.058 | 0.416 | lua |
| build a 2,000-char string | 0.217 | 0.304 | 0.691 | ring |
| copy + sort 2,000 numbers | 0.229 | 0.375 | 0.882 | ring |
| create 2,000 objects | 3.729 | 0.203 | 0.646 | lua |
| JSON encode ~8.7 KB | 0.134 | 0.726 | 0.433 | ring |
| JSON decode ~8.7 KB | 0.760 | 1.183 | 0.197 | js |
| 1 MB through JSON | 1.53 | 154.7 | 14.2 | ring |
Four rows of nine. A day earlier it was two — the difference is one day of executing a plan that had been written from the first run of this same harness.
Why Ring wins where it wins
The pattern behind every win is the same sentence: when the work lands in the vendored C, Ring is competitive with anyone.
String building. Ring's append is amortised — the JSON hardening measured this directly, and appends are linear. Lua's strings are immutable, so every concatenation pays a fresh allocation.
Sorting. sort() lands in the VM's own C sort, and
that sort is good.
JSON. This is the one worth pausing on, because it is the clearest case of a design decision paying off. The codec used to be written in Ring itself — 10.4 ms to encode 8.7 KB, and 944 ms for a megabyte round trip. It is C now, held byte-identical to the pure-Ring reference by a permanent test, so the same file still runs on native Ring and any divergence fails the build. Encoding went to 0.134 ms — ahead of QuickJS's own native codec — and the megabyte round trip to 1.53 ms, which is a 617× improvement and the widest margin on the board.
Why it loses where it loses
A fresh evaluator: 3.3 ms against 0.15. Mostly not interpreter quality. The peers compile their wasm once and instantiate many; RingScript now does too, stamping each new instance from a post-initialisation memory snapshot — which is what took this row from 6.7 ms to 3.3. The remainder is Ring parsing its embedded library at startup. For a page that creates one VM, 3 ms is not a number anyone will feel.
Assigning one global: 20× slower. This is overhead, not
execution — everything wrapped around your code: a keyword scan, the
try/catch shim that routes through Ring's own eval(), the
auto-main check. For a page calling into Ring on a click,
0.04 ms is nothing. For anything calling it in a tight loop it dominates
before your code runs a single statement, and the answer is to batch the
work into one call.
The loop: Lua is 11× faster. True, and context matters — Lua is also 7× faster than QuickJS on the same row. Register-based dispatch with a fused loop opcode is a different design generation. Ring sits within about 1.5× of QuickJS here, which is a respectable place for an interpreter that has never had a bytecode redesign.
Creating objects: 18× a Lua table. The single largest interpreter-side gap, and it was 31× before an object template cache landed. The honest guidance stands: hot-path data in lists, objects in the domain model.
What this is for
The scoreboard is not the product. What it produced is: every one of
those losses came with a cause, the causes became a work list, and the work
list was executed — snapshot instancing, a C JSON codec, computed-goto
dispatch, an -O2 hot core, an object template cache. Two wins
became four in a day, and no correctness was traded for any of it:
the same ~850 programs still match native ring.exe byte for
byte.
Racing the fastest small interpreters in the business is not about beating them. It is the only reliable way to find out where your own next multiple is hiding.
What the page is not: a single machine, Node rather than a browser, and three engines that made different trade-offs — Lua ships no JSON at all, QuickJS ships a C one, Ring ships a pure-Ring one so the same file runs natively. Read it as a map of where the headroom is, not as a league table.
Run it yourself
cd tests/rivals
npm install # wasmoon + quickjs-emscripten, ~10 packages
node run.js --json=results.json
It is deliberately not part of CI: it needs npm dependencies the runtime itself must never grow, and its numbers are for learning rather than gating — tests/bench.js remains the regression gate, calibrated against a fixed workload so a millisecond means the same thing on your machine as on mine.
The full page, including the before-picture and every caveat: docs/rivals.md. The plan the first run produced: docs/HEADROOM_PLAN.md.