Crystal by Reference: Making freeze() Mean It, Then Making It Fast
Most programming languages make some kind of promise about immutability. JavaScript has Object.freeze(). Java has final. Python has tuples. Rust builds half its identity on it. The promise is always the same shape: this value will not change, and you can build on that.
This is a story about what happens when a language makes that promise and the implementation doesn't keep it — how the holes got there, how they were found, what it took to close them, and the surprisingly large reward waiting on the other side. The language in question is Lattice, a small interpreted language I've been building, but you don't need to know Lattice or care about it for the story to be useful. The bugs, the debugging patterns, and the performance trade-offs are the kind that show up in any system that manages memory and makes guarantees. Lattice just happens to be where I got to watch the whole arc play out in one place.
A Promise in Three Lines
The one piece of background you need: Lattice is built around a metaphor of crystallization. Values start out fluid — mutable, workable — and can be frozen into immutable crystal. It's the same idea as const or Object.freeze(), but as a runtime lifecycle you can observe and control, and it's the central concept of the language. Freezing is the thing Lattice is about.
Which makes these three lines embarrassing:
flux a = [1, 2, 3] // a mutable array freeze(a) // now immutable... supposedly a[0] = 99 // prints [99, 2, 3]
That third line should be a runtime error. On the released version of the language, it just... worked. The frozen array changed. No error, no warning, nothing.
I didn't find this because a user complained. I found it while reviewing the design for a performance feature — which, it turns out, is one of the most reliable ways to find soundness bugs. The feature was going to share frozen data instead of copying it, and its entire safety argument was one sentence: frozen values can't change, so letting many parts of the program point at the same frozen value is safe. Before betting the memory model on that sentence, I tested it. The sentence was false.
How a Promise Breaks
It's worth dwelling on how this happens, because it isn't carelessness — it's structure.
Lattice has three separate execution engines that are supposed to behave identically: a simple tree-walking interpreter (the original), a bytecode virtual machine (the default — the thing that actually runs your code), and a register-based VM (an experiment in going faster). Three implementations of one language means every rule has to be enforced three times. And "frozen values reject mutation" isn't one rule in one place — it's a cross-cutting concern. Arrays are assigned through one code path, map keys through another, globals through a third. There are dozens of built-in methods that modify their target: push, remove, fill, resize. Every one of those is a door, and the guard has to be standing at every door, in every engine.
The audit found doors standing wide open everywhere, and — this is the telling part — different doors in each engine. The bytecode VM checked local variables but not globals. The tree-walker checked maps but not arrays. The file containing all the mutating built-in methods had zero immutability checks in it, anywhere, for any type, on any engine — so writing bytes into a frozen buffer worked everywhere. Lattice's partial-freeze feature (freeze this map, except leave this one key writable) kept its exemptions in a side table that a later full freeze never cleared, so an exemption, once granted, was permanent. The one engine that mostly enforced things had its own subtle inversion: a key missing from the side table was treated as exempt rather than frozen.
The most honest summary: each engine enforced the promise wherever its author happened to be thinking about the promise that day. Nobody had ever sat down and enumerated the doors.
And one door had something worse than a missing guard behind it. Calling .remove() on a frozen map didn't return an error on the tree-walker — it crashed the whole process with a double-free, a memory-corruption bug. The missing immutability check had been quietly standing in front of a memory-safety bug the whole time. Hold that thought; it becomes a theme.
The fix wasn't clever and that's the point. Write down every door first: a matrix of nineteen small test programs, each run against all three engines, asserting that frozen things reject mutation and exempted things don't, before fixing anything. Then station guards until the matrix is green. Where possible, replace scattered per-door checks with one chokepoint — all those mutating built-in methods now get rejected by a single shared classifier that all three engines consult before running any method, so the next method someone adds is guarded by default instead of by memory. The review of that change caught one engine sneaking around the chokepoint through an undocumented alias of remove — which tells you the chokepoint was worth building.
The enforcement work shipped as v0.5.0, and the version number was itself a decision worth a sentence: these were bug fixes, which argues for a patch release, but a patch number tells users "upgrade blindly," and this release deliberately breaks any program that was mutating frozen values and getting away with it. That's a minor bump and a bolded line in the release notes. Version numbers are communication, not bookkeeping.
The Reward: Stop Copying Things That Can't Change
Here's why all of that was act one and not the whole story.
Lattice, like a lot of languages that want to make shared-state bugs impossible, copies aggressively. Pass a map to a function: copy. Send an array to another thread over a channel: copy. Spawn a thread that uses data from the parent: copy everything it touches. This is a perfectly honest design — there's no spooky action at a distance because there's no sharing — and profiling real programs showed it was also, by far, the dominant cost. Not the interpreter loop. Not allocation. Copying data that nothing was ever going to modify.
Read that sentence next to the language's central promise and the opportunity is obvious. A frozen value can't change — now provably, on every engine. Copying a thing that cannot change is pure waste. Other ecosystems have versions of this insight: Clojure's persistent data structures share internal nodes precisely because they're immutable; Rust's Arc exists to share read-only data across threads; copy-on-write strings were a whole era of C++. The common thread is always the same bargain — immutability, if it's actually enforced, is a license to share.
So act two: when you freeze a container in Lattice now, the runtime materializes it — takes one full snapshot of the whole structure into a sealed, self-contained memory region with an atomic reference count. From then on, "copying" that frozen value means bumping the count and handing out a 72-byte handle. Pass it to a function: a count bump. Send it across a channel to another thread: a count bump, where there used to be two full deep copies. When the last handle goes away, the region's memory is returned, all at once. Thawing a value back to mutable does a real copy — so the language's isolation model is untouched; you can never mutate something someone else can see.
Not everything participates. Tiny values — numbers, short strings — keep the old behavior because copying eight bytes is cheaper than refcount traffic. Values containing closures or other inherently-shared machinery are excluded by a classifier and quietly fall back to the old copying freeze, with identical semantics. And freezing itself got more expensive: that one-time snapshot is real work, about 1.6x on a freeze-heavy microbenchmark, which is now watched by an automated regression gate so it can't silently grow. You pay once at the freeze, and you stop paying forever after.
The payoff, measured on the default engine:
- Aliasing a frozen 200,000-element array 2,000 times: 0.03 seconds, versus 3.49 seconds with sharing disabled. About 116x.
- Sending a frozen 100,000-element array to another thread: 0.06 versus 2.14 seconds.
- Four threads sending and two receiving 80,000 messages of frozen data: memory usage flat at ~21 MB whether you run half or all of it — nothing accumulating, nothing leaking, verified under AddressSanitizer.
The Bugs That Were Already There
The implementation rolled out in stages — first the machinery with nothing connected to it, then the tree-walker, then the default VM — and every stage went through independent adversarial review before merging: one reviewer checking the work against the design, another reading every changed line with a single hostile question in mind (does everyone who frees this still own it?), and a third rebuilding everything from scratch and trying to break it with programs. Every stage, without exception, the review found a real bug. Two of them are worth retelling because neither was actually new — and that's what makes them interesting.
The crash that had been shipping for months. During the tree-walker integration, a reviewer constructed an innocuous-looking program — freeze an array of strings, run it through an iterator with a little callback — and the process aborted. The cause: the iterator code was handing values to callbacks and then freeing those values again afterward, even though the callback machinery had already taken ownership and freed them. A double-free. Here's the kicker: the same program, with the freeze removed, crashed the previous released version of the language too. This bug predated the entire project. It had been shipping for months, invisible, because the test suite happened to exercise iterators with integers — which the memory manager doesn't track — instead of strings. The new reference counts didn't create the bug. They made it loud: a refcount hitting zero twice trips an assertion immediately, where a heap double-free corrupts quietly and crashes somewhere else, later, if at all.
The leak that used to be invisible. On the default VM, the reviewer found that error handling — try/catch, error propagation — threw away in-flight values during stack unwinding without releasing them. Before sharing, each abandoned value leaked a private copy: a small, bounded, invisible heap leak that nobody had noticed in years. After sharing, each abandoned value held a reference count forever — a catch block in a loop would pin megabytes of supposedly-reclaimable frozen data permanently. The proof-of-concept accumulated 1,500 stranded references in 300 caught errors. Same code path, same bug, years old; the new machinery just converted it from invisible to alarmed. The fix releases everything between the old and new stack positions, exactly once — and incidentally fixed the years-old copy leak too.
There's a general lesson in this pair, and it's the thing I'd most want a reader to take away even if they never touch Lattice: strict resource accounting is a bug detector for bugs you already have. A system that deep-copies everywhere is forgiving — ownership mistakes cost you a leaked copy or a redundant copy, and nothing ever screams. The moment you introduce sharing with exact reference counts, every pre-existing ownership mistake in the codebase becomes a fire alarm. It feels like the new feature is breaking everything. It's doing the opposite: it's telling you what was already broken.
What Actually Caught the Bugs
None of these bugs were caught by the test suite being green. The suite was green the whole time — that's the uncomfortable part. They were caught by process choices that I'd defend for any project of this shape:
Write the failure matrix before the fix. The nineteen-test enforcement matrix, written before any guard was added, is what turned "I think there are holes" into a punch list, and it's what proves the holes stay closed on all three engines forever after.
Build a kill switch that proves equivalence. The sharing machinery has an environment variable that disables every sharing path at runtime, so the whole system degrades to old-fashioned copying. CI runs the entire test suite both ways and requires identical results. Sharing is supposed to be invisible to program behavior; the oracle makes "supposed to" a checked property instead of a hope. When the two modes disagree, you've found a bug by construction.
Pay someone to be hostile. Every merge went through review whose explicit job was to break the change, not to approve it — including constructing crashing programs, not just reading diffs. I'll be honest that much of this was orchestrated with LLM agents running the inventories, implementation, and reviews in parallel (a post for another day), but the methodological point doesn't depend on the tooling: the adversarial pass found a shippable bug at every single stage of this project. A green suite tests what you thought to test. An adversary tests what you didn't.
Keep redundant implementations honestly redundant. Three engines is an expensive luxury, and this project is the best argument I have for it: nearly every bug here was visible as a disagreement between engines before it was visible as a failure in any one of them. When three implementations of the same rule diverge, at least two of them are telling you something true.
Where It Stands
As I write this, the sharing work is live on two of the three engines, with the third in progress, followed by a hardening pass under ThreadSanitizer and a proper benchmark accounting. The enforcement work — the part that makes the language's central promise true — shipped in v0.5.0 across all thirteen platforms Lattice builds for, along with unified channel-send rules and a small milestone for the self-hosted compiler.
The shape of the whole project is the part I keep turning over. The performance feature was only possible because the semantics were enforced. Enforcing the semantics surfaced a memory-corruption bug hiding behind a missing error check. Building the performance feature surfaced more pre-existing bugs, because exact accounting converts silent corruption into immediate alarms. At every step, the work of making the promise true paid for itself in bugs found — before the optimization saved a single microsecond.
Immutability that the runtime actually guarantees isn't just a correctness feature, and it isn't just a performance feature. It's a foundation that makes both kinds of claims checkable. Most promises in software are like that: the value isn't in making them. It's in building the machinery that would notice if they were false.
Lattice is open source at lattice-lang.org. It compiles and runs on macOS, Linux, Windows, and the BSDs with nothing beyond a C11 compiler, and you can try it in your browser at the playground. If you want the deeper technical history, see the earlier posts on the phase system and the bytecode VM.