Deep research report

The Tolstoy PWA, examined

A review of the local-first architecture, sync model, and build pipeline — with specific risks and priority fixes.

Drafted 23 April 2026 · synthesis of five parallel investigations

Part 1How the sync model actually works

The sync model has three independent pieces that people often conflate. Let me separate them.

Piece A — What's being synced

Only your annotations, not the books. The books (works + wiki articles) are public files on tolstoy.life — you download them fresh from the origin when you want to read offline. The only mutable user-owned data is your annotations: bookmarks, highlights, notes, reading position, tags. That's maybe tens of kilobytes for a casual reader, maybe 5–15 MB for a power user after years.

So "sync" means: keep that small blob of annotations in agreement between your phone, your laptop, and wherever else you read. Nothing else is synced.

Piece B — How the conflict-free merge works (the CRDT)

The annotations are stored locally in a thing called a Y.Doc (a Yjs document — Yjs is a well-known open-source CRDT library). A CRDT is a data structure where two people can edit offline, come back online, and the merge is deterministic — no "resolve merge conflict" dialog, no "the server decides." Both sides converge to the same state by math.

Practically: every change you make to an annotation becomes an operation (an op) — "inserted text here", "set body to X", "deleted annotation Y." Ops carry a little metadata (which device, a logical clock). When two devices exchange ops, Yjs replays them in a well-defined order and both end up with the same final state.

This Y.Doc is persisted to your browser's IndexedDB via a library called y-indexeddb. So "your annotations" live in IndexedDB on your phone and in IndexedDB on your laptop — each is a complete, authoritative copy.

Piece C — How devices find each other (the relay + pairing)

For two devices to exchange ops, they need a rendezvous point. That's the sync relay: a tiny Cloudflare Worker + Durable Object. It's a "dumb pipe" — its entire job is to forward opaque binary blobs from phone to laptop and vice versa. It doesn't parse your annotations; it doesn't know if you're annotating War and Peace or Anna Karenina; it just sees "room ID X received 4 KB of bytes, forward to the other device connected to room X."

A "room" is just a UUID. Your annotations live in your room. Two things are needed to connect to your room:

  1. The room ID (a UUID)
  2. The sync key (a random 256-bit secret, used to HMAC-sign your messages so the relay knows you belong there)

There's no account, no email, no password. The key is your identity.

The pairing flow

How a second device gets the room ID and sync key without a server to look them up:

  1. Phone (already set up) generates a QR code. Inside the QR code is a URL: https://tolstoy.life/pair#room=<id>&key=<key>&did=<deviceid>&exp=<5min>&sig=<hmac>.
  2. Everything after the # (the "URL fragment") stays client-side — browsers don't send fragments to servers, so tolstoy.life's logs see only /pair, never the key.
  3. Laptop scans the QR (via camera) or the user types in a BIP-39 six-word phrase on a phone call.
  4. Laptop opens the URL, extracts the fragment, does a confirmation handshake with the phone via the relay.
  5. Laptop now has room ID + sync key → connects to the relay → Yjs reconciles both sides → laptop has your annotation history.

That's the whole sync model. The elegance is that the server is trivial (~150 lines of TypeScript), carries no secrets, can be thrown away and rebuilt, and can never leak user data it doesn't have.

Part 2What lives on device — and what a cache clear takes with it

Books and annotations both persist on device, but in different browser APIs with different cleanup triggers. Worth making explicit because "clear cache" means different things in different places.

Storage topology

Once a work is downloaded for offline reading, here's where each piece actually lives:

Storage bucketBrowser APIWhat lives there
tolstoy-work-v1:war-and-peace:v2026-03-01-a3f5c8 Cache API The work's XHTML chapters, CSS, images
tolstoy-wiki-v<bundle-version> Cache API Shared wiki articles referenced by any downloaded work
Workbox precache Cache API App shell + wiki-previews bundle
tolstoy-life-annotations IndexedDB (via y-indexeddb) The Y.Doc: your annotations, bookmarks, reading position
downloadedWorks store IndexedDB (separate) Metadata: which works are downloaded, versions, sizes, status

Cache API and IndexedDB are both persistent browser storage, both sit under the navigator.storage quota, both survive app closes, reboots, and OS updates. They're separate APIs because they're optimised for different things: Cache API speaks HTTP (request → response), so the service worker can serve from it on every navigation; IndexedDB is for structured data with indexes.

Importantly, Cache API is not the browser's HTTP cache despite the confusing name.

The scenarios

1 · Clearing the browser's HTTP cache

Chrome → "Cached images and files", Firefox → "Cached Web Content". Nothing lost. HTTP cache is separate from both Cache API and IndexedDB. This is the safe one.

2 · Clearing "site data" / "Cookies and other site data"

Chrome or Firefox. Everything for tolstoy.life goes. Cache API + IndexedDB + localStorage + service worker, all wiped together.

3 · Safari → "Clear History and Website Data"

macOS or iOS Settings. Same as scenario 2 — a full origin-bucket wipe. Safari doesn't let you clear only part of it.

4 · iOS 7-day ITP auto-eviction

Uninstalled PWA, no interaction for 7 days. Same as scenario 2, but silent.

5 · User removes PWA from home screen

iOS: data usually survives in Safari's shared origin bucket. Android: depends on OS version; uninstalling through the app manager typically wipes it.

What survives a site-data wipe

DataStatus after wipeRecoverable?
Works (War and Peace etc.) Gone Yes — re-download from tolstoy.life. Free, public domain, immutable. No real loss.
Wiki articles Gone Yes — re-download.
App shell Gone Yes — auto-refetched on next visit.
Annotations (Y.Doc) Gone locally Only if (a) sync is enabled and another paired device still has them, or (b) the user exported to JSON-LD, or (c) the encrypted-backup-to-user-URL was set up.
Sync key Gone Only if exported to a password manager, paper, or backup file.
Downloaded-works metadata Gone Not directly — the app wouldn't know which works used to be downloaded.

The load-bearing insight

Books are always recoverable — they're public-domain files on the origin. Annotations are the only data that can be truly lost if the user hasn't exported and hasn't enabled sync with another device.

