7 minute read
Offline mode was Notion's #1 most requested feature for years. And every time someone brought it up, there was always some response in the vein of "how hard can it be? Just cache the pages."
Pretty hard. It took a PhD dissertation and a complete rethink of how Notion stores data locally.
Notion's engineering team published the full architecture breakdown in December 2025, and it's worth reading if you want to understand why "just add offline" is the kind of request that makes backend engineers sigh audibly.
The SQLite Cache That Wasn't Enough
Notion had always used SQLite locally. But it was a best-effort cache: no guarantees about what was there, no guarantees about how fresh it was. If something was missing, the client asked the server. Works fine when you're always online.
Offline mode required a completely different contract. A page had to be fully usable without any network access, which meant every record the page depends on had to be pre-downloaded and kept current. The cache couldn't be opportunistic anymore. It had to be authoritative.
That's a different system. Not an enhancement; a rebuild.
The Set Problem
The first attempt at tracking which pages to keep offline was straightforward: maintain a set. Page gets toggled "Available offline," add it. Toggle off, remove it.
It broke immediately once they added automatic downloads and inheritance.
Consider a page that's both explicitly toggled offline and auto-downloaded because you've visited it recently. If you toggle it off, the simple set model removes it entirely. But it should still be offline because of the recent visit reason. You'd silently lose offline access to a page you expected to have.
Then there's inheritance. Notion pages contain databases, and databases contain pages. Mark a database offline and Notion wants to automatically bring its top 50 rows with it. Those child pages are offline because of the parent, not because you touched them directly. The set model has no way to represent that.
Offline Trees
The solution was to model this as a forest of offline page trees, where each node tracks all the reasons it's being kept offline, not just whether it is.
Two tables. offline_page has one row per page currently available offline. offline_action has one row per reason a page is offline: toggled explicitly, auto-downloaded, inherited from a parent database, favorited.
The invariant: a page stays in offline_page as long as it has at least one row in offline_action. Remove a reason, the page only drops off when the last reason disappears.
So if you untoggle a database, Notion deletes all offline_action rows where that database was the origin. But if a child page also had a separate favorite action, it stays. Clean, auditable, correct.
This matters way more than it sounds. The alternative is a confusing mess where pages randomly disappear from your offline set depending on what order you touched things. Notion solved it with a data model instead of case logic.
Keeping Pages Fresh Without Melting the Server
Once a page is offline, it has to stay current. Someone else edits it from another device, you need those changes.
The naive approach is polling: every client periodically asks the server "anything new?" for each offline page. Works in testing. Doesn't work at millions of users and devices.
Notion's approach: push-based updates wired into their existing snapshot system. Whenever a batch of edits lands on a page, the server fires a message on that page's channel. Clients subscribed to that channel fetch the delta.
Coming back online after being offline? Each page tracks a lastDownloadedTimestamp. On reconnect, Notion compares that against the server's lastUpdatedTime and only fetches pages where the server is ahead. No unnecessary refetches of pages nobody touched.
Structural Drift
Notion's page hierarchy isn't static. Pages move. Databases gain and lose rows. Inline databases get added and removed. The offline forest has to track all of this without leaving stale entries or incorrectly dropping pages.
When Notion refetches an offline page, it compares the fresh server state against the current offline_action table and applies the minimum diff: inserting actions for new descendants that should inherit offline status, deleting actions for rows that fell out of the view. The forest stays eventually consistent with the live workspace without full rebuilds.
This is the part that's genuinely hard. Keeping a local representation of a dynamic graph in sync with a remote one, at scale, without making it feel laggy or broken, is a distributed systems problem wrapped in a UX problem.
What Shipped and What Didn't
The v1 that launched in August 2025 works. You can write on a plane, in a cabin, anywhere. CRDT-based conflict resolution handles text merges well enough that solo offline editing is basically seamless. If you want to understand how that works at the rich-text level, Notion engineer Slim Lim co-authored Peritext, the paper that solved a lot of this, and it's worth the hour it takes to read.
The limitations are real though. Databases cap at 50 rows. Background sync had a bug at launch where pages weren't actually updating in the background. Notion's team said they fixed it in August but Thomas Frank's testing found it still unreliable for a while after.
These aren't surprises. They're what happens when you ship a v1 of something genuinely hard instead of waiting another two years for something perfect.
The Actual Lesson
"Just add offline" is a phrase that probably costs Notion engineers years of their lives in aggregate. The architectural debt that made this hard wasn't negligence; Notion's always-online model was a reasonable bet for years. Offline breaks the foundational assumption and requires a new storage contract, a new sync model, and a new way of representing page state.
The gap between "why hasn't this shipped yet" and "oh, that's why" is the gap between knowing what a product does and knowing how it works.
Notion's engineering post is worth your time. Not just for the Notion-specific stuff, but for how they explain the data model trade-offs. The offline_action table design especially. That's the kind of thinking that separates teams who build things correctly the first time from teams who end up with a pile of edge case handlers six months later.