How a perfectly reasonable design decision turned into 50 MB of data transfer per edit, and what I learned fixing it.
I’ve never really understood why writing applications need so much memory.
I was looking at Activity Monitor recently and noticed that Microsoft Word was consuming 373 MB of RAM to display a document containing nothing but text. That’s not necessarily unreasonable for a modern application, but it still strikes me as a surprising amount of computing power devoted to putting words on a screen.
I’ve had worse experiences. Years ago, I used OneNote to annotate PDFs and collect material for an entire university course. As the notebook grew, the application became progressively slower, eventually reaching the point where it was almost unusable.
Then the notebook became corrupted, and I lost work.
That experience stayed with me.
When I started building Bookend, my own document editor, one of my goals was to avoid that kind of resource consumption. I wanted something that could handle large, complex documents without demanding increasingly powerful hardware just to remain responsive.
I chose Rust for the document engine, with a React frontend packaged using Tauri 2.
I wanted the core to be efficient, portable and independent of the user interface. And for a while, everything seemed to be going pretty well. I was using Bookend for everyday notes, drafting documents and organising small collections of material, usually a dozen pages at a time. Everything was responsive, the features were coming together, and I was becoming increasingly pleased with myself. I’d even started distributing development builds to friends and family, buoyed by an entirely disproportionate level of confidence in software that had existed for approximately two weeks.
Then I went to Japan.
When the documents got bigger
In late September, I travelled to Japan for a work conference. I brought an older M1 MacBook Pro with 8 GB of RAM, intending to use Bookend while I was there.
Alongside my conference notes, I was working with a much larger knowledge base that I’d been building and reorganising. It contained 864 pages and approximately 40,600 blocks of content, including cross-references, tables and other structured information. This was considerably larger than anything I’d been using during development, and much closer to the sort of document Bookend was actually intended to handle.
Initially, everything worked. But as I spent time reading and editing the knowledge base, the application became progressively less responsive. Typing started to lag, the interface occasionally froze, and operations that had previously been instantaneous became noticeably slower.
I opened Activity Monitor.
Between the main application and its WebKit helper processes, Bookend was consuming approximately 5 GB of memory.
It was an unpleasant discovery, particularly because avoiding this sort of behaviour had been one of my original motivations for building the application. I’d chosen Rust partly for its efficiency, and I’d deliberately separated the document engine from the user interface. I thought I’d made sensible architectural decisions. Clearly, something wasn’t working as intended.
The interesting question was why.
The innocent decision that started everything
To understand the problem, I had to go back almost to the beginning of the project.
On 13 September, when Bookend was only a few hours old, I implemented the mechanism by which the Rust engine communicated changes to the React frontend.
The idea was simple: every command would return a snapshot of the current application state. React would replace its existing state with the new snapshot, ensuring that the interface always reflected the authoritative document model.
The commit message described it as:
Each command returns a Snapshot that becomes the new UI state (single source of truth).
That sounded perfectly reasonable. The relevant Rust code was essentially:
let stale = editor
.refresh_staleness()
.into_iter()
.map(|id| id.to_string())
.collect();
Ok(Snapshot {{
book: serde_json::to_value(&editor.book)
.map_err(|e| e.to_string())?,
stale_blocks: stale,
warnings,
dirty,
can_undo: editor.can_undo(),
can_redo: editor.can_redo(),
path,
conflict: None,
}})
The important line is:
book: serde_json::to_value(&editor.book)
Every command returned the entire book. At the time, my test documents contained about five pages. Serialising the whole thing was trivial, and the architecture had a considerable advantage: the frontend never had to work out which parts of the document had changed. It simply received the latest version of everything. This was convenient. Unfortunately, it was also the beginning of the problem.
Death by a thousand perfectly reasonable features
Over the following eleven days, I added a substantial number of features.
References could point to objects elsewhere in the book. Expressions could depend on values in other pages. Live blocks could become stale when their inputs changed. Documents could be edited on multiple devices and merged. Images were imported into the asset store. Autosaving and external file-change detection were added.
Individually, none of these was an especially unreasonable feature.
But each one inherited the same assumption: the entire book was readily available whenever anything happened.
For example, reference resolution initially lived in JavaScript. To resolve a reference to a block or table cell, the frontend could search through the book.
That was easy because the whole book was already sitting in React’s state.
Dependency tracking worked similarly. Whenever a snapshot was generated, Rust checked which expressions and live blocks had become stale.
And autosaving reused the same snapshot mechanism.
So did the external-change detector.
By version 0.9.3, an ordinary paragraph edit looked something like this:
I finished editing a paragraph.
React sent a small command to Rust containing the modified block.
Rust applied the change.
Rust checked dependencies across the book.
Rust serialised the entire book into JSON.
Tauri transferred that JSON to the frontend.
JavaScript parsed it into a new object graph.
React replaced its existing book state and re-rendered the relevant interface.
Half a second later, autosave returned another complete snapshot.
At the next checkpoint, the book was sent again.
There was also a particularly ridiculous detail.
Every 15 seconds, Bookend checked whether the file had changed externally.
That check also returned the entire book.
Even when nothing had changed.
Even when I wasn’t touching the computer.
I’d somehow arranged for my application to send approximately 50 MB of JSON to its own interface four times a minute, just to confirm that nothing interesting had happened.
I suspect this was not the intended meaning of reactive programming.
The frontend had become a second database
The problem wasn’t simply that the transfer was large.
Once the frontend held a complete copy of the book, it became natural to implement features that depended on having that copy. Reference resolution, page paths, tags, mirrors, chart pickers and other interface features all read directly from the JavaScript book. By the time I recognised the problem, the frontend had accumulated hundreds of places that assumed the complete document model was available.
So I couldn’t just stop sending the book. I’d built an architectural dependency on the very behaviour I needed to eliminate.
This is one of the more interesting ways software becomes complicated. Nobody needs to make an obviously terrible decision. A series of individually convenient decisions can gradually make the original design impossible to change.
Measuring the damage
Back home in Australia, I started a more systematic investigation.
The first task was to measure the problem properly. I added a performance harness to the Rust command-line tools and measured the size and serialisation time of different books. My initial test was reassuring.
Suspiciously reassuring.
It turned out I’d benchmarked the wrong neurology book. That one was a much smaller import from OneNote, with a JSON payload of around 2 MB. When I repeated the measurement on the actual 864-page knowledge base, the result was considerably less encouraging.
50.4 MiB of JSON.
For one command response.
The original book was around 22 MB on disk, but its serialised representation was more than twice that size, and that was before JavaScript parsed it into objects.
In the debug build, serialising the book took approximately 1.2 seconds. An instrumented application response took about 1.7 seconds.
Those timings need an important qualification: they were measured in debug builds, on an M4 Pro development machine. When I later reproduced the old benchmark in release mode, serialisation took approximately 55 milliseconds rather than 1.2 seconds. So, it would be misleading to claim that every edit in the released application spent more than a second serialising JSON, but the size of the transfer was unchanged. A release build still had to construct, transfer and parse approximately 50 MB of JSON, and the frontend still had to deal with the resulting object graph.
That was the real problem.
Why 50 MB of JSON can consume much more than 50 MB of memory
A serialised document isn’t the same thing as its in-memory representation.
At various points, Bookend could have several representations of the same information:
The live Rust document model.
A second copy retained as a baseline for conflict detection.
A temporary serde_json::Value tree.
The serialised JSON string.
The JavaScript string received through Tauri.
The parsed JavaScript object graph.
Previous JavaScript objects still retained by interface components.
Temporary buffers created during autosaving.
Some of these were short-lived. Others remained in memory.
The frontend also had an unbounded cache of base64 image URLs, and the undo history could grow indefinitely.
To make matters worse, some interface controllers captured references to older versions of the document, preventing those objects from being released when React received a new snapshot.
This created a mixture of retained state, repeated allocations and garbage-collection pressure.
On an 8 GB laptop, that was a fairly effective recipe for memory compression, swapping and intermittent freezes.
I should be precise about one thing here: I never captured a complete heap profile of the original 5 GB incident.
The figure came from Activity Monitor, combining Bookend and its WebKit helper processes. I can’t account for every byte, and I can’t prove that there was a conventional memory leak.
The architecture certainly explains why memory consumption could become enormous, but repeated allocation and retention are not necessarily the same as a leak.
I also never recorded a reliable before-and-after resident-memory comparison.
What I can measure is the data-transfer problem and the work performed by the engine.
And those measurements were sufficiently alarming on their own.
The plan: stop treating the frontend as the book
The first explicit performance plan was written on 30 September, while I was still in Japan. It described the central problem fairly accurately: Bookend’s memory and processing costs were growing with the size of the entire book rather than with the page being edited. I wanted to establish a different rule:
The engine should understand the entire book, but the frontend should only need the pages it is actually displaying.
That sounds obvious in retrospect.
It wasn’t how I’d built the application.
The solution required changing several parts of the architecture together.
Rust would remain the authoritative owner of the document model.
The frontend would retain a lightweight navigation manifest and a working set containing the active page, plus another page if the editor was split.
Instead of returning the whole book after every command, Rust would report only the pages that had changed.
References would be resolved through Rust queries rather than JavaScript scans.
Autosaving and external-change checks would return status information rather than document snapshots.
And I needed a reliable way to determine which pages had actually changed.
That last requirement turned out to be particularly interesting.
Copy-on-write and a change journal
The original document model stored pages as ordinary Rust structures. To detect changes, I could have instrumented every mutation operation to explicitly report which objects it modified. But Bookend already had a substantial number of editing commands, and I wasn’t enthusiastic about adding bookkeeping to every one of them.
Instead, I changed the document model to use copy-on-write pages stored behind Arc<Document>. An Arc is a reference-counted pointer. Cloning it doesn’t duplicate the underlying document. It simply creates another reference to the same allocation. When a page is actually modified, copy-on-write creates a new allocation for that page. That gives us a useful property. If the pointer hasn’t changed, the page hasn’t changed.
The change journal can therefore compare the document pointers before and after a command:
for (id, a) in &after.documents {{ match before.documents.get(id) {{ Some(b) if Arc::ptr_eq(a, b) => {{}} _ => changed.push(Unit::Document(*id)), }} }}
That’s the core of the mechanism. No complicated comparison of every paragraph, and no need for dozens of mutation functions to remember to register their changes. If the page was modified, copy-on-write guarantees that it has a different pointer and the journal records that page as changed.
This also made transactions easier to reason about. Before applying a command, Bookend can cheaply retain the previous state. If the command fails, it can restore that state rather than leaving a partially modified document. The result was both a performance improvement and a cleaner model of document mutations. I rather like solutions that do both.
Sending changes instead of books
With the journal in place, I could replace the old snapshot protocol. Previously, a command response contained something like:
Snapshot
└── book
├── section
│ ├── chapter
│ │ ├── page
│ │ ├── page
│ │ └── ...
│ └── ...
└── ...
Every edit transferred the entire structure.
The new response contains only the changed pages and enough metadata to apply them safely:
Snapshot
├── documents_changed
│ └── edited_page
├── through_seq
├── epoch
├── book_id
├── manifest_dirty
└── status
The frontend keeps its navigation manifest and currently loaded pages. Then,when a response arrives, it updates only the affected pages.
If the structure of the book has changed, it refreshes the manifest.
If a changed page isn’t currently loaded, the frontend doesn’t need to retain it.
The important distinction is that Bookend now transfers whole changed pages, not individual changed blocks. That’s a deliberate compromise. Page-level updates are simple enough to manage reliably, while still being dramatically smaller than transferring an entire book.
For my knowledge base, individual page responses ranged from roughly 1.6 KB to 278 KB in a later benchmark, with a median around 54 KB.
During the original refactor, one logged edit response was just 20,357 bytes. Compared with the original 52.8 million bytes, that’s a reduction of approximately 99.96% for that particular edit.
I should emphasise that 20 KB isn’t a universal constant. A larger page produces a larger response, but the critical scaling relationship has changed. An edit to a small page no longer requires transferring hundreds of unrelated pages.
One subtle problem: what if a response gets lost?
Once I introduced incremental updates, I had to think about something the old architecture largely avoided.
How does Rust know which changes the frontend has received?
The journal assigns sequence numbers to changes.
The frontend requests everything since its last acknowledged sequence.
Initially, it would have been tempting to advance the acknowledged sequence whenever Rust constructed a response, but constructing a response doesn’t mean the frontend actually received and applied it. If a response were lost, the next request could skip changes the frontend had never seen.
So, the protocol uses an explicit acknowledgement. Rust sends the changed pages with a through_seq value. The frontend applies them, then calls:
ack_applied_cmd(through_seq)
Only then does Rust advance its record of what the client has seen.
The updates contain absolute page state rather than instructions such as “insert this character at position 12”. That makes duplicate delivery safe: receiving the same page twice simply replaces it with the same content.
This is one of those details that is easy to overlook until you start thinking about what happens when communication fails. And it led to one of my favourite discoveries during the refactor.
The test harness caught a bug almost immediately
Before switching the application over to the new protocol, I built a differential test harness that reconstructed the book from the sequence of page updates and compared the result against the authoritative Rust document.
The idea was simple: if the new protocol was correct, both representations should remain identical.
Within minutes of manual testing, it found a problem. When I opened a different book, the change journal reset its sequence number and epoch. The frontend saw what appeared to be a valid sequence of updates and applied them to the previous book.
Apparently, identifying a document by its position in a sequence isn’t sufficient when you can open an entirely different document.
Who could have predicted that?
The fix was to include a book_id in every update.
Now the frontend can distinguish between a new revision of the current book and a completely different book.
I was particularly pleased that the harness caught this before the new protocol became the default.
There was also a slightly ridiculous irony: the development-only harness reconstructed and compared the entire book after every edit, performing exactly the sort of expensive operation I was trying to eliminate.
At least this time it was deliberate.
The optimisation that made everything slower
The data-transfer problem wasn’t the only performance issue.
Bookend also tracks dependencies between document objects. A calculation might reference a variable elsewhere in the book, or a live block might depend on content in another page. When a source changes, Bookend needs to know which results may have become stale.
The original implementation checked the entire book whenever a snapshot was generated. On my knowledge base, this took about 200 milliseconds in the debug build.
My original performance plan was for a more sophisticated dependency index. That seemed sensible. The existing implementation included linear searches for object revisions, so I built an index mapping object IDs to their revisions. Surely replacing repeated searches with a lookup table would be faster.
It wasn’t.
The staleness check increased from approximately 200 milliseconds to 350 milliseconds. I’d made the application slower by optimising it.
Fortunately, I’d instrumented the individual phases of the operation. The results were revealing:
Index construction: 129 ms Live block checks: 0.9 ms Expression scan: 196 ms
The index was adding 129 milliseconds of work while barely improving anything.
Why? Because my enormous notebook contained zero live blocks. I’d optimised the expensive-looking dependency lookup path, but that path wasn’t responsible for the measured slowdown.
The actual problem was much simpler.
Bookend was scanning approximately 40,000 blocks looking for inline expressions. Most of those blocks were ordinary prose containing no expressions at all, and yet the engine was repeatedly parsing them to discover that fact.
The eventual fix was an early return: if a rich-text block contained no expression marker and had no recorded expression dependencies, there was nothing to check.
A few lines of code.
The staleness pass dropped from approximately 200 milliseconds to around 20 milliseconds in the debug build. I reverted the index.
This is probably the most satisfying part of the entire refactor. I’d started with a sophisticated algorithmic improvement, discovered that it made things worse, and replaced it with a simple check that avoided unnecessary work. Sometimes the best optimisation isn’t doing something faster. It’s not doing it at all.
A confession about incremental dependency tracking
My original performance plan called for a fully incremental reverse-dependency index, but that isn’t what I ultimately implemented. The current staleness checker still walks through the book, it just skips the overwhelming majority of blocks very cheaply.
On the neurology knowledge base, that remaining pass takes approximately 5 milliseconds in a release build. I could still build a more elaborate incremental dependency engine, but doing so would introduce additional state that has to remain correct as references, dynamic queries and document structures change.
At the moment, the simpler implementation is fast enough. I’ve become increasingly reluctant to replace something simple and reliable with something complicated just because the complicated version sounds more architecturally impressive.
Fixing the other sources of memory pressure
The main protocol change removed the largest source of unnecessary data transfer, but there were several other problems worth addressing.
Images
Originally, images were loaded into the frontend as base64 data URLs and stored in a JavaScript cache that had no upper bound. Every image viewed during a session could remain in memory. I replaced this with a custom bookend-asset:// protocol, allowing the WebView to request images as resources and use its normal caching behaviour. Images can also be loaded lazily.
Undo history
The original undo stack could grow indefinitely. Worse, editing a cell could retain an entire table as an undo entry. I introduced limits of 500 entries or 32 MiB.
An unlimited undo history sounds generous until it starts competing with the operating system for memory.
Autosaving
The old autosave mechanism repeatedly serialised the working book. The refactor introduced an overlay-based persistence system that writes changed content rather than rebuilding everything for each autosave. Unchanged entries can also be copied through when creating checkpoints. THis was particularly useful because saving a document shouldn’t require reconstructing every unrelated page.
React rendering
The old frontend replaced the entire book object after every command, which meant that every page, block and string received a new JavaScript identity, even when the underlying content hadn’t changed.
I separated stable command handlers from changing page state and introduced memoisation for several kinds of block views. Not everything is memoised. Text blocks, charts and flowcharts still have rendering paths that could be improved, but the frontend no longer needs to treat every edit as a replacement of the entire book.
The results
The main refactor took place over the first week of October, and became Bookend 1.0.0. The before-and-after measurements on my real neurology knowledge base were substantial.
The original 20 KB response was one measured example, not a fixed payload size.
Later measurements across the knowledge base gave a median page response of approximately 54 KB.
And the memory improvement?
Qualitatively, it was immediately apparent.
The application became substantially more responsive, and the enormous memory pressure I’d encountered in Japan was no longer the normal experience, but I don’t have a reliable numerical before-and-after memory benchmark, so I’m not going to invent one.
The 99.96% figure refers to data transferred for a particular edit, not a 99.96% reduction in memory consumption. Those are different measurements and I think the distinction probably matters.
Then my regression tests missed another problem
After completing the refactor, I wanted to make sure I never accidentally reintroduced the original architecture, so I added scaling tests.
One test constructs books containing 70 and 700 pages, edits a page, and checks that the response size doesn’t grow substantially with the total number of pages. Another explicitly checks that command responses no longer contain a complete book object. These tests would have caught the original problem. I was quite pleased with them.
Then, a week later, an architectural audit found that Bookend was still unnecessarily transferring pages. This time the problem involved live blocks. The copy-on-write mechanism relies on pointer identity. If a page hasn’t changed, its pointer should remain the same. But one part of the staleness code called Arc::make_mut on pages containing live blocks before checking whether anything actually needed to change. Because the transaction retained a reference to the previous state, calling make_mut could clone the page.
The journal then saw a different pointer and concluded that the page had changed, so every page containing a live block could be marked as modified and included in the next response, even when the edit was completely unrelated.
The audit measured approximately 1.3 MB per command with 20 live-block pages. Not as bad as 50 MB, but still entirely unnecessary.
Why hadn’t my scaling tests caught it?
Because the synthetic test books contained no live blocks.
I’d written a test to ensure that response size didn’t scale with book size, then constructed test books that avoided one of the most important ways response size could scale with book size. A pretty impressive achievement in testing the wrong thing.
I fixed the copy-on-write behaviour and added regression tests using pages with live blocks. But the experience exposed a more general limitation: the original scaling tests measured response bytes, not CPU work or memory use.
They also used extremely simple documents.
A 700-page book containing a heading and a paragraph on each page is not remotely equivalent to a real 700-page technical reference with tables, expressions, live blocks and cross-references. The tests were useful, but they weren’t as comprehensive as I’d initially imagined.
And there’s one further qualification: these particular tests are currently part of the Tauri shell’s manually run test suite, rather than an automated CI pipeline. So the next improvement isn’t just adding more tests, it’s making sure the important ones run reliably and use representative documents.
What I’d do differently
Looking back, I don’t think the original snapshot design was inherently foolish.
For a small application, returning a complete state snapshot after every command is a perfectly reasonable approach. It’s simple, easy to debug and difficult to get out of sync.
The problem was allowing that convenience to become a permanent architectural assumption.
Once the frontend depended on having the entire book, every new feature made the eventual refactor more difficult. There are a few lessons I’ll carry into future projects.
1. Data transfer is part of the architecture
I’d spent a lot of time thinking about the efficiency of the Rust engine. But it doesn’t matter how efficient your engine is if you repeatedly serialise its entire state and send it somewhere else. The boundary between components deserves as much attention as the components themselves.
2. A single source of truth doesn’t mean copying the truth everywhere
Rust was supposed to own the document model. In practice, the frontend had become a second representation of the entire book, with its own searches, caches and derived state. Making Rust genuinely authoritative required changing what information the frontend was allowed to retain.
3. Measure before optimising
My failed dependency index is a useful reminder. I identified something that looked algorithmically expensive and built a more sophisticated replacement. Then I measured it and discovered that the replacement was slower. The actual bottleneck was a completely different operation. A few lines of early-return logic solved the problem more effectively than the index.
4. Test the thing you actually care about
I wanted the cost of editing a page to remain independent of the size of the book. My tests checked response sizes for simple documents. Those are related, but they aren’t the same thing. Realistic fixtures matter. So do CPU measurements, memory measurements and tests covering the less common features.
5. Sometimes the simple implementation is good enough
I still haven’t built the fully incremental dependency engine described in the original plan. The current whole-book pass is fast enough for my actual documents, and it’s considerably easier to reason about. That may change as Bookend grows. But until it does, I’m happy to leave it alone.
The slightly embarrassing conclusion
The most interesting part of this experience is that I didn’t accidentally make Bookend slow by choosing an inefficient programming language or a particularly heavyweight framework.
The Rust engine was doing exactly what I’d asked it to do.
So was Tauri, and so was React.
The problem was the way I’d designed the application to move information between them. I’d built an efficient document engine, then arranged for it to repeatedly reconstruct and transmit the entire document whenever something happened. Because the application initially worked perfectly well on small books, the problem remained largely invisible until I tried using it for the sort of substantial document it was supposed to support.
In retrospect, taking an older machine on a trip was probably one of the more useful things I could have done for the project. It exposed a problem that my much more powerful development machine had been pretty happy to conceal.
Bookend is now substantially better for the experience. The frontend only retains the pages it needs, document changes are tracked explicitly, and ordinary edits no longer require transferring the entire book.
There are still things I’d like to improve. Some operations still scan more of the book than necessary, and the current protocol transfers complete changed pages rather than individual blocks. But I’m much happier with the architecture.
More importantly, I’ve learned to be suspicious whenever a convenient implementation quietly makes the cost of a small operation depend on the size of an entire system.
I built Bookend because I wanted a document editor that didn’t become increasingly expensive to use as my documents grew. For a little while, I’d managed to build exactly the opposite. At least I noticed before writing another 800 pages.