This is why the architecture relies on three defences for annotations: (1) JSON-LD export, (2) device sync as defence-in-depth, (3) encrypted backup to a user-controlled URL. Any one of them is enough.

One gap worth repeating

If the user is dormant for 180+ days, the relay room is pruned (see priority fix #14 below). So "sync with another device" only saves you if at least one paired device was active within that window. A lone user who syncs once, then clears data on both devices after a long absence, loses the annotations even if they had "sync enabled."

Part 3Installing the app — the UX problem

The 7-day eviction story in Part 2 means one thing: installing the PWA to the home screen is not an optional power-user feature. It's the difference between a library that survives and one that quietly disappears after a week on iOS. Which makes the install UX the single most important piece of onboarding this project has.

And install UX is platform-fractured. Each operating system handles it differently — some let you prompt programmatically, some don't, one of them (iOS) treats PWAs as a second-class citizen entirely. The site has to detect what's possible and render the right flow for each.

Device landscape

PlatformInstall surfaceAPIFriction
iOS Safari Manual: Share → Add to Home Screen None High — three taps, discoverability is a known problem
Chrome iOS / Firefox iOS Not available — they use WKWebView but don't expose A2HS None Impossible — user must switch to Safari
Android Chrome Browser menu "Install app" + site-triggered dialog beforeinstallprompt Low — one tap once prompt fires
Desktop Chrome / Edge Address-bar install icon + site button beforeinstallprompt Low
Safari macOS 14+ Manual: File → Add to Dock (since Sonoma, 2023) None Medium — discoverable but silent
Firefox desktop / Android Not supported natively None N/A — skip install UI

The decision tree

Before showing any install UI, detect in this order:

  1. Already installed? → matchMedia('(display-mode: standalone)').matches or navigator.standalone. If yes, show nothing.
  2. beforeinstallprompt firing? → Chrome / Edge / Android. Show a site install button.
  3. iOS Safari? → 'standalone' in navigator and not standalone. Show the illustrated Share hint.
  4. Chrome or Firefox on iOS? → One-liner "Open in Safari to install."
  5. Safari on macOS? → Tip card in Settings: "File → Add to Dock."
  6. Otherwise (Firefox desktop/Android) → Hide the install surface entirely.

The iOS pattern — an illustrated share-sheet hint

iOS is the hardest case and the most important. Since there's no prompt API, the site has to teach the user the three-tap gesture. The canonical approach: a small bottom-sheet that appears on the second visit, or right after a meaningful event like creating an annotation, showing the Share glyph and pointing at where to find it in the toolbar.

Step 1 · The in-app hint, with the Share button highlighted in iPhone Safari's bottom toolbar.

Step 2 · Apple's native share sheet. The user taps the highlighted row.

A critical detail: on iPhone (iOS 15+) the Share button is in the bottom toolbar; on iPad it's top-right. The arrow in the hint should point accordingly. Detect with a pointer + viewport check, not UA.

The Android / desktop Chrome pattern — a first-class install button

Where beforeinstallprompt works, the pattern is well-established:

  1. Listen for the event, call preventDefault(), stash it.
  2. Reveal a site-placed "Install Tolstoy" button — in Settings, or as a contextual card right after the user taps "Make available offline."
  3. On click, call event.prompt(). The browser shows its own native dialog.
  4. Listen for appinstalled and hide the button.

Android / Chrome / Edge · A site-placed button triggers the browser's native install dialog. The address bar also shows a corresponding install icon (top-right).

The desktop Safari pattern — a static tip card

Safari macOS has "Add to Dock" from the File menu since Sonoma (2023), but no API. The site's only move is a small static tip card in Settings or after high engagement.

Desktop Safari · The site can't trigger this; it can only tell users where to find it. A static card in Settings works best.

Copy that works

UseAvoid
"Add Tolstoy to your Home Screen""Install this app"
"Keep your library on this device""Download"
"Read offline, forever""Save for offline" (reads as per-book action)
"Your annotations stay safe on your device""Enable app mode"

The word install triggers App Store expectations on iOS. Avoid it. Apple's own verb is "Add to Home Screen" — use theirs.

Timing — earn the ask

Never show on first visit. Always wait for a meaningful engagement signal. For a reader, good triggers are ranked:

  1. The user just created data worth losing. First annotation made, first work tapped "Make available offline." This is the strongest pitch — "We just saved this on your device. Would you like to make sure it stays?"
  2. Second session (user returned voluntarily).
  3. More than 60 s of reading in the current session.
  4. More than 50% scroll through a chapter.

Defer to requestIdleCallback so the hint never competes with reading.

Dismissal and frequency

In-app browsers

A user tapping a tolstoy.life link from Twitter, Gmail, Facebook, or Instagram lands in a WKWebView that has no Add to Home Screen in its share sheet. Detect the missing standalone property or absent beforeinstallprompt, and show one line: "Open this page in Safari to install." Never show the full illustrated hint — it'll only frustrate them.

Recommendation for tolstoy.life

Recommended stack

Write a ~80-line vanilla web component — no dependency, fits the project's lean-web discipline, lives in CUBE's Block layer. Gate behind: isInstallable && !isStandalone && !isInAppBrowser && engagementScore >= threshold && !recentlyDismissed. Trigger specifically on the first "Make available offline" tap — the moment the user has just produced persistent data is the moment the pitch writes itself.

Headline copy for that trigger, specific to the project's voice:

Your note is saved here. Add Tolstoy to your Home Screen so it stays — even if you don't visit for a while.

Honest, mechanism-explaining, and aligned with the values check in the README: it honours the reader's device as a resource.

Part 4What sync should feel like

Once two devices are paired, what does the user actually experience when edits made on the phone appear on the laptop? The spec is notably quiet about this. It says "show appropriate UI, but never block the user" — and stops. The design is left as an exercise.

The right answer is a specific kind of calm. Not silent, but close. Not chatty, not at all.

The guiding principle

Principle

Calm by default · honest on demand · loud only on conflict.

Mature sync apps converge on this shape. Apple Notes, Obsidian Sync, iA Writer, Linear — none of them announce that sync is happening. They just let the updates appear, the way paper pages let ink appear: without ceremony. The app should feel like it remembered where you left off, because it did.

Six states, six treatments

StateWhat the user seesBlocks reading?
App opens with new edits from another device The edits are already there. Optional: a thin toast on open, auto-dismisses in 2–3 s. Never mid-reading. No
Active sync while the app is open A small refresh glyph in the corner rotates briefly, then settles. No text. No
Connected and caught up (steady state) A quiet check-in-circle glyph, or nothing at all if the user prefers (toggle in Settings). No
Offline (no connection) The glyph becomes a cloud-with-slash. Everything still works locally. No alarms. No
Sync failing repeatedly The glyph becomes an exclamation-in-circle. Hovering or tapping opens Settings with the real error. No
Conflict on the same annotation Surfaced. A small banner at the top of the annotation: "Edited on two devices. Keep this version / keep the other." Blocks that one annotation until resolved

The fact that conflicts are visible is what lets everything else be invisible. If the user trusts that the app will tell them when a decision is needed, they can trust its silence the rest of the time.

The sync indicator

One small glyph, tucked into the settings corner or next to the work title. No text unless the user asks for it. Each state has a distinct shape, not just a distinct colour — so the indicator remains legible for colour-blind readers and in any ambient lighting where tinted dots would fade into the paper-coloured background. Two of the five states animate (spin for active sync, pulse for demand), adding a third channel that works without colour perception at all.

Synced
Connected and caught up. The default state — a quiet check inside a circle. Could be hidden entirely for readers who find any indicator distracting.
Syncing
Two arrows circling, rotating gently. Sending or receiving updates right now. Should never last more than a few seconds in normal use.
Offline
A cloud with a line through it. No connection. Reading and writing still work — edits queue up locally and sync when signal returns.
Error
An exclamation inside a circle. Something failed — relay unreachable, auth issue, timeout. Tapping reveals the detail in Settings.
Needs attention
A pulsing warning triangle. A conflict requires a decision. The only state that actively asks the user to do something — the pulse, not the colour, is what catches the eye.

The welcome-back toast

When the laptop opens and there are fresh edits from the phone, a thin pill appears — briefly, once, never mid-reading:

Auto-dismisses in about 2 seconds. No actions, no "OK" button. The user doesn't need to acknowledge that sync happened — it's not an event, it's the default state of reality. If they missed the toast, the edits are still there.

Whether to ship this toast at all is a judgement call. Apple Notes doesn't — updates just appear. Raindrop and Notion do. Argument for: it builds trust, especially early on. Argument against: a reader shouldn't have to acknowledge their own library. My recommendation: ship it, make it dismissable-forever in settings after the first time ("Got it, don't remind me again"), and let the reader opt out once they trust the system.

What lives in Settings instead

For users who want the detail, the Settings → Sync panel has everything:

Last synced 2 minutes ago · 3 devices · 247 annotations
iPhone · paired March 2026
Work laptop · paired April 2026
Home iPad · paired April 2026
End-to-end encryption: on

Exposed on demand, never pushed. The dot in the corner is the tip of the iceberg; this is the rest.

Why it matters

An e-reader for Tolstoy is not Slack. It doesn't need to feel alive, or present, or eager to talk. It needs to feel like a book that happens to remember where you left it. Every pixel of sync chrome on the screen is a pixel not giving itself to the text.

The architecture has made this easy to honour: the CRDT reconciles without asking anyone's permission, the sync key is already on the laptop, the annotations are already in IndexedDB. The UI can stay out of the way because the system is doing its job underneath. Calm by default is not laziness — it's trust in the design.

There's one hard dependency on the fixes in Part 6: the "loud only on conflict" treatment only works if conflicts are actually rare. Right now, with the §2.3/§8 shape inconsistency and the Y.Array-of-plain-objects duplication bug, concurrent edits can produce silent duplicates and losses — which is the worst of both worlds (the user sees no banner, but their data is gone). Resolving those before Stage 4 ships is what lets this UX be honest.

Part 5Will the corpus volume be manageable?

Short answer: yes for most users, but with several specific gotchas that the current docs under-estimate.

What's actually fine

Real volume risks

  1. iOS Safari 7-day eviction. If a user reads in Safari without adding the site to their home screen, iOS will wipe their downloads after 7 days of non-use — even with navigator.storage.persist() granted. The "train reader" user story breaks on iOS unless the user installed to home screen first. The docs say "never block features behind install" which collides with this. Recommendation: elevate install-before-download to a strong prompt on iOS.
  2. works.json busts its budget by 30×. The design targets ≤100 KB. At full scope (~10,000 letters indexed as works per the schema) it becomes ~3 MB unminified. Fix: split into works.json / letters.json / volumes.json, or group letters into letter-volume works.
  3. Versioned work caches leak. Stage-1 §4.2 says "old cache stays intact until the user explicitly migrates" — but users don't migrate. A user who updates War and Peace twice keeps 3 × 8 MB = 24 MB per work. Across 500 downloaded works that's 12 GB of stale cached versions with no prune. Fix: auto-prune non-current versions after N days unless annotations still reference them.
  4. Wiki-previews bundle at ~3 MB gzipped. The docs hand-wave "lazy-load if it gets large." It already is large at full scope. Make lazy-load the default.
  5. Prior-version artifacts have no durable home. Tl-pipeline §3.4 says current + 2 prior versions are served, but the versioned subtrees live only in the build output. Netlify build cache is best-effort. If the cache is wiped, annotations anchored to v2024-01-01 silently 404 until someone rebuilds from the right git tag.
Verdict

The volume story is 80% fine, but the 20% includes user-visible data-loss modes that deserve to be named in the docs, not left as "TBD."

Part 6Could there be problems with the QR sync model?

Yes — several serious ones. All fixable before Stage 4 ships, but none are currently specced.

Three categories: pairing protocol, CRDT semantics, platform assumptions.

3A · Pairing protocol weaknesses

  1. Device re-registration defeats revocation. §4.7 says "revocation is immediate." But the relay authorizes any did whose HMAC is valid — and HMAC validity requires the sync key, which a stolen or compromised device still has. A thief's phone, after "revocation," can simply mint a new did and re-register. Revocation without key rotation is a placebo. Fix: require new-device registration to be signed by an already-authorized device, and make the "stolen device" UI force a key rotation.
  2. Confirmation UI can't distinguish attacker from real device. §6.1 step 5 says the two devices exchange a confirmation "so an attacker who somehow obtained the QR can't silently pair." But if the attacker wins the race to the relay, the user simply clicks "yes that's my new phone" on the attacker's connection. Fix: use a Short Authentication String (SAS) — a 4–6 digit code derived post-key-exchange and shown on both devices. The user compares them visually, like Signal does. Without SAS, the confirmation is theater.
  3. Same key used for HMAC and AES-GCM. §4.3.1 encrypts with "the sync key"; §4.3 HMACs with "the sync key." That's a textbook crypto footgun — key-separation failure. Fix: HKDF the root key into K_hmac, K_aead, K_export with explicit labels.
  4. iCloud Tabs may replicate the pairing URL fragment. If the QR is scanned into Safari (not the installed PWA), the URL including the fragment (containing the key) ends up in Safari history, which iCloud syncs to other Apple devices. Fix: (a) history.replaceState on /pair load to drop the fragment from the URL bar before iCloud sees it; (b) switch to a short opaque token that the PWA exchanges for the real key via a short-lived Worker endpoint.
  5. BarcodeDetector is NOT in Safari or iOS. The docs treat it as "default, BIP-39 as fallback." Reality: BIP-39 must be primary on iOS (which is the most security-sensitive path because of #4). The docs need a straight correction and a bundled local QR decoder (jsQR/zxing-wasm) — never a hosted decoder, since that would funnel the key through a third party.
  6. Rate-limiting on pairing attempts is assumed, never specced. The BIP-39 66-bit entropy is safe only if the token is burned after first use and attempts are rate-limited at the Durable Object. Neither is in the spec.
  7. Key rotation vs stale sync-key backup. User exports key to 1Password, rotates key 6 months later → the exported backup is now useless. The rotation flow must force re-export before completing.

3B · CRDT / Yjs semantic problems

These aren't attacks — they're silent data-loss modes from concurrent edits.

  1. Same-note concurrent edit silently loses data. §2.3 says "plain replacement, one wins, history preserves the other." But Y.Map LWW resolves by (clientID, clock), not wall-clock — and there's no UI surfacing the loser. User edits on phone at 09:00, edits the same note differently on laptop at 09:30, both offline; on sync, the phone version may win because its clientID is higher. Laptop text disappears silently.
  2. §2.3 and §8 contradict each other. §2.3 specs body as a Y.Array of plain objects. Decision log §8 item 1 says body is Y.Text. These produce different semantics. Pick one before implementation.
  3. Y.Array of plain objects produces duplicates on concurrent replace. Replacing a body via delete(0); insert(0, newBody) on two devices ends up with both bodies in the array (Yjs inserts resolve deterministically but don't dedup). Use Y.Text or a single Y.Map key set, not a Y.Array.
  4. Delete semantics are undefined. Delete-vs-concurrent-edit can either resurrect a "deleted" annotation with a fresh body or silently discard the edit. Spec must choose. Recommendation: soft-delete with grace window + UI for resurrect/discard choice.
  5. Version-migration mutates annotations in place. When a work ships v2 and device A auto-migrates 100 annotations' selectors, device B (offline on v1) later receives those updates pointing at offsets that don't match B's text. Annotations corrupt on B. Fix: migration creates new annotations linked via migratedFrom; old ones stay untouched.
  6. Reading-position update rate. Every few seconds → ~65,000 ops/year/work, each sent over the WebSocket. Huge bandwidth and history-bloat cost for a field that's one timestamp + one position. Either debounce aggressively (~30 s), or move reading position out of the Y.Doc into a tiny dedicated LWW endpoint.
  7. "Relay never parses" contradicts "merged Yjs state." §4.5 says the relay keeps a compacted merged blob. Compaction via Y.mergeUpdates requires parsing the Yjs framing — and if encryption-at-rest is on, the server can't merge at all. This is an architectural contradiction the spec needs to resolve (probably: with encryption on, only clients compact; server appends until a trusted client uploads a replacement).
  8. Two-year dormant user loses everything. Relay prunes rooms at 180 days. iOS may evict IDB. Yjs major version could shift. User loses annotations without warning. Fix: heartbeat from any device keeps room alive; warn user when any paired device has been silent 120 days; require storage.persist() prompt on first annotation.

3C · Platform assumptions that don't hold on iOS

Part 7The nastiest finding: a build-pipeline cascade bug

This one isn't about sync or volume — it's in the build. I'm flagging it because it's the single most damaging issue found and it hits the foundation Stage 1 sits on.

The wikiPreviewsUrl field is embedded in every work's manifest.json. When a single wiki article is edited:

  1. generate-wiki-previews.py emits a new wiki-previews-v<new-hash>.json
  2. generate-asset-manifests.py writes that URL into every work's manifest.json
  3. Every work's manifest.json bytes change
  4. If manifest.json is included in the work's hashed asset set (and §6.2's walk_files(work_dir) doesn't exclude it), every work gets a new version tag
  5. Retention window (current + 2 prior) collapses to "all versions are new" after two wiki edits
  6. Every client re-downloads every work on every wiki edit

Compounded by the fact that wiki-integration.md §2.2 and tl-pipeline-integration.md §4.3 specify different shapes for relatedWiki (one with per-article sha256, one with just slug strings). If the richer shape wins, any wiki article edit directly propagates a new hash into every work's manifest regardless of whether manifest.json is itself in the hash.

Fix

Exclude manifest.json and all generated cross-references from the work hash input. Compute the work hash over only rendered content files (chapters, CSS, images, TOC). Remove the wikiPreviewsUrl field from per-work manifests — the works.json top-level pointer is already sufficient.

Also: git_first_commit_date_for_dir doesn't work on shallow Netlify clones. Netlify defaults to --depth=1. Either force full history, or drop the git dependency and store contentDate in the previous works.json as the authoritative value, only advancing it when the content hash changes.

Part 8Hosting — where does all this run?

Two hosting surfaces matter: the static origin (tolstoy.life — serves works, wiki, manifests, app shell) and the sync relay (wss://sync.tolstoy.life — the tiny Cloudflare Worker that ferries annotation blobs between devices). They have very different profiles and different best-fit providers.

The two surfaces

SurfaceContentShape of loadDynamic?
Static origin HTML, CSS, JS, JSON manifests, wiki-previews bundle, images Bandwidth-heavy, mostly reads, cacheable at edge No — pure static output from Eleventy + Python generators
Sync relay Opaque Yjs update blobs Long-lived WebSockets, bursty, mostly idle Yes — stateful per-room durable storage

Static origin — options ranked

Provider1 TB/mo10 TB/moDeterminism-safeValues
Netlify (incumbent)~$19~$5,000 legacyYes, with post-processing OFFMedium
Cloudflare Pages$0$0Yes (default)Medium
Vercel$20~$1,370No — Image Opt defaults onLow
GitHub Pagesover soft limitsuspendedYesMedium
R2 + CF CDN~$0.20~$0.20YesMedium
Bunny.net + storage~$5~$50YesHigh
Hetzner + Caddy (+Bunny)~$4–9~$4–50YesHighest

Ruled out: Vercel (Image Optimization defaults on, which destroys the SHA-256 manifest hashes the PWA relies on — and the $150/TB overage is unworkable for public-domain texts). GitHub Pages (100 GB/month soft limit, no per-path Cache-Control, no Service-Worker-Allowed customisation).

The most interesting free option: Cloudflare Pages has unlimited bandwidth on its free tier, no asset auto-transform, and _headers + Workers for dynamic rewrites. At 10 TB/month that's a $5,000/month delta vs. Netlify's legacy pricing. The main downsides are platform dependency (CF is a hyperscaler) and a 25 MB per-file / 20,000-file project limit (currently not a concern; might bite after Phase 5).

The values-maximalist stack: Bunny.net edge caching in front of Hetzner + Caddy. Bunny is an independent Slovenian CDN with transparent per-GB pricing (~$5/TB) and no AI ambitions. Hetzner is a German-owned VPS provider. Fully portable; no lock-in on the data (it's files on disk, not in a proprietary system).

If you're on a legacy Netlify free plan

Netlify changed its pricing model on 4 September 2025. Accounts created before that date are grandfathered indefinitely onto the legacy plan — "no action is required of you" per Netlify's own docs. The practical difference is substantial:

Legacy Free (pre-Sept 2025)New credit-based Free
Bandwidth100 GB/month15 GB/month (300 credits ÷ 20 credits/GB)
Build minutes300/monthbundled into 300 credits
Overage on freeHard stop (site stops serving)Hard stop — can't buy more credits
Grandfather end dateNone announcedn/a

A grandfathered legacy free plan has ~6.7× the bandwidth of a new free plan — a small asset worth preserving. Two rules follow:

Build-minute economics

Bandwidth is one of two budgets the free plan imposes. The other is 300 build minutes per month, and it's the sleeper bottleneck — fine today, structurally dangerous by Phase 3 unless you plan for it. A Phase 5 build that takes ten minutes burns 30 builds per month on the free tier. At three deploys a day, that's five times over budget.

Where the build time goes

An Eleventy + Layer-1 Python build at full scope has four cost centres:

  1. npm install — 30–60 s uncached, 5–10 s with Netlify's build cache warm. Essentially free once the cache settles.
  2. Eleventy rendering — scales linearly with page count. Roughly 5 ms per page on cold render in Node 20+.
  3. Python Layer-1 generators — generate-wiki-previews.py (cheap), generate-related-wiki.py (parses every chapter for wikilinks), generate-asset-manifests.py (the expensive one — SHA-256 of every served file), generate-works-index.py (cheap).
  4. PostCSS / Tailwind — 5–15 s, fixed cost, unaffected by content scale.

Realistic projections

PhaseFilesCold buildWith cachingBuilds per 300 min
Phase 2 (now)~50~25 s~15 s≥1,000
Phase 3 (TEI ingestion)~8,0002–3 min45–90 s200–400
Phase 5 (full vault)~26,5008–12 min2–4 min75–150
Phase 5 + facsimile scans26,500 + 100k images15–25 min4–8 min40–75

"With caching" assumes: npm cache warm, Eleventy's .cache/ persisted, and — critically — the SHA-256 generator caches its output keyed by (mtime, size) so unchanged files aren't re-hashed. Without these, Phase 5 builds easily exceed ten minutes and blow through the budget with room to spare.

The highest-leverage optimisation: cache file hashes

At full scope, the asset-manifest generator will SHA-256 maybe 100,000 served files per build. Hashing 100,000 HTML files (~30 KB each) on Netlify's build hardware is on the order of 3–5 minutes of pure CPU — and almost all of it is redundant, because between two consecutive builds, perhaps 5 files actually changed.

# generate-asset-manifests.py — cached-hash pattern
cache = load_json(".cache/file-hashes.json")  # { path: {mtime, size, sha256} }
for path in walk_files(build_root):
    stat = path.stat()
    cached = cache.get(str(path))
    if cached and cached["mtime"] == stat.st_mtime and cached["size"] == stat.st_size:
        sha = cached["sha256"]  # reuse
    else:
        sha = sha256(path.read_bytes())
        cache[str(path)] = {
            "mtime": stat.st_mtime,
            "size": stat.st_size,
            "sha256": sha,
        }
save_json(".cache/file-hashes.json", cache)

With .cache/ persisted via Netlify's build cache (configure in netlify.toml), this turns Phase 5 builds from 10+ minutes into 2–3 minutes. Do this before Phase 3 ingestion starts — retrofitting is fine, but the determinism CI test I recommended in Part 7 is the natural place to validate that cached-hash output matches from-scratch-hash output byte-for-byte.

The second-highest-leverage optimisation: build-ignore

Netlify supports a build.ignore command that short-circuits the build if a predicate returns zero. Add to netlify.toml:

[build]
  ignore = "git diff --quiet HEAD^ HEAD -- website/"

This skips the build entirely if the last commit didn't touch website/. Root-level doc changes, _generated/ updates, tool-project changes — none of them trigger a build. Realistically this alone eliminates 30–50% of builds for an active project.

Other optimisations, ranked

  1. Eleventy incremental mode. eleventy --incremental only re-renders changed pages. Works well in dev; in CI the cache has to be persisted for it to help. Non-trivial to wire correctly but worth 40–60% of full-build time at Phase 5.
  2. Per-context build commands. Deploy previews for PRs can skip the Layer-1 pass entirely, using last-good works.json + manifest.json from the main branch. The preview is visually complete; only the PWA offline-download contract is stale. Acceptable for review builds.
  3. Split deploys. If facsimile scans are their own Netlify site (scans.tolstoy.life), they don't factor into the main tolstoy.life build at all. Scan updates deploy independently on their own cadence.
  4. Nightly-only full builds via scheduled webhook. Keep PR / commit builds fast (skip the heavy Layer-1 pass); run a 15-minute full rebuild once a day via a Netlify scheduled build. Trade-off: versioned manifests lag commits by up to 24 hours.
  5. Move the whole build to GitHub Actions and netlify deploy --prod the result. GitHub Actions free tier gives 2,000 min/month for public repos — 6.7× more than Netlify's 300. tolstoylife/website is likely public. Netlify then serves a pre-built directory; it's not running the build itself.

Option 5 is the nuclear escape hatch — if Netlify's 300 min becomes structurally inadequate, moving the build to Actions while keeping Netlify as pure hosting preserves the 100 GB legacy bandwidth allowance without the build-minute pressure. A genuinely portable architecture in two places at once.

When to act

Timeline
  • Phase 2 (now): nothing to do. Current builds are probably 20–30 seconds. Measure a baseline — run time npm run build locally so you have a before/after.
  • Before Phase 3: implement the SHA-256 hash cache and the build.ignore command. Low-effort, high-leverage. These are the two items that keep you under budget indefinitely.
  • Entering Phase 5: profile each generator individually. If any single one exceeds 2 minutes consistently, that's the one to optimise.
  • If monthly builds ever sustain > 250 min: move the build to GitHub Actions. Netlify becomes pure hosting.

Static origin — the minimum-change fix (do this now)

Before migrating anything, fix the determinism issue flagged in Part 7 on Netlify itself. Asset Optimization ("post-processing") can be fully disabled without leaving the platform. Add a netlify.toml:

[build]
  publish = "_site"
  command = "npm run build"

[build.environment]
  PYTHON_VERSION = "3.11.9"
  TZ = "UTC"
  LC_ALL = "C.UTF-8"

[build.processing]
  skip_processing = true
[build.processing.css]
  bundle = false
  minify = false
[build.processing.js]
  bundle = false
  minify = false
[build.processing.html]
  pretty_urls = false
[build.processing.images]
  compress = false

Then add _headers:

/works.json
  Cache-Control: public, max-age=300, must-revalidate

/*/manifest.json
  Cache-Control: public, max-age=300, must-revalidate

/*/v*/*
  Cache-Control: public, max-age=31536000, immutable

/wiki-previews-v*.json
  Cache-Control: public, max-age=31536000, immutable

/sw.js
  Cache-Control: no-cache
  Service-Worker-Allowed: /

Re-run the determinism CI (build twice, diff). The audit should now pass.

Migration trigger: move to Cloudflare Pages when bandwidth approaches the plan's hard limit — 80 GB/month on a legacy free plan (100 GB cap), ~500 GB/month on a legacy Pro plan, or whenever Netlify's post-September-2025 credit-based pricing would apply to a new account.

Sync relay — Cloudflare Workers + Durable Objects

The architecture doc picks CF Workers + Durable Objects with hibernatable WebSockets. Research confirms this is the right default. Key correction to the doc's §7: the "fits in the free tier" claim is aspirational. Hibernatable WebSockets + DO storage require Workers Paid ($5/month). That's the real floor.

Cost projections at realistic scale:

Active usersPeak concurrentMonthly relay costNotes
10010~$5Paid plan floor. Everything else within included allowances.
1,00050~$6–$10Base + minor DO request cost.
10,000500~$25–$60~300M DO requests/mo becomes the dominant line.
100,0005,000~$400–$900Scales linearly; still cheap per MAU.

At every tier above, the sync relay is cheap. The main risks are not cost — they're operational.

Combined monthly cost picture

ScaleStatic originSync relayCombined
100 users, ~10 GB/mo~$0 (within free tiers on any provider)$5~$5/mo
1K users, ~100 GB/mo~$19 Netlify / $0 CF Pages~$10~$10–$30/mo
10K users, ~1 TB/mo~$19 / $0~$50~$50–$70/mo
100K users, ~10 TB/mo~$5,000 Netlify / $0 CF Pages / $50 Bunny~$700~$750–$5,700/mo

The static-origin bandwidth is the dominant cost driver at scale, not the sync relay. This is why the migration path off Netlify matters even though everything fits comfortably on the free tier today.

Operational action items

If the project stays on Cloudflare Workers + DO for the sync relay (recommended):

  1. Upgrade to Workers Paid at launch. Document the $5/mo floor; update yjs-schema-and-sync.md §7 to replace "fits in the free tier" with concrete figures.
  2. Pin the Durable Object location to EU. On first idFromName() call, pass { locationHint: "weur" }. Swedish users' blobs stay in Western-European data centres (Amsterdam, Frankfurt, Dublin). Disclose this in the privacy settings panel.
  3. Vendor the y-durableobjects implementation. It's a small-maintainer project. Fork into tolstoylife/y-durableobjects, audit, pin to a reviewed commit. Don't depend on an unmaintained npm tag.
  4. Write the Hocuspocus-on-Fly fallback now. A 50-line Dockerfile + fly.toml committed to the repo (not deployed) operationalises the "operator can be swapped without users noticing" promise. Without it, that promise is aspirational.
  5. Nightly R2 backup of DO state. DO storage is durable but has no external backup. A DO Alarm that exports each active room to R2 (CF's object store, $0.015/GB-month, no egress fees) is cheap insurance — ~$0.30/month at 100K users. It also becomes the migration-off-CF pathway.
  6. Measure four metrics from day one: DO requests/day, P95 cold-start on first connect after idle, snapshot size distribution per room, rooms-flagged-for-pruning count.
  7. Abuse protection. CF WAF rules: rate-limit WebSocket upgrades per-IP to ~10/minute. Reject connections without a well-formed HMAC param inside the Worker (never wakes the DO). Per-room message-rate cap in the DO itself to prevent a malicious device flooding its own room.

LightRAG — the third surface, internal only

Beyond the two public-facing surfaces is a third that never touches the public internet: the LightRAG indexing box that runs the nightly semantic-search pipeline (Layer 2 in the scaled architecture — see the project CLAUDE.md). It's a developer tool, not user-facing. Every design decision should fall out of that fact.

LightRAG has two computational phases:

  1. Embedding generation — small model (e.g. nomic-embed-text, ~275 MB). CPU-fast. Cheap.
  2. LLM extraction — entity, relationship, and summary extraction via Qwen2.5:7b or similar. This is where most of the compute goes.

Plus graph and vector storage (NetworkX + NanoVectorDB), which are tiny and purely disk-bound.

The real question is whether a VPS can run Qwen2.5:7b efficiently enough. The answer splits into two regimes.

CPU-only — Hetzner standard VPS

Qwen2.5:7b at Q4_K_M quantisation needs ~5–6 GB RAM to run, ~8–12 GB with a 32K context. Throughput on modern Xeon / EPYC cores is 5–15 tokens per second.

Hetzner tier~€/moSpecVerdict
CX22€3.792 vCPU, 4 GBToo small — can't even load the model
CX32€74 vCPU, 8 GBBorderline — only with aggressive context trimming
CCX33~€248 dedicated vCPU, 32 GBSweet spot for ongoing sync
CCX43~€4916 dedicated vCPU, 64 GBFaster but unnecessary for nightly incremental

CCX33 handles the nightly incremental easily. After initial ingestion, you're looking at perhaps 10–100 changed files per day in steady state — minutes of work at 10 tokens/second.

The catch is the initial full ingestion. 26,500 files × multiple LLM calls per chunk can mean a week of continuous compute on a CPU-only box. Doable if you're patient, annoying if you're not.

The GPU shortcut

Hetzner added dedicated GPU servers in 2024. GEX44 with an RTX 4000 SFF Ada (20 GB VRAM) at ~€184/month makes Qwen2.5:7b run at 40+ tokens per second — initial ingestion shrinks from a week to hours. Overkill for ongoing incremental sync, though.

Better split: do the initial ingestion on a rented-by-the-hour GPU (RunPod or Vast.ai — roughly $0.30–$0.60/hour for an A5000 or RTX 4090), then move the resulting index to the CPU VPS for ongoing incremental work. One-time cost: ~$20–$50. Ongoing cost: €24/month.

Recommended setup

  1. Keep local dev primary. An Apple Silicon Mac runs Ollama via Metal at 40+ tokens/sec — essentially free, no monthly cost. Use it for development and test ingestions.
  2. Rent a GPU by the hour once for the first full Tolstoy ingestion (~$30 on RunPod). Save the resulting lightrag/data/ directory.
  3. Hetzner CCX33 (~€24/month) hosts the nightly sync and the HTTP query API (port 8420). Reach it via Tailscale or WireGuard — never expose the query API publicly.
  4. Back up lightrag/data/ to Hetzner Storage Box or Cloudflare R2 weekly. The index is regenerable from the vault, but a fresh regen costs hours, so keeping the artefact is worth the near-zero storage cost.

The critical separation

Three boxes, not one

LightRAG's query API on port 8420 must never be the same machine as the static-site origin or the sync relay. Keep the three surfaces cleanly separated: tolstoy.life static origin (Netlify → CF Pages), sync.tolstoy.life Yjs relay (CF Workers + DO), internal-only LightRAG box (Hetzner CCX33, not publicly routed, Tailscale access only).

LightRAG holds the whole extraction-pipeline state — the knowledge graph, the vector index, the raw LLM-extracted entities. It's a developer tool that happens to be an HTTP service. Keeping it off the public internet is cheaper (no CDN, no TLS concerns, no WAF rules), safer (no attack surface), and matches how it's actually used: by Claude sessions during ingestion and lint, not by readers.

A note on sovereignty and lock-in

The project's stated values include independence from platform gatekeepers. Hosting is the place this value is actually tested. Worth being honest:

The Cloudflare consolidation arc

Having committed to Cloudflare for the domain, the sync relay, and R2 backups, the question of whether the static origin should also move is half-decided. The direction is clear; the open questions are when and how, not whether.

The two-phase architectural evolution

Cloudflare Pages is ideal for the project today. It is not sufficient at Phase 5 scale. The difference isn't bandwidth — it's a per-deployment file cap.

PhaseFilesCF Pages alone?Required architecture
Phase 2 (now)~50YesCF Pages — simple
Phase 3 (TEI ingestion)~8,000YesCF Pages — under 20k cap
Phase 4 (wiki expansion)~15,000Yes, barelyCF Pages — approaching cap
Phase 5 (full vault)>100,000No — exceeds 20k capCF Pages (shell) + R2 (content) via Workers

Cloudflare Pages caps each deployment at 20,000 files and 25 MB per file. At Phase 5 the Tolstoy vault projects to 26,500 wiki articles, ~500 translated works with many chapters each, ~10,000 letters, and ~90 Jubilee volumes. The total easily exceeds 100,000 files — five times Pages' limit.

The native Cloudflare answer: Pages + R2

The ranked options table earlier in this section listed R2 + Cloudflare CDN as the "best pure economics at 10+ TB/month" option. At Phase 5 it also becomes the answer to the file-count problem. The split:

Both deployments stay in the same Cloudflare account. Same dashboard, same billing, same DNS. The manifest.json contract from Part 7 doesn't care where the bytes physically live — the manifest's url field is the origin-relative path, and the Worker resolves it.

Staged migration, no big bang

The parallel CF Pages account you already set up is the right first step. The rest, at your pace:

  1. Transfer the domain to Cloudflare Registrar at the next Netlify renewal. Saves ~$22/year and centralises DNS management.
  2. Connect tolstoylife/website to CF Pages as a second deploy target for the main branch. Builds produce a pages.dev URL alongside the Netlify deploy. Zero user-facing change yet.
  3. Run the cross-platform determinism test: build the same git commit on both Netlify and CF Pages, diff the output directories. They must be byte-identical. If they're not, you've found a bug in either provider's build pipeline before it costs anything. This extends the single-platform determinism CI test from Part 7 into a cross-platform reproducibility test.
  4. Flip DNS when satisfied. The A and CNAME records for tolstoy.life point to CF Pages instead of Netlify. Rollback is the same DNS change in reverse — seconds of propagation.
  5. Keep Netlify deploying silently as a standby for 6–12 months. Don't formally cancel. If Cloudflare ever has an issue, flip DNS back. The grandfathered 100 GB bandwidth remains a passive hedge. Costs nothing — Netlify free stays free as long as builds stay under 300 min/month.
  6. Budget for the R2 split as a Phase 5 milestone. When the file count approaches 15,000 (an alarm, not a cliff), implement the Pages-shell + R2-content split. The Worker router is about a week of work; the Eleventy build-config change to output the split is smaller than it sounds.

Concentration without lock-in

This consolidates significant infrastructure at one US hyperscaler. The project's stated value is "not dependent on big data companies." Cloudflare is a big data company. The mitigation — and it's a real one — is that every piece of the data remains portable:

Concentration without lock-in is the honest phrase for this stack. Every piece is portable; the consolidation is operational convenience, not architectural dependency. The Bunny.net + Hetzner stack remains the sovereignty option if the politics ever change — and the migration path above makes arriving there simpler from Cloudflare than from Netlify, because R2 exports cleanly to Bunny Storage and Worker logic ports cleanly to Caddy configuration.

Recommendation

For now

Keep Netlify running with post-processing disabled (the netlify.toml above) while setting up CF Pages as a parallel deploy target. Transfer the domain to Cloudflare Registrar at next renewal. Use Cloudflare Workers + DO for the sync relay, paid tier from day one. Pin the DO to EU. Write the Hocuspocus fallback. Back up to R2 nightly.

When the DNS flip becomes urgent

The Netlify → CF Pages transition is planned regardless. It becomes urgent when bandwidth approaches Netlify's 100 GB/month hard cap (alerts at 50 and 80 GB) or when monthly build minutes sustain above 250 of the 300 budget. Until either fires, the parallel CF Pages deploy serves as validation — the DNS flip can happen whenever you're ready.

The sovereignty option

If the project later wants full platform independence, the destination is Bunny.net + Hetzner for the origin and Hocuspocus on Hetzner (or a trusted EU VPS) for the relay. Same bytes, same protocols, different operators. Plan for it; don't rush to it.

Part 9Priority fixes, ranked

Before Stage 1 ships

  1. Fix the wiki-previews/manifest cascade. Exclude cross-references from the work hash. (Pipeline determinism)
  2. Reconcile §2.3 vs decision log §8 for annotation body. One shape, one concurrency story. (Data integrity)
  3. Publish a deterministic-build CI test. Build twice, diff. Block merges. (Pipeline correctness)
  4. Add chapterUri validator + one-shot migration. Uniqueness check across corpus. (Annotation anchoring)
  5. Pick a canonical JSON library (rfc8785) and NFC-normalize strings. (Determinism)

Before Stage 4 ships

  1. HKDF key separation for HMAC / AEAD / export keys. (Cryptographic hygiene)
  2. SAS confirmation handshake replacing yes/no pairing confirm. (Real MITM protection)
  3. Device-registration authorization (existing device must sign new-device registration). (Fix the revocation-is-placebo bug)
  4. Rotation flow blocks on re-export with stale-backup banner. (Prevent user data loss after positive security action)
  5. Rate-limit pairing attempts at the Durable Object, one-use BIP-39 tokens. (Brute-force defense)
  6. Resolve the "relay doesn't parse" contradiction. Either spec client-side compaction under encryption, or weaken the claim. (Architectural honesty)
  7. BIP-39 as iOS primary, not fallback; bundle local QR decoder. (Platform correctness)
  8. iCloud Tabs mitigation — history.replaceState on /pair, Referrer-Policy: no-referrer. (Fragment leak)
  9. Dormant-user heartbeat — keep room alive if any paired device is active. (2-year lifecycle)

Broader

  1. Split works.json by type once letters are indexed. (Volume)
  2. Auto-prune stale work-version caches with annotation-reference reachability. (Storage leak)
  3. Lazy-load the wiki-previews bundle from day one. (Shell install cost)
  4. Move reading-position out of Yjs or throttle it to ~30 s. (Bandwidth + bloat)
  5. Version-migration creates new annotations, doesn't mutate old ones. (Multi-device correctness)

Part 10Overall verdict

The architecture is fundamentally sound — the design instincts (opaque-blob relay, no accounts, content-addressed versioning, W3C annotations, public-domain-aligned values) are all correct. The docs are unusually honest about their own threat model (the §4.3.1 cryptography callout is exemplary).

The weaknesses are specification-gap failures, not architectural rot. A two-page "sync security spec" locking down HKDF labels, SAS protocol, rate-limit numbers, device-registration authorization, and rotation-export atomicity would close most of the sync risks. A week of pipeline hardening (excluding cross-references from hashes, the determinism CI test, a canonical JSON encoder) would close most of the build risks.

Volume is manageable for the realistic user. Sync is workable with the fixes above. The project can ship.

The single most important thing is to treat the PWA docs as drafts that assume a happy path, and run the priority-fix list before implementation begins. Every issue in this report is findable only because the docs are thorough enough to have specified the details — which is the opposite of the usual problem.