All news
ASCodeworksReleases

SYNX 3.8 is faster everywhere, and every parser finally agrees

SYNX 3.8.0 doesn't change the language at all. What changed is how fast every implementation reads it, up to 9.6 times faster in TypeScript and 2 to 3.6 times in Rust, and the fact that they now give the same answer for the same file.

by APERTURESyndicate · October 17, 2026

SYNX 3.8 is faster everywhere, and every parser finally agrees

SYNX 3.8.0 is out, and if all you do is write config files, you won't see anything new in the files themselves, because the grammar hasn't moved at all. What moved is everything underneath it: the Rust core, the TypeScript package, the Python and Node bindings and all seven native ports were measured against 3.7.4 on the same machine and the same set of files, and then rebuilt wherever the time was actually going.

How much faster

Here’s how long the same files take with each version:

Parse time, 3.7.4 → 3.8.03.7.43.8.0
Rust · 2.5 KB service config38.6 → 17.6 µs×2.19
Rust · 4 MB static document60.1 → 35.3 ms×1.70
Rust · 1.2 MB with markers81.0 → 22.5 ms×3.61
TypeScript (Node) · 2.5 KB config34.0 → 20.4 µs×1.66
TypeScript (Node) · 1.2 MB with markers297.5 → 31.1 ms×9.56
Python · 2.5 KB config50.3 → 17.6 µs×2.86
Python · 100,000 SYNXL records173.7 → 83.2 ms×2.09
Grey is the time 3.7.4 took, blue is 3.8.0 on the same file. One machine, Windows 11.

And the whole table, with Bun next to Node and the Python binding:

DocumentRustTypeScript · Node 24TypeScript · Bun 1.3Python
2.5 KB service config×2.19
38.6 → 17.6 µs
×1.66
34.0 → 20.4 µs
×1.67
43.1 → 25.8 µs
×2.86
50.3 → 17.6 µs
Static, 134 KB×1.84
1.78 → 0.97 ms
×1.37
1.70 → 1.24 ms
×1.55
2.06 → 1.33 ms
×2.13
2.48 → 1.16 ms
Static, 4 MB×1.70
60.1 → 35.3 ms
×1.41
53.9 → 38.2 ms
×1.60
68.3 → 42.6 ms
—
With markers, 36 KB×3.64
2.08 → 0.57 ms
×1.43
1.23 → 0.86 ms
×1.66
2.10 → 1.26 ms
—
With markers, 1.2 MB×3.61
81.0 → 22.5 ms
×9.56
297.5 → 31.1 ms
×6.73
331.0 → 49.2 ms
—
Our 46 production configs×2.43
3.57 → 1.47 ms
×1.25
2.75 → 2.20 ms
×1.53
3.76 → 2.46 ms
×2.72
4.12 → 1.51 ms
Best of interleaved runs on one machine, same inputs, output checked identical.

The biggest jump is TypeScript on large documents with markers, and the reason is a little embarrassing: every :calc key used to rescan its expression against every other key in the document, so the work grew with the square of the file. In Rust most of the win came from memory. With the Windows system allocator a plain parse spent close to half its time asking for memory, and 3.8.0 asks about a third as often on static files and a fifth as often in active mode. The binary .synxb format got the same treatment: the strings a document uses most now get the shortest indexes, so files are 3 to 20 percent smaller in Rust and up to 40 percent smaller in TypeScript, and the same document now always compiles to the same bytes, which 3.7.4 didn't promise.

The parsers didn't actually agree

This part we didn't go looking for. To make sure the faster code gave the same results, we started feeding the same randomly generated documents to the Rust core and to the TypeScript package and comparing what came out, and 17 percent of the !active documents came out different. Some of that was TypeScript being wrong and some was Rust, and some of it was Rust being wrong differently on every run: it walked objects in hash-map order, so the tax:calc / total:calc example straight from the spec failed with CALC_ERR in about half of all runs.

And once we started looking, there was more of it. Markers inside list items were never applied in Rust at all. Synx.stringify and .synxb changed data on the way back, so "007" came back as the number 7 and |+ code blocks lost their indentation, and 1,777 of 3,000 random documents didn't survive the round trip. A few documents could even take the whole process down: :clamp:nan:5 made Rust panic, and a file that included itself overflowed the stack. All 55 of these are fixed, and on the final code the Rust core and TypeScript agree on every one of 70,000 generated documents.

Seven ports, all on 3.8.0

SYNX also has native parsers for Go, Java, .NET, Dart, C++, Swift and Godot, and they had been falling a few releases behind. All of them are on 3.8.0 now, checked the same way against the Rust core on thousands of generated documents each, with nothing left that differs. Some of them changed a lot more than the others, and the active-mode column is where you see it:

Port1.2 MB with markersChecked against the core
Go329 ms → 23.1 ms23,000+ documents
Java 17179 ms → 28.7 ms10,000 documents
.NET 89.7 s → 70 ms3,400+ documents
Dart 318.4 s → 68 ms3,400+ documents
C++1788.5 s → 32.7 ms21,000+ documents
Swift46.6 s → 95 ms21,000+ documents
Godot / GDScriptdidn’t compile on Godot 4.7; parsing ×1.6–1.931,000 documents

What it changed on our own platform

Pretty much everything APERTURESyndicate runs reads SYNX somewhere. Every service starts from an app.synx, the profile service keeps its store, cosmetics and quests in SYNX, Apeiron’s store and patch notes are SYNX files signed with the depot key, and Rebellion loads its whole balance and both languages from it. So the day 3.8.0 came out we moved all of it over and measured again, this time on the production server and on the files each of these places actually reads, against 3.7.2, which is what our services had been running:

Our services on the production server, 3.7.2 → 3.8.03.7.23.8.0
Profile page · Rebellion shelf, every view279 → 167 µs×1.67
Search · Apeiron store, every query160 → 106 µs×1.51
Main site · page registries at start648 → 452 µs×1.43
Profiles · store, cosmetics and quests at start1.56 → 0.95 ms×1.63
Resonance · genres, places and credits448 → 266 µs×1.68
Sign-up · legal facts145 → 84 µs×1.74
Frontier · map in the browser1.96 → 1.20 ms×1.63
Rebellion site · game balance export16.8 → 9.7 ms×1.73
ASAI · building the injectors340 → 165 µs×2.06
AMD EPYC server, Bun 1.3.14; each service’s own files and calls, measured in a fresh process per run.

The two at the top run on every request, and both now take about two thirds of what they used to, which in absolute terms is around a tenth of a millisecond per page. The bigger files live in the desktop apps and the game, so those we measured on a Windows 11 machine:

WhereBefore → afterSpeed-up
Apeiron · store, game detection and achievements, every 10 min438 → 232 µs×1.89
Apeiron · Updates feed348 → 191 µs×1.82
Apeiron · Rebellion depot file list, 64 KB178 → 176 µs×1.01
Rebellion · locale, 1.1 MB13.1 → 5.6 ms×2.34
Rebellion · balance, 54 files19.8 → 15.4 ms×1.28
Rebellion · DLC manifest46 → 33 µs×1.40
Rebellion · writing a run save113 → 134 µs×0.84
Resonance desktop · writing a 3,000-track library4.28 → 1.75 ms×2.45
Resonance desktop · reading it back6.36 → 5.17 ms×1.23
Windows 11. “Before” is the version each app shipped with: 3.7.3 in Apeiron, 3.7.1 in the game and Resonance.

Not everything moved the right way, and it’s worth saying where. Service configs didn’t get faster at all: an app.synx with !active is small, and in a fresh process the first parse on 3.8.0 is about a millisecond slower, because there’s more engine to warm up, though that happens once per start. Rebellion takes about a fifth longer to write a save, because the .NET writer now quotes strings that look like numbers, so "1" stays a string when the file is read back, and the very first read of the locale when the game starts takes about 7 ms longer. Saves written by the old version open exactly as before.

One thing we caught before it reached production. In !active mode SYNX collects [...] constraints by field name across the whole document. That’s what the spec has always said and what Rust always did, but the TypeScript package only started doing it in 3.7.4, and our services were still on 3.7.2. Five of them declared port[type:int, min:1024] for their own HTTP port, so under 3.8.0 the SMTP email.port 587 picked up the same min:1024, came back as a CONSTRAINT_ERR string, and mail from those services would have stopped. Before deploying we parsed every service config with both versions under its real production environment, which is how it showed up, and gave the HTTP port min:1 instead.

Updating

Your .synx files don't need to change. The packages are @aperturesyndicate/synx-format on npm, synx-core on crates.io and synx-format on PyPI, and the ports come through their usual registries. If you use synx-core from Rust directly, objects are now an ordered IndexMap instead of a HashMap, so code that builds its own HashMap and wraps it needs a .collect(), while everything that reads through get and iteration compiles as before.

If you’re moving the TypeScript package up from 3.7.3 or older and use !active, look for constraints on field names that repeat in different sections of the same file, because that’s exactly what we ran into above.

There's one thing the format still can't express: a list inside a list item, followed by another item, doesn't survive writing and reading back, because SYNX keeps one open list at a time. That one is planned for 4.0, together with compact strings, which in a lab copy of the core made parsing another 1.2 to 1.5 times faster.

Every change, with the numbers, is in the changelog and in the 3.8.0 release on GitHub.

Other stories

All stories
SYNX 3.8 is faster everywhere, and every parser finally agrees | APERTURESyndicate