How we made Notion available offline
All posts←
←All posts
Published December 11, 2025 in Tech<br>How we made Notion available offline<br>By Raymond Xu<br>Software Engineer, Notion
Share this post
Last August, we announced that users could create, edit, and view Notion pages without an internet connection. "Offline Mode" was our #1 requested feature for many years, but Notion's unique block architecture meant we had to solve several challenging problems around reference tracking, background syncing, and rich-text conflict resolution. This post explores the architecture and data model that took Offline Mode from just an idea to a production-ready feature.
Storage Layer
For years, Notion has used SQLite to cache records locally and speed up page loads. This cache was best-effort: It didn't have guarantees about which records would be available or for how long. In an always-online world, that was fine—whenever data was missing or stale, the client could simply ask our servers for the latest version.
Offline Mode required a much stronger guarantee. A page had to be fully usable without a network connection, which meant downloading every record that the page depends on and keeping those records up to date over time. To support this, we evolved our SQLite cache into a persistent storage layer that:
Tracks which pages are available offline
Stores all the data required to render each offline page
Records why each page is available offline
Tracking offline pages
We track which pages in your workspace are fully available offline and only let you access these pages when you have no internet connection.
This is done for two main reasons. First, pages that are marked as available offline are dynamically migrated to our new CRDT data model for conflict-resolution. Second, when offline, we never want to show a page that might be missing data. Opening a page and seeing half the content “missing” would be a worse user experience than not being able to open it at all, and make conflict-resolution less predictable.
Our first idea was straightforward: Keep a single set of pages that are actively synced for offline use. We would add a page to the set when “Available offline” was toggled on, and remove it when the toggle was turned off.
This broke down once we introduced automatic downloads and “offline inheritance” (pages becoming available offline because of their parent page). For example, consider a page that is both:
Explicitly marked “Available offline”, and
Automatically downloaded because you visited it recently
In the simple set model, turning off the explicit toggle would remove the page from the set entirely, and we would incorrectly lose offline access , even though the page should still be kept offline due to recent activity.
Balancing how Notion downloads and keeps recent pages and favorite pages offline was an early challenge.
Offline inheritance adds another layer of complexity. Notion pages can contain databases, and databases contain pages. If you mark a database as available offline, we wanted to automatically download up to 50 pages in the current view. Those database pages would be available offline via inheritance, even if you never toggled them directly.
The above examples weren't possible with the simple set approach. Clearly, we needed a more nuanced data model.
Offline trees
Our solution was to maintain a forest of offline page trees that track the reasons that each page is being kept available offline .
The key ideas are:
A page can have multiple independent reasons that it is available offline (toggled, auto-downloaded, inheritance, etc.)
We should only remove a page from the offline set when the last reason disappears
We also need to track page hierarchy so that offline state stays consistent as pages move, databases change, or new pages are added
We model this with two tables:
offline_page
One row for every page or database that is available offline. This is our offline set.
offline_action
One row for each reason a page or database is available offline.
Columns
origin_page_id
The root of this offline tree: the page or database that was initially made available offline
from_page_id
For databases and database pages, the parent page in the offline tree, if it exists
impacted_page_id
The page kept offline as a result of the origin being made available offline. This can be the origin itself
type
The reason, such as toggled or favorite
Invariant: every row in offline_page must have at least one row in offline_action where it appears as
impacted_page_id. If a page has no such rows, it is removed from offline_page
Example: Suppose we have a database D with 2 rows, X and Y, and we toggle “Available offline” on D. We write into offline_action:
If X is also favorited, we add another action:
Now X has two reasons to be offline.
If we toggle off D, we delete all actions with origin_page_id = D, but X remains available offline because its type=favorite action still...