Why We Don't Yet Use a Database
We evaluated Turso/libSQL for our iPad handwriting app, measured everything and chose flat snapshot files. The workload does not need a database — each step should pay only for problems that already exist.
Lulucat Notes is an iPad handwriting app. Until last week, it had one canvas and no concept of a second note. We were about to add a note library — multiple documents, each with multiple pages — and the first architectural question was how to store it.
A database appeared to be the obvious answer. Notes apps store structured data. Structured data goes in databases. We evaluated Turso and its Swift SDK, had it running on the iOS simulator, benchmarked real stroke data, and then chose not to use it.
We chose flat files instead. This is what we found and why we made that call.

Photo by Gabriel Cox on Unsplash. Unsplash licence.
What the app actually does with data
A handwriting app has a narrow and predictable data access pattern. Reading means opening a page and loading every element on it — all the strokes, all the images — into memory at once. The canvas holds everything; it never runs a partial query. Writing means finishing a pen stroke and appending one element to the page. In rare cases the user erases part of a stroke, moves a selection, or deletes something, but those are still single-page, single-element operations.
There is no concurrent access. One person writes on one page of one document at a time. There is no cross-document search — the note library only needs a title, a timestamp, a page count, and a cover thumbnail for each document, none of which require reading page content.
Queries, indexes, and concurrency coordination are what databases are built for. Our app uses none of the three.
The Turso evaluation
We evaluated libsql-swift, the official Swift SDK for Turso’s libSQL engine.
The SDK works. All nine of its test cases pass. We integrated it into a copy of the app, built for the iOS simulator, launched it, and created a local database in the app sandbox. We wrote 100 strokes of 3,400 sampling points each — 4,080,000 bytes of BLOB data — in a single transaction. It took about 0.019 seconds on our development Mac.
After running PRAGMA wal_checkpoint(TRUNCATE), the WAL file shrank to zero and we could copy the main .db file alone to another location, open it, and read back all the data. The engine itself is sound.
The SDK has costs. The CLibsql.xcframework weighs 161 MB. After linking, our Debug simulator build went from roughly 1.9 MB to roughly 8.2 MB. The API is synchronous and blocking, with no Swift Concurrency wrappers. There is no explicit close() method. Transaction.commit() does not throw — the underlying C API returns void. The repository’s README describes the SDK as a “technical preview,” and the most recent commit was about a year before our evaluation, in July 2025.
The Turso ecosystem has a gap. Turso now recommends its new “Turso Database” engine and “Turso Sync” protocol for new projects. Turso Sync has client SDKs for TypeScript, Python, Go, and Rust. It does not have one for Swift. The older Embedded Replica mode exists in libsql-swift, but its Swift initializer does not expose the offline parameter needed for a fully local-first mobile app. Adopting libsql-swift today gets us a local SQLite fork but not the synchronisation capabilities that make Turso distinctive.
What a database would cost us right now
Even if the SDK were mature, we would still be paying costs that do not buy anything for our workload:
WAL sidecar management. A running database creates -wal and -shm companion files. Copying a document means either checkpointing first or copying all three files atomically. Exporting a .lnote package to Files or AirDrop now requires a pre-export step that the user cannot see and the developer cannot forget.
An adapter layer. Strokes would need to be serialised into BLOBs and deserialised back. Page elements have a natural array order that the canvas renders directly; a database would introduce row ordering and z-index columns. We would be writing a translation layer between two representations of the same data, and maintaining it across every schema change.
A 161 MB dependency. For an app whose Debug build is under 2 MB, a dependency larger than 80× the app itself is a cost worth noticing — especially one labelled “technical preview” with a year of inactivity.
These costs are not hypothetical. They start the moment the dependency is linked. And they buy us capabilities — querying, indexing, concurrent writes — that our app does not use.
The solution we shipped: snapshot file packages
A .lnote document is a directory package:
Documents/Notes/<UUID>.lnote/
manifest.json # library cache: title, time, page count, cover
document.json # source of truth: document metadata + page order
pages/
<page-uuid>.content # one snapshot per page
assets/ # document-level shared resources
<asset-uuid>.jpg
thumbnails/
<page-uuid>.jpg # per-page thumbnail; first page doubles as cover
document.json is the source of truth for the document’s structure: its ID, title, timestamps, and an ordered list of pages with each page’s canvas size, timestamps, and element count. Page content files store the element array in the same quantised integer encoding the app already uses — coordinates and radii at 0.1-point precision, pressure in thousandths, timestamps in relative milliseconds.
The note library reads only manifest.json and cover thumbnails. It never parses document.json or any page content. Opening a page loads one .content file. That is the only file read that touches stroke data.
Pages solve write amplification
A handwriting app already has the concept of a page — it is the unit users think in, the thing they swipe between. Making the page the unit of persistence means auto-save only rewrites the pages that changed.
A single page of handwriting — say, 1,000 to 2,000 strokes — occupies roughly 3 to 5 MB in our quantised format. One 21-stroke recording with 3,400 sampling points quantises to about 55 KB. Writing one page snapshot to flash storage takes 10 to 20 milliseconds on modern hardware. With a 0.5-second debounce, saves are invisible to the user.
The save cost scales with the amount of writing on the current page, not with the total number of pages in the document. A 200-page notebook saves exactly as fast as a 2-page notebook, because only the dirty page is rewritten.
Every write uses atomic file operations — write to a temporary file, then rename — so a crash mid-save cannot produce a truncated page. Entering the background flushes all dirty pages immediately, matching the behaviour the app already had with a single canvas.
Consistency without transactions
File packages do not have transactions, but they have clear ownership rules that serve the same purpose:
Resources before references. When the user inserts an image, the asset file is written to assets/ immediately. The page snapshot, which references the asset by ID, is written later by the debounced auto-save. At no point does a page reference an asset that does not exist on disk.
Source of truth wins. document.json and the pages/ directory are the source of truth. manifest.json is a cache. If they disagree, the next save reconciles the cache to match the source. Thumbnails are derived and can be regenerated at any time.
Orphans over dangling references. The worst outcome of a crash is an orphan asset — a file in assets/ that no page references. Orphans are cleaned up when the document is closed. The reverse — a page referencing a missing file — cannot happen, because assets are written before the referencing page snapshot.
These rules are simpler to reason about than WAL checkpointing and transaction isolation, and they match the single-process, single-page access pattern of the app exactly.
The upgrade path is written down
Choosing flat files now does not mean choosing flat files forever. The package structure is designed so that upgrading the storage engine changes what is inside the package without changing the package itself.
Level 1: snapshot + append journal. If write amplification ever becomes perceptible — say, continuous writing on a page with thousands of strokes causes a noticeable save delay — each page file splits into a snapshot and an append-only journal. New elements are appended as [length][CRC][type][payload] frames. Replaying the journal discards any frame whose CRC does not match, which provides crash safety. When the journal exceeds a threshold or the page is closed, it merges back into the snapshot. This is roughly two hundred lines of code with zero external dependencies.
Because page-level snapshots already eliminate cross-page write amplification, this level may not be needed for a long time. A 5 MB page rewrite every 0.5 seconds is well within flash write budgets.
Level 2: SQLite database. If the app ever needs full-text search across notes, per-element synchronisation, or cross-document indexing, SQLite becomes the right tool. The likely engine at that point is GRDB, which is a mature, source-compiled Swift wrapper with near-zero binary size overhead. libsql-swift would only be reconsidered if the Turso ecosystem — specifically Turso Sync for Swift — becomes a real product need.
The migration path is straightforward: each page’s element array maps to a strokes / images table with one immutable BLOB per stroke (16 bytes per sampling point in little-endian binary). The 0.019-second benchmark for 100 strokes confirms the approach is viable. Before shipping, migration would verify that element counts and asset counts match between the old package and the new database, and that crash recovery, WAL bounds, and background flush all pass.
When to pay
This decision is not a judgment on databases. SQLite handles concurrent writers, complex queries, and crash recovery across shared state — none of which our app currently needs. Paying for those capabilities before the app has the problems they solve is a net loss.
The costs of a database — the dependency, the WAL management, the adapter layer, the binary size — start the moment the library is linked. The benefits begin when the app has queries to run, indexes to maintain, or concurrent writers to coordinate. At this stage it has none of the three.
Each step in our storage evolution will only pay for problems that have already appeared. Page-level snapshots pay for the problem we have today: saving multi-page documents without rewriting the entire file. If write amplification becomes measurable, an append journal will pay for that problem. If search or sync becomes a product need, a database will pay for that problem.
The package structure, the manifest, and the document schema do not bind to any storage engine. Switching costs are low because the boundaries are in the right place. When the day arrives that we genuinely need a database, we will adopt it for a specific, already-measured problem — not for a hypothetical one.