An outliner sync engine that is right for the use case A developer building an offline-first outliner app has designed a sync engine using Durable Objects and SQLite, targeting a single-user workspace that can handle up to 1.125 million nodes over ten years of use. The architecture, inspired by celld, prioritizes structural integrity over text merging, allowing concurrent additions to coexist while field edits resolve by version agreement. The project emerged from the developer's daily use of outliners like Logseq and Roam Research, with the first day of testing revealing about 100 fixes and a peak of 450 nodes created in a busy workday. I love outliner apps. Well, love might be a strong word, I don't know. There was always something special for me about having what I write belong to a graph instead of files. For the past few years, be it Logseq, Roam Research, or Obsidian with a pile of plugins, an outliner has been my go-to place for any writing that matters. It has also been the side project I kept not starting. An outliner that feels right is a graph editor pretending to be a text file. I wanted to build an app which is offline-first, but where sync isn't an afterthought. Plus I really want to keep using things that right now are inseparable from my daily workflows, like automated note entries from emails, meetings, work tasks, etc. With the recent jump in what coding agents can do, I finally tried. This is the first of what I expect to be several posts about the technical problems along the way. This one is about the sync engine, because that is the part I was most afraid of, and also the part where I think I found a sensible architecture. Building from constraints The app is single-player. One workspace belongs to one person, but they can use it across many devices. That means edits will very rarely happen at the same time. There might still be various CLI, MCP, or API integrations pushing data to the workspace independently. I use those a lot. But they rarely edit an existing node I'm working on, mostly they add new material. So I'm fine with not implementing text merging within a single node for now. I need to retain the structure and keep both nodes when two clients append something at the same time. If they do edit the same field, they need to agree on which version wins. I'd still like to be able to retrieve the other version. How much data are we talking about? Before working on the sync engine, I built an offline version that uses SQLite and started using the editor with the goal of keeping it as my main destination for all notes during the day. The first day was rough. There were probably around a hundred fixes I had to make in the background. I had to work late on my actual work tasks that day. As soon as I had the basic outliner workflow working, I checked how many nodes I create during a busy workday. That came down to 450 nodes. A node is a single line of text in an outline, or it can be a long document. What I was usually missing in Logseq was the ability to toggle a mode for a node so I could work on it like it was a document. In my case that just means switching the default behavior of the Return key and adding some subtle indicators in the UI. A sidenote here, I think writing documents in outliners should be an edge case. When I write an article draft, I do it as an outline. Document mode is useful for things like a polished, formatted email that is ready to copy and paste, or a Slack update I'm finalizing. In the future, I think a good workflow would be to have the full source in an outline, then a predefined command which uses AI and my skill config to write the content as a document node. One that is clearly connected to the source, so I can see when they go out of sync. Based on those 450 nodes, a very rough estimate of ten years of usage during workdays would come to around a million nodes in a workspace. At 250 working days a year, it's 1.125 million. That's the size I want the backend to handle without issues. It also needs room for the operation history and attachments, which can grow much faster than the node count. And although I'm building for a single user, they might have several machines, with one falling months out of sync. I want that machine to catch up normally when it comes back online. The build Recently I'd been reading a lot about Durable Objects and celld https://celld.dev/ , a self-hosted implementation of that model, so naturally that was top of mind as the solution. One account gets one Durable Object with its own SQLite database. Each account owns one personal workspace. Cloudflare explicitly describes documents and users as sensible units for separate objects https://blog.cloudflare.com/sqlite-in-durable-objects/ . This doesn't give every user separate hardware, and one busy account still has to fit within one object's throughput. It does mean that adding users doesn't require putting all their writes through my own shared sequencer. For a personal outliner, that is a useful place to draw the boundary. Storing files One limitation I ran into was size. A Durable Object has a 2 MB limit on a string, BLOB, or table row, and a 10 GB SQLite database limit https://developers.cloudflare.com/durable-objects/platform/limits/ . I want users to be able to store files as nodes. They might be large files, and I don't want the database's row limit to decide what fits. If I can store a file on my machine, I'd like to be able to store it in the outliner as well. Plus, I want sync to be cheap to maintain. So I decided to store file nodes as a hash and metadata pointing to a blob uploaded to Cloudflare's R2 object storage. Apart from getting the file out of the database row, it's much, much cheaper per gigabyte stored. Paid storage beyond the included allowances costs $0.20 per GB-month in a Durable Object https://developers.cloudflare.com/durable-objects/platform/pricing/ and $0.015 in R2 Standard https://developers.cloudflare.com/r2/pricing/ , about thirteen times less. That's storage only, requests and compute have their own costs. The Durable Object keeps the file registry, including references and transfer progress. R2 keeps the bytes. I'll still need an account quota policy, but attachments won't be competing with the edit log for that 10 GB database. I also needed somewhere for authentication data and the account directory, so D1 was a natural choice to avoid complicating the infrastructure. php flowchart TD API "