A reading app for iOS and Android, the book database behind it, the site you're reading this on, and the pipeline that ships all of it. I build and run the whole thing, so this is the whole thing — what it does, how it works, and why I made the calls I did.
Almost everything happens right on your device. Screens read through hooks, hooks read and write AsyncStorage, and a set of services owns the logic that outgrew a hook. None of that needs a network.
Only two things reach out to the network, and both are optional: book metadata lookups when you search or open a book you haven't cached, and sync when you've turned it on.
Before the internals, the surface. This is the whole feature set as it stands today — it's a lot for a solo project, and most of it exists because someone asked for it.
Nearly all of that works with no signal, because your library lives on the device. The three things that genuinely need the network are the ones you'd expect: searching for a book you don't own yet, the ISBN lookup after a barcode scan, and sync. Everything else — shelving, rating, note-taking, the timer, insights, export — runs offline.
There's no database. Every collection is a single JSON value in AsyncStorage, read whole and written whole. No SQLite, no ORM, no query layer.
Keys are namespaced under leafed: and defined in one place, src/config/storageKeys.js — 33 of them. Ten hold the library itself and are the ones that sync:
leafed:books — the book catalogleafed:shelves — shelf definitions, system and customleafed:shelfItems — the book-to-shelf links, where ratings, dates and mood/pacing tags actually liveleafed:notes — quotes and notesleafed:readingSessions and leafed:readings — sessions, and one record per read-through so re-reads don't overwrite each otherleafed:readingGoals, leafed:savedForLater, leafed:excludedBooks, leafed:dismissedDuplicatePairsThe rest are local-only: preferences, timer state, an insights cache, prompt gating, and per-day counters. One key is a deliberate exception to the naming convention — the analytics opt-in is stored as @leafed/analytics_opt_in, and it's one of only two keys that survive a full app-data wipe. The other is the tombstone list, for reasons that matter in the sync section.
Reading a whole collection to touch one record is fine at personal-library scale, right up until it isn't. Reading sessions hit that wall first: scanning every session to answer "what did I read in March" got slow enough that I hand-rolled a date index, leafed:readingSessionsByDate, which is a thing a database would have given me for free.
Migrations are similarly hand-built. There's no schema version — each migration owns a boolean flag key and checks it on startup, five of them so far, running inline during the shelves hook's load rather than from app init. It works, and it's clearly a version counter that grew legs.
Now the part I could easily have glossed over: there's no storage abstraction layer. No repository, no DAO, no wrapper module. storageKeys.js exports key constants and a clearAppData() helper — it does not wrap getItem and setItem.
55 files import AsyncStorage directly. 41 of them are outside src/services: 12 hooks, 9 screens, 16 utils, 3 components, 1 context.
The de-facto boundary is the hooks, not the services. useShelves is 1,176 lines and is where migrations run, shelves mutate, and writes get marked for sync. That's the real seam in this codebase, and it's a hook.
The services that do exist got extracted when logic outgrew a hook, not as a designed tier:
| Service | Owns |
|---|---|
iCloudSync/ | The sync engine — SyncManager (1,119 lines), MergeEngine (881), TombstoneManager. The largest subsystem in the app. |
sync/ | Provider abstraction and the optional account layer. Picks iCloud or an account transport at runtime. |
ReadingSessionService | Session CRUD plus the shelf and read-through side effects that fire when a session is saved. |
mergeBooks | Merging two book records into one, repointing notes, sessions and shelf items at the survivor. |
duplicateDismissals | Which duplicate pairs you've told the app to stop asking about, plus garbage collection. |
analyticsService | The two-gate analytics setup described in privacy. |
aiAvailability | Rate limiting and a circuit breaker for AI features. Currently gated off. |
ratingPromptService | Store-review eligibility — 600 lines and 14 separate trigger points, because asking at the wrong moment is worse than not asking. |
revenuecat | In-app purchases. Configured with an API key and nothing else — no user id, no attributes. |
tipPrompt, milestoneTip, monthlyWrapped | Prompt gating, each with its own persisted state and cooldown rules. |
Honestly, this shape grew rather than got designed. It holds together because the storage API is only three methods wide and the key constants live in one place, so routing everything through a wrapper would mostly be ceremony. It would stop holding together the day storage stops being AsyncStorage, and I know it.
Leafed reads from seven book data sources. There is no single resolution order, because search, details and barcode scanning are different problems and I ended up solving them three different ways.
Open Library and the Leafed indie database are queried concurrently, then merged. Indie failure is caught to an empty result set, so it can never block or fail a search. If Open Library returns nothing, indie results stand alone.
Indie results are ranked above Open Library. That's a positioning decision, not a relevance judgement — the indie catalogue is the part of Leafed that doesn't exist anywhere else, and merging it by relevance would mean it effectively didn't exist at all.
That decision came back to bite me pretty quickly. The indie API matches loosely: query talk and it returns Stalked by Saint, matching inside the word. Promoted above a properly ranked Open Library page, those rows pushed the real answer off the visible list on exactly the short, common queries that are hardest to search. So there's now a relevance gate that partitions promoted results into ones that earned the placement and ones that get demoted below Open Library. So the gate is really there to clean up after the ranking decision.
Opening a book runs a four-stage fallback:
The results are merged field by field with explicit precedence rather than whole-record — titles prefer a stored value, then Google, then Open Library; ratings prefer Open Library, then Google, then Hardcover, and the winning source gets recorded on the record so it's auditable later.
Barcode scanning is a third pattern: Open Library by ISBN, then Google Books, then Project Gutenberg, each wrapped so a failure only warns and moves on.
Matching books is done at three different strictnesses on purpose:
| Layer | Matches on | Why this strictness |
|---|---|---|
| Search dedupe | Exact lowercased title + first author | Cheap and runs on every keystroke's worth of results. Deliberately naive. |
| Sync identity | Namespaced hard keys: record id, Open Library work key, ISBN-13, or source + external id | Title and author are deliberately excluded. This is the set that can cause a merge or a delete during sync, and no fuzzy signal should ever be able to do that silently. |
| Duplicate detection | Equal ISBN, or equal work id, or fuzzy title ≥ 0.9 and author ≥ 0.7 | Fuzzy matches are shown to you, never applied automatically. Only ISBN and work-id matches can be bulk-merged. |
A manually entered book has no external identity at all. It gets a generated id — timestamp plus random suffix — and that id is its only hard identity key unless you type an ISBN. Which means a manual book can never be automatically matched against the same book from an API. That's the correct trade-off for avoiding false merges and a real limitation if you type in a lot of books.
Covers come from Open Library, Google Books, the indie database, or a local file for manual entries. A backfill job fills gaps: once a day, up to 40 books, 300ms apart, walking a five-stage ladder that ends at Apple's search API.
The fiddly part is detecting a cover that isn't really there. Open Library serves a 1×1 pixel rather than a 404 for ISBN-derived cover URLs that don't exist, so the check rejects those URLs by shape before trusting them. Misses get stamped with a timestamp and a version number and retried weekly; hits bump the record's timestamp so the fix propagates through sync like any other edit.
You can use every feature of Leafed without an account, and one is never created implicitly. There is an optional passwordless account, and it exists for exactly one reason: it's the sync transport when iCloud isn't available, which on Android is always.
Sync is off by default either way. Nothing leaves the device until you turn it on.
| State | iOS | Android |
|---|---|---|
| Signed out | iCloud, one JSON file per collection in your own private container | No sync provider at all. Manual export/import only. |
| Signed in | Account transport on both platforms, replacing iCloud while signed in. | |
The core rule is whole-record last-write-wins keyed on updatedAt, biased to the local copy on ties or missing timestamps. Not field-level merge. If two devices edit the same book, the newer edit replaces the older one entirely.
That rule governs books, shelves, shelf items, notes, sessions and read-throughs. Five collections deliberately don't use it:
The union choice is my favorite one here. For a list of "duplicate pairs I've dismissed", last-write-wins can lose a dismissal, and losing one means the app starts nagging you about a pair you already resolved. Union's worst case is that a dismissal you undid comes back — a nuisance. Last-write-wins' worst case is data loss. So union wins, and undismissing goes through a tombstone instead.
Deletes are recorded as tombstones, and this is probably the fiddliest corner of the whole thing. Two things are doing real work here:
Deletions are always recorded locally, on every platform, even when sync is off. Otherwise anything you deleted before enabling sync gets resurrected by the first merge. This is also why the tombstone list survives a full app-data clear.
Tombstones only apply to local records for books. For every other collection they're applied only to remote-only records. That asymmetry is intentional, and the cost is that delete propagation is partial rather than complete: a shelf or note deleted on one device can survive on another. I took that trade because the alternative is worse — a stale tombstone applied locally could delete a system shelf and orphan every item on it.
A local write marks its collection dirty and debounces 500ms before uploading, with three retries on failure. A full sync pulls and merges tombstones first, flushes pending dirty collections, then syncs books and shelves sequentially — because both produce id maps that the remaining collections need to resolve their references — and only then syncs everything else in parallel.
Merging re-reads local storage up to three times to fold in writes that landed during the merge, which was the fix for a class of bug where a fast typist could lose an edit to their own sync.
A repair pass runs at the end: it de-duplicates shelves, reassigns orphaned items to a best-guess shelf based on their own data — a finish date means finished, a start date means reading, a rating means finished — and drops duplicate book-shelf links keeping the newest.
Export produces versioned JSON — currently 2.0 — plus CSV and Markdown. It's the same on both platforms; it just matters much more on Android, where it's the only data-portability route when you're signed out.
Import is deliberately not last-write-wins. It's additive and local-preferring: existing books are never overwritten, a rating is filled in only where you don't already have one, and notes are only added for books that have none. Importing a backup can add to your library but can't quietly rewrite it.
The privacy properties come from the structure, not from a policy. Here is every outbound call the app can make.
| Trigger | Destination | What's sent | Optional |
|---|---|---|---|
| Search, book lookup, author suggestions | Open Library | Your query text or a book identifier | Core |
| Displaying covers | Open Library covers | A cover or ISBN id in the URL | Core |
| Detail enrichment, barcode fallback, imports | Google Books | Title, author or ISBN | Needs a configured key |
| Ratings when nothing else has them | Hardcover | Title, author or ISBN | Flag + token |
| Best-seller lists | New York Times | List name and date | Flag + key |
| Search and indie browsing | Leafed indie database | Query text, paging and filters | Feature-flagged |
| Barcode third fallback | Project Gutenberg | ISBN or query | Needs a key |
| Cover backfill, final stage | Apple search | Title and author | Feature-flagged |
| Tapping an audiobook link | Affiliate network | The destination URL | Only on tap |
| Contact form; optionally submitting a book you typed in | Leafed email endpoint | Your message and email; book title and author if you opt in per book | You initiate it |
| Analytics signals | TelemetryDeck | An event name and a small count or label | Off unless you turn it on |
| Purchases and restore | RevenueCat | Store product ids and receipts | Only if you buy or restore |
| Sign-in and sync | Account backend | Your email, then your library | Only if you sign in |
| Update check on launch | Expo | App version metadata | Platform-level |
Two of those are unconditional: Open Library search and Open Library covers. Everything else is gated behind a flag, a key, an opt-in, or an action you took.
Analytics require both a build-time flag and a runtime opt-in that you set. The client is not merely muted when you haven't opted in — it is never constructed. The opt-in defaults to off, nothing in the app writes it to on except the switch in Settings, and turning it back off destroys the instance.
When it is on, the user identifier passed is the literal string anonymous. Signal payloads carry an event name and at most a version string, a source label, a count, or a trigger name. No book titles, no authors, no ISBNs, no search queries, no email, no device id.
I'd rather say this plainly than have you find out later: signing in sends your book titles and reading history to a Leafed-controlled server. That's what sync is. Rows are isolated per user at the database level.
Two things are not sent. The raw API payload cached on each book — several kilobytes of Open Library and Google Books response — is stripped before upload and restored locally afterwards. And covers for manually entered books are local files, so they don't transfer, which is why a manual book can arrive on a second device without its cover.
Authentication is a magic link with a six-digit code fallback. No password, no social login, no phone number. Signing out keeps your local data. Deleting your account keeps your local data too.
No crash reporter. No ad SDK. No Sentry, Crashlytics, Firebase, Mixpanel, Amplitude, Segment, PostHog or Facebook SDK — the app has no advertising identifier permission and no tracking-transparency prompt, because there's nothing to ask for. Errors are caught by an in-app boundary that logs to the console and offers a retry.
One honest caveat on "no trackers": RevenueCat, Expo's update service and the affiliate network are third-party endpoints too. They're not analytics, and none of them receive your library, but I'd rather name them than claim a tidy zero that quietly skips past them.
One deliberate gap in this page. The indie database has a public submission path, and I've left its request shape, field names, paths and throttling behavior off this page on purpose. Same for keys and internal routes. You'd notice the hole either way, so I'd rather name it than pretend the page is exhaustive.
Open Library and Google Books don't index much small-press or self-published work, which is a real gap if that's what you read. So Leafed has its own book database, and I run it: a public submission form, a moderation step, and an admin panel behind it.
It's deliberately boring technology — PHP and MySQL on shared hosting — because it needs to keep working with no attention for months at a time, and because the app is designed to survive it going away entirely.
| Piece | What it does |
|---|---|
| Public API | 12 endpoints serving search and book detail to the app and to the site's browse page, in an Open Library–shaped response so the client treats it like any other source. |
| Submission form | Anyone can submit a book. Bot-protected, and nothing reaches the catalog without passing through moderation first. |
| Admin panel | Session auth against a hashed password, edit, soft-delete and restore, and an audit log recording who changed what, when, and from where. |
| Cover pipeline | Submitted cover URLs get fetched, re-encoded to a consistent 600×900 JPEG, and self-hosted rather than hotlinked — so covers don't break when someone else's bucket goes away. |
| Analytics | Search and view logging on the catalog side, which tells me which books people look for and don't find. That's the queue for what to add next. |
The cover pipeline is the piece I'd point at. It fetches user-supplied URLs, which means it's a server-side request forgery hole if you write it naively — so it validates the scheme and rejects private and reserved IP ranges before it will fetch anything, and re-encodes rather than trusting the bytes it gets back. I'd rather build that carefully once than find out the interesting way.
Adding books to the catalog one form submission at a time doesn't scale past about ten, so most of what I built next was for me rather than for users.
None of this is glamorous and all of it is the reason the catalog exists at all. The operational work is usually what decides whether a side project survives its first year.
94 flags in one plain object, imported by 104 files. No remote config, no override UI, no environment switches except two that derive from whether an API key is present. Flipping a flag means a rebuild.
That's a real constraint — I can't dark-launch or kill a feature remotely. What I get instead is that the flag state is whatever shipped, provable by reading one file, with no drift between what I think is on and what's on for a given build.
Most flags gate route registration and rendering; the code is still bundled. Three change behavior structurally rather than visually: one removes a collection from the syncable set entirely, one selects a different merge algorithm, and one prevents the account SDK from ever being loaded.
622 tests across 40 suites, all passing. The coverage isn't even, and the shape of it is a fair map of what worries me: sync and merge logic carry the most tests, followed by insights, title matching, security, search relevance, duplicate detection, and migrations.
There are no component render tests and no tests for the sync manager itself — it's mocked in the tests for everything that depends on it, which means the orchestration layer is verified by hand and on device rather than in CI. The merge engine underneath it, which is the part that could actually lose your data, is a pure function over two arrays and is tested heavily. That order was deliberate rather than accidental — though it's still a gap I'd like to close.
Two app stores, a web app, an API and a marketing site, all shipped by one person. That only works if releasing is boring, so most of the effort has gone into making it boring.
There's also a real constraint I designed around: Android now requires 16KB memory page alignment, which meant auditing native dependencies for compliance rather than discovering the problem at submission time. Platform requirements arrive whether or not the roadmap has room for them.
The engineering decisions on this page are mostly about correctness. This one isn't, and it's the code I've rewritten most.
The store-review prompt is 600 lines and eleven separate trigger points — after a reading session saves, after a goal completes, after an import finishes, after a five-star rating, after a share. Never on a cold launch, never mid-task, never twice.
The reason it's that long is that asking at the wrong moment is worse than not asking at all. A prompt that interrupts someone mid-sentence in their notes costs you the review and some goodwill. A prompt right after they finish a book they loved is a fair thing to do. Encoding "a good moment" turns out to take a lot more logic than encoding "thirty days after install".
The same shape covers the rest of the asks. Tip prompts, the monthly wrapped, and milestone nudges each carry their own persisted state and cooldowns, each has an escape hatch that stops them permanently, and all of them are off in the moments where they'd be irritating. Support for the app is a tip jar rather than a paywall — nothing in the feature list above is gated behind paying me — and books link out through several booksellers rather than just the obvious one.
None of that is technically hard. It's the part that decides whether people keep the app, which makes it worth more attention than it usually gets.
It was the fastest thing that worked, and a personal library is small enough that reading a whole collection into memory has never been the bottleneck. The cost is real: no indexes, no queries, and a hand-rolled date index for sessions because scanning them all got slow.
There's a compensating benefit I didn't plan for. Because a collection is one readable, writable unit, the merge engine is a pure function over two arrays. With rows in a database I'd have needed row-level change tracking before I could sync anything at all.
I'd pick SQLite starting today. I'm not migrating 100,000 lines to prove a point.
Field-level merge is better on paper and needs per-field timestamps to work — which means either a schema change on every record or a shadow structure tracking modification times per field. On a flat JSON blob that's a lot of machinery for the actual conflict rate, which for one person on two devices is close to zero.
The cost is that a genuine simultaneous conflict loses the older edit entirely — rate a book on your phone and add a note on your iPad within the same sync window and one of them can lose. The exceptions in the sync section are the places where that felt too steep, so I paid for something better.
The app had no account layer at all for most of its life, and every design decision downstream assumed that. It's why data is local-first, why sync was iCloud-only, and why there's no server-side user identity to leak.
Adding an optional account was the only honest answer to Android having no iCloud. The cost is a genuine complication of the privacy story: "we have no accounts" was easy to say and easy to verify, and "you can use everything without an account, and here's exactly what changes if you make one" needs a paragraph and a table. That's this page's job.
Open Library is the backbone but has real gaps in descriptions, page counts and ratings. Google Books fills those and has its own gaps. Hardcover exists in the chain purely as a ratings backstop.
The upshot is that a book isn't one single thing. It's a merged record with per-field precedence and a recorded provenance for ratings, and the identity code has to be careful enough that merging never silently deletes. That's where most of the tricky code lives — MergeEngine and the identity helpers, not the UI.
Covered above: no remote kill switch, in exchange for zero drift and no config service to run, secure or pay for. For a solo-maintained app I'd make this trade again.
Leafed is a labor of love. Nobody is paying for this, there's no team and no roadmap pressure, and features land when I finish them. That's worth saying plainly, because it explains both the pace and the choices — I optimize for things I'd still want to be maintaining in two years, rather than for a launch.
The optional account is the newest and most interesting part of the system, mostly because of everything it brings along with it. Local-first means the worst case is one person's device. The moment there's a server, the questions change: what's the retention story, what does account deletion actually delete, how do you migrate a schema under live clients that may be several versions behind, how do you know a sync bug happened at all when you deliberately collect no telemetry about people's libraries.
That's the biggest thing this project is teaching me at the moment. The design goal is that the server stays the least interesting component — a transport with row-level isolation and nothing clever in it — so that turning it off would cost you sync and nothing else.
I'm planning a change of name and a visual redesign. The current identity was a placeholder that stuck, and the app has grown past it. The architecture on this page is the part I intend to keep; the surface is the part I intend to redo.
There are features other reading apps have that Leafed simply doesn't yet, and some of them are just work I haven't done. Those get picked off in the order people ask for them — there's a public voting board for exactly that reason, and it's the closest thing to a prioritization system I've got.
The direction I'm most interested in is what's now possible locally: on-device models and embeddings good enough to power semantic search over your own library, better recommendations from your own reading history, and smarter matching for the identity problems described above — without a request leaving the phone.
That's the only version of those features I want to ship. An earlier bookshelf-scanning feature that depended on a cloud vision API was retired rather than kept, and the AI chat assistant sits behind a flag that's off. If the fun version of a feature means sending your library off to someone else's inference endpoint, it doesn't ship.
The constraints stay fixed regardless: privacy, offline-first, optional sync, and no social feed.
A few things mentioned elsewhere on this page, gathered here so they're easy to find: adding real connectivity detection so the app stops inferring offline from failed requests; closing the Android gap where signing out means no sync at all; making delete propagation converge for collections other than books; and making the importer read the version field it already writes.
Leafed is free on both stores. If you've read this far and want to argue with any of it, I'd genuinely like that — get in touch.