Skip to content
Notion Arena

Notion platform

The sync, in six figures

The same six pictures the rest of this section argues in prose. Each one is drawn from lib/diagrams.ts, exported to PNG by scripts/export-diagrams.mjs, and footed with the files it was measured against, so a claim that stops being true can be caught by reading the figure next to the code.

How to read them

  • happens on every sync, no condition
  • conditional: offline, or a platform refusal
  • ordering and containment, not transport
  • Arena, and the widget's own write
  • landed in the workspace
  • waiting outside Notion
  • the platform refused it

Every identifier in a figure is a real one: notion-arena.habit-grid.v1 is the key public/widgets/habit-grid.html writes, 1500 ms is the stream poll in app/api/sync/instances/[id]/stream/route.ts, and 450 ms is the debounce in lib/bridge/client.js. Nothing here is a placeholder rounded for looks.

The six

Figure 01

PNG, light
Where a component's state lives today A component already holds the user's data. It holds a different copy on every device, and inside Notionit holds it in storage the browser is allowed to refuse. Three devices, three separate copiesThis laptop3 expenses savedexpense-tracker.html, savedPhoneemptysame component, a new key spaceWork laptopemptyand one more againnothing crossesInside the Notion pagethe same frame, either channellocalStoragenotion-arena.expense-tracker.v1third-party storageevery write sits in try/catchSafarian opaque origin, so every write throwsChromepartitioned per site: the data is not the page'sThe widgetreports success either way, because it caught it192widgets in public/widgets166declare a persistence key111listen for storage events0call fetch or postMessage counted across the 192 files in public/widgets, 2026-10

Figure 02

PNG, light
One instance, two renderers, one system of record The embed calls home on every render. The file Notion runs cannot reach the network at all, so it queuesinstead, and Arena rewrites the file when the data moves. Your page in Notionthe page owns the frame, and the frame is the sandboxembed block/embed/habit-grid?i=inst_8f2a&k=sk_…networkHTML blockhabit-grid-2a9c.html, a file we can rewriteno networkArenathe only party holding a Notion tokenbindings index1 instance, 1 data sourcesealed credentialsAES-256-GCM at restchange log, receiptsop ids, so replays areno-opssync engineone rate-limit bucket perconnectionYour workspacethe rows are the state; Arena only caches themHabitDateLevelArena keywater2026-10-115water|2026-10-11water2026-10-123water|2026-10-12read2026-10-121read|2026-10-12Habit Heatmap log, one database per bindingwebhookids only, HMAC-SHA256403the Free plan's capPublish: the file is re-rendered and PATCHed into theblock, so the offline reader refreshes in place.fetch + SSEkey in the URL, never a cookieno network in the sandboxwrites wait in an outboxNotion API writesthe outbox drains, one row at a timethe webhook says a row movedids only, so the engine re-readsTwo renderers, one instance: the same id, the same document, the same key. routes, limits and payload shapes as implemented in app/api/sync/** and lib/sync/**

Figure 03

PNG, light
What happens when someone clicks a cell The widget saves exactly the way it always has. Everything after that is the runtime, one POST, and acommit every other renderer can see. 1The widget saves its own waybrowserlocalStorage.setItem('notion-arena.habit-grid.v1', …)2The runtime diffs itbrowserpath water.2026-10-12, value 3, and it mints one op id3The write is batchedbrowser450 ms of quiet, then POST /api/sync/instances/{id}/sync4Operations apply to rowsArena1 created, 1 updated, 0 deleted in Habit Heatmap log5Only changed properties are writtenArenatoPropertyPayload uses the data source's own types6The commit is what others seeArenarev 5, and the op id is receipted against a replay7Renderers pull the deltaeveryoneSSE within about 1.5 s, a 20 s poll as the fallbackOffline, steps 2 and 3 become a queueops keep their ids, a sync code carries them to a renderer that can reach us, and the replay is a no-op lib/bridge/client.js and lib/sync/engine.ts: syncInstance

Figure 04

PNG, light
A click, and the row it becomes One cell in the widget and one row in the table are the same fact. This is the work lib/sync/mapping.tsdoes, and the shape of the change that goes back to every renderer. The widgetstate, as it saves itwater · 2026-10-12set to 3notion-arena.habit-grid.v1The runtime adopted thiswidget's own key by patchingStorage.prototype, so thewidget needed no edit.One operationwhat crosses the networkidop_7f2a9c81pathwater.2026-10-12value3at1786012345678baseRev5The id is minted by the clientso a retry, or a replayed synccode, cannot write the row twice.The path is where it landsin the widget's own document,derived from the business key.The row in NotionHabit Heatmap logHabitDateLevelwater2026-10-115water2026-10-123read2026-10-121Arena keywater|2026-10-12rich_text, hidden in the UISourcewidget · notionA person can retype the key; theengine treats their edit as the truth.1 op1 rowComing backthe engine sends the delta and the document, because a row is not a locationeach renderer replaces its state; its own diff decides what to repaint, so a heatmap never has to understand a business key lib/sync/mapping.ts: businessKey, pathForKey, toPropertyPayload

Figure 05

PNG, light
Two writers, one cell Who edited what while the other side was not looking. The policy is per field: the later edit wins, and theloser is written down rather than thrown away. Notionwater|2026-10-11Level2the row nowYour phoneset it to 2, offlineop_4b71 · queued10:02:14Your laptopset it to 5op_1c9a · baseRev 410:02:31water|2026-10-11one cellBoth writers meant this one record, and neither of them knew about the other.1Order by timelast_edited_time decides, and our own counter breaks a tie inside one round, so 2 wins2Write onceone row is updated: no duplicate, no second record for the same habit and day3Keep the loserthe value that lost goes into the append-only conflict log and shows as a noticekeep-both is the other policy, used for event-shaped data: two records, both kept, neither silently merged. lib/sync/engine.ts: materialise and the conflict policy; lib/sync/types.ts: ChangeRecord.conflict

Figure 06

PNG, light
The instance is the identity The same rows on the phone as on the laptop, with no account to create and no cookie to carry: theaddress of the component is the instance, and the instance carries a key. The instanceidinst_8f2a41c7componenthabit-gridbound toHabit Heatmap logrev5one component, one bindingIn the address?i=inst_8f2a41c7&k=sk_poQurl9_…The embed, the standalone pageand the downloaded file all carrythe same pair.At restSHA-256AES-256-GCMHashed for lookup, sealed so it canbe re-shown. A Notion token neverreaches a browser.Rotatethe old URL answers 401 at onceRevoke410, and the key can never beshown againAttributeevents carry the key prefix and adevice idEvery renderer of an instance shows the same rows because they share the instance, not because they share a login. lib/sync/instances.ts, lib/sync/ids.ts, lib/sync/crypto.ts

Where these come from

The figures are code, not drawings that drift. lib/diagrams.ts holds them as SVG strings built from measured text, and it is imported by this page as a module: what you are reading and what the exporter writes to public/diagrams/ are the same characters. The export resolves var(--token) through app/globals.css and fails loudly on a token that does not exist, so a colour that stops being part of the design system breaks the build rather than the picture.

What a figure is allowed to say
Each of these was checked against the running slice before it was drawn. Where the slice is only a slice, the figure says so in its own footer instead of implying a finished system: the adapter has been exercised against a demo workspace, not against live Notion, and the four environment questions in docs/SYNC_PLAN.md are still open.

The argument they support, with citations, is in two-way data sync; the platform limits are quoted line by line in API limits, cited.