← Blog

Offline is the default, not a mode

· Jason @ Swiftfox Software

Most apps treat offline as a failure state. You get a spinner, then a banner apologizing that you “appear to be offline,” then a UI where half the buttons silently don’t work. For a read-it-later app, that’s not a bug, it’s a broken premise. The entire point is that you saved things for later — and later has a stubborn habit of happening on planes, in subway tunnels, and in cabins with one bar of signal.

So Squirrel Reader is built around a blunt internal benchmark we call the airplane test.

The airplane test

Put your phone in airplane mode and use Squirrel Reader for a week. Everything should work: read articles, search your library, tag things, highlight passages, jot notes, reorganize collections, listen to downloaded episodes, save new links. Then turn the network back on and watch everything reconcile — no conflict dialogs, no “which version do you want to keep?”, no lost work.

Passing this test isn’t a feature you bolt on at the end. If “offline mode” is a separate code path, it’s the code path nobody exercises, and it rots. The only way it stays working is if it’s not a mode at all. That one constraint dictates most of the architecture.

The UI never talks to the network

Your entire library lives in an on-device SQLite database, and the UI reads only from it. Every screen — library, reader, search, collections — is a local query. The network exists solely as a background sync concern: a pull loop that folds server changes into SQLite, and a push loop that replays your local edits upward.

This means there’s no “cached copy” versus “real copy.” The local database is the app. There’s no online rendering path and a sadder offline one — there’s one path, and it happens to work identically at 35,000 feet.

One honest asterisk: article parsing is server-side (our server fetches the URL and strips it down to clean text — your phone never does the scraping). So a link saved in airplane mode lands in your library instantly, but its readable text arrives after you reconnect. Everything already in your library is fully, permanently there.

Every write is a retry-safe upsert

Every row’s primary key is a UUID generated on the device that created it. That one decision does a surprising amount of work: since the client already knows the row’s identity, every write to the server is an idempotent upsert. Push it once, push it five times after flaky-network retries — same result. There’s no “did that POST actually go through?” ambiguity, and no duplicate rows born from nervous retries.

Deletes are soft (deleted_at timestamps), so even deletion is just another row update that flows through the same pipe.

A queue that drains itself

Every local mutation does two things in a single SQLite transaction: write the row, and append an operation to a pending-ops queue. When connectivity returns, the queue drains strictly in order.

Failures are sorted into two buckets. Transient ones — network blips, server hiccups, an expired token — keep the op and retry with exponential backoff, from the same position, so ordering is never violated. Terminal ones — say, a free-tier cap enforced by a database trigger — drop the op, re-fetch the server’s verdict for that specific row, and surface a sync notice, instead of wedging the queue forever behind one doomed write.

A week of airplane-mode edits is just a longer queue. On the pull side, incoming rows never overwrite a row with queued local edits: your unpushed change wins locally until it’s pushed, and then the server settles it.

Reading progress only moves forward

Most rows merge with boring last-write-wins, which is fine for a title or a tag. Reading position is the one field where that’s actively wrong. You read to 80% on your phone over lunch, offline. Your iPad synced at 45% an hour later with a fresher timestamp. Last-write-wins would happily throw away your afternoon.

So progress merges through a forward-only server function: the device that read further never loses the position. A five-minute session window keeps this from being annoying — if you deliberately scroll back to re-read something, that’s treated as part of your current reading session, not a regression to be vetoed.

Search works in a tunnel

Search is a local SQLite FTS5 index, maintained by triggers as rows change. Queries over titles, tags, notes, and podcast transcripts — and on Premium, the full text of every saved article — run entirely on-device. No request, no spinner, results in milliseconds with zero bars. Search that depends on a server isn’t search you can trust on a commute.

Podcasts and videos get the same treatment through downloads: pull an episode down over Wi-Fi and playback needs nothing from anyone.

What this is really about

The airplane test isn’t really about airplanes. It’s a forcing function for a simple stance: your library belongs on your device, and the network’s job is bookkeeping, not gatekeeping. There’s a pleasant side effect for the “calm, not addictive” pillar too — when the app loses connectivity, the status line simply says it’s offline and that changes are saved locally and will sync when you reconnect. That’s it. Not a warning, not an error. Just a fact, the same way a squirrel doesn’t consult a server before digging up something it buried in October.

Squirrel Reader is on the App Store for iOS today, with Android on the way. If you try the airplane test and find something that doesn’t survive it, I genuinely want to hear about it: support@squirrelreader.com.