All posts
#2 6 min read

Building Jotless #2: autosave bug

A bug that survived code review and died the moment we watched it run: comparing documents by their JSON. Plus the ordering rules that keep autosave honest.

There is a class of bug that reads as correct, passes review, and only reveals itself when you watch the program run. Jotless had a clean example in its autosave, and it is worth walking through slowly, because the wrong version looks more obviously correct than the right one.

What autosave has to do

A scratchpad has no save button, so autosave is doing real work on your behalf. Two requirements pull against each other. It has to write often enough that a crash never costs you a thought. It also has to write rarely enough that it is not hammering the disk on every keystroke. The standard tool for that tension is a debounce: wait until edits stop for a beat, 600ms here, then write once.

There is a wrinkle. The editor produces two different change signals. Typing inside a block fires a low-level text-changed notification but does not fire the model’s own change event. Structural edits, like splitting a block or toggling a todo, fire the model event. Autosave merges both into a single "something is dirty" trigger, then debounces that.

So far, so ordinary. The interesting part is the next question: when the debounce fires, did anything actually change? You do not want to write an identical copy of the note just because the user moved the cursor and the trigger fired anyway.

The assumption that looks right

The obvious answer: keep the last thing you saved, and compare the current note against it. And the obvious way to compare two documents is to serialize both to JSON and compare the strings. If the JSON is identical, nothing changed; skip the write.

This is wrong, and it is wrong in a way that is almost invisible. The early autosave did exactly this, and intermittently wrote to disk on every cursor move, even when the content was untouched. It also produced a flaky test that the static review and the verifier agents all missed.

What actually happens

The culprit is the encoder. JSONEncoder builds an intermediate dictionary and emits its keys in hash-seed order. That order is not stable between encodes. So re-encoding the exact same data can produce two different strings:

// same document, encoded twice
{"id":"…","title":"Notes","blocks":[…]}
{"title":"Notes","id":"…","blocks":[…]}

// byte-for-byte different, so a string compare says "changed", so it writes anyway

The comparison was never really comparing the note. It was comparing two essentially random orderings of the same fields, and those disagreed often enough to defeat the whole point of the check. The dedupe was a no-op dressed as an optimisation.

The fix is to compare values, not text

The document has a plain value type, an Equatable struct. Two documents are equal when their fields are equal, regardless of how anything serializes. So the dedupe compares the value, not a string:

let document = DocumentMapper.document(from: model)
guard document != lastSaved else { return nil }   // value-equality, not JSON text

JSON is for the disk, not for deciding equality. And where the on-disk bytes do need to be stable, such as backups or anything you might diff later, the encoder is pinned with sorted keys so the output is deterministic. Two different jobs, two different tools: structural equality in memory, stable bytes on disk.

The other half: order and switching

Once dedupe was honest, two subtler hazards showed up, both about time, not content. Saves are asynchronous, and the user keeps editing and switching notes while a write is in flight. Three rules keep that from corrupting anything:

  • Saves are serialized. Each write awaits the previous one before it starts, so a slow save and a fast one can never land out of order.
  • The baseline advances only after a write succeeds. If a write fails, the last-saved marker is left untouched, so the next attempt retries instead of quietly assuming the failed data is on disk.
  • A late save must not poison another note’s baseline. If you switch from note A to note B while A’s write is still finishing, the marker only updates when the note that finished is still the note on screen.

There is also a flush: before switching notes, resigning, or quitting, autosave waits for any in-flight write and then persists any edit newer than it. That is the line between "we autosave" and "we never lose your last keystroke," and they are not the same promise.

The lesson worth keeping

The JSON-string bug is a good teacher because every safeguard around it failed. It compiled. It read as correct, arguably more correct than the fix, since "compare the serialized form" sounds thorough. Review did not catch it. The automated verifiers did not catch it. What caught it was instrumenting the running app and noticing it was writing when nothing had changed.

Static reasoning is good at "is this logic sound?" It is bad at "does the platform actually behave the way I assumed?" For that second question there is no substitute for watching the thing run.

Changelog

  • Dedupe on the Equatable document value, not on its serialized JSON string.
  • Pin the on-disk encoder to sorted keys so saved bytes are deterministic.
  • Serialize saves so a slow write and a fast one cannot land out of order.
  • Advance the last-saved baseline only after a write succeeds.
  • Never let a late save of one note poison another note’s baseline.
  • Flush any in-flight and newer edit before switching notes, resigning, or quitting.

Where it stands

Autosave is settled. Edits dedupe on value, saves are serialized, the baseline only advances after a write succeeds, and a flush runs before every note switch and on quit. Eighteen tests cover it and it holds up under stress. The remaining persistence work is about reach rather than correctness: more export formats, and eventually the sync layer the schema was already shaped for.