SyncTicket: a local-first issue tracker with a Durable Object relay
A local-first, real-time issue tracker: the browser owns the data in a Yjs CRDT, a Cloudflare Durable Object relays edits between browsers, and search runs on the device. No LLM. Live as a demo since 2026-10-22.
An issue tracker in which the browser owns the data instead of caching a server's: edits work offline and merge without conflicts, two browsers stay in step in real time, and all of it runs at 0 EUR a month.
- stack:
- Svelte 5 + SvelteKit 2 · Yjs + y-indexeddb · Cloudflare Durable Objects · FlexSearch · y-partyserver · Transformers.js (all-MiniLM-L6-v2) · virtua · Tailwind v4 + shadcn-svelte · Vitest + Playwright · Cloudflare Workers
- when:
- 2026
- published:
Problem & constraints
SyncTicket is an issue tracker in the style of Linear, built to take the hard parts of that problem seriously: the browser owns the data, edits work offline and merge without conflicts, two browsers stay in step in real time, and search needs no server. It is a portfolio piece that happens to be a real application, not a product looking for users. The people who open it are evaluators, so every claim has to be checkable in the running demo or the source. The name is a working title.
Three constraints shaped it. The budget was 0 EUR, so everything runs on Cloudflare’s free plan, whose ceilings (100,000 requests and 100,000 rows written a day, account-wide, and 2 MB per stored value) became design inputs rather than surprises. A visitor should be productive within a minute, so there are no accounts: the workspace link is the access. And the demo has to keep working with nobody watching it, so when it cannot do something it has to say so instead of failing silently.
It uses no LLM. Its only model is an optional sentence-embedding model for search, which runs in the visitor’s browser.
Architecture
SyncTicket is two Workers on Cloudflare’s free plan, live at syncticket.elbaymalik.com since 2026-10-22. The SvelteKit app Worker serves the pages and the self-hosted search model; a separate relay Worker holds one Durable Object room per workspace. They are separate because the stable SvelteKit adapter cannot export a Durable Object class.
In the browser, each workspace is one Yjs document: a map of issues, each issue a map of fields. y-indexeddb persists it to IndexedDB, and that document, not the server, is the source of truth. A Svelte 5 runes bridge observes it and patches a reactive projection that the UI reads; the UI never writes to the projection, only to the document. The Kanban board orders cards by fractional keys with a random suffix, so two browsers that create a card offline do not mint the same key, and a repair step heals equal keys that already exist.
The relay, y-partyserver on a hibernating Durable Object, passes updates between browsers and saves a snapshot of the document to the room’s SQLite storage, debounced at 2 s with a 5 s ceiling. Hibernation is what makes an idle room cost nothing, and it evicts the room’s memory: the document has to be rebuilt from SQLite on every wake, and the presence map starts empty. Search runs in a module Web Worker. FlexSearch answers from the first keystroke; when a user turns on meaning search, all-MiniLM-L6-v2 runs through Transformers.js on WebAssembly, and the two rankings are fused with Reciprocal Rank Fusion. With meaning search off, an end-to-end test records every request while queries are typed and asserts that there are none. The decisions behind it are logged below, each with the alternative it beat.
Where the libraries fell short
Three times, the installed package did not do what its documentation or the plan assumed. Only presence was a documented promise; the other two gaps were found by reading the installed code.
Presence: y-protocols documents that a peer not heard from within 30 seconds is dropped. y-partyserver clears the interval that does it, on the client and on the server, so a peer whose laptop lid closed stayed on screen forever. Hibernation made it worse, because a woken room has an empty presence map, and removing a peer from it reaches nobody. The client now owns liveness with a heartbeat and a staleness filter, gated so that a tab on its own sends nothing and lets the room sleep.
Connection: the provider writes the time of the last message it received and never reads it, so a socket that went quiet without closing was reported as connected and synced. The first design for a fix, calling disconnect() and then connect(), was broken twice over, as a probe against the installed provider showed before it shipped: connect() was a no-op, and disconnect() permanently unsubscribed the channel that keeps tabs of the same browser in sync. The shipped fix closes the suspect socket and lets the library’s own backoff reconnect.
Persistence: above Cloudflare’s 2 MB row cap the relay’s save throws, and the library swallows the error into a log line that the free plan keeps for three days. The room went on syncing in memory and would have lost everything since its last good save at the next eviction. Now the relay checks the size before it writes, keeps the last good snapshot, records the failure where it survives hibernation, and tells every client, including those that arrive later.
Outcomes
All 13 roadmap phases are done, and both Workers have been live on the free plan since 2026-10-22. The deploy was checked the way the repository’s runbook asks, not by the absence of an error: the connection popover settled on “Ready to share”, and an issue created in a second browser given only the share link appeared in the first. It is a permanent, open demo for evaluators, with no accounts by design: anyone with a workspace link can read and edit it, and the app says not to enter real data.
The figures above were measured locally. Virtualization took the list at 10,000 issues from 5,019 ms to 33 ms; FlexSearch answers a first keystroke in 0.005 ms against MiniSearch’s 3.96 ms; and two browsers on one machine converged through a local relay with medians of 29 to 30 ms, far under the 1 s target but not a production figure. A workspace stops being saved at about 6,130 issues, and the app says so. Search quality is not measured: there is no golden set, so these are speed numbers, not relevance numbers. The gates run locally with scripts/ci-local.sh, which mirrors the CI workflow step by step.
The open items are written down in the repository. The name is still a working title: the product got its own mark on 2026-10-22, but no name. The build still ships a second, byte-identical copy of the 23.6 MB ONNX WebAssembly file that is never served, and collapsing the two is an open follow-up. Silent loss through the daily row budget is reported only generically, because its error shape is unverified. A half-open connection that neither the browser’s offline event nor a return to the tab reveals is noticed only at the browser’s own TCP timeout. Board cursors from the plan were left out on purpose, because every pointer move would wake a sleeping room. And there is no export yet for getting data out of a workspace the relay can no longer save; the data is still in the visitor’s own IndexedDB.
Lessons
Read the installed package, not its documentation. Only one of the three gaps above broke a documented promise; the other two were behaviour that only the installed code showed, and the first fix for one of them would have shipped a silent regression. A throwaway probe against the package on disk cost less than any of them.
Measure before choosing. At 10,000 issues, the reactive layer cost 2 ms and the DOM 5 seconds, so the fix was one component instead of a rewrite of the query layer; the search engine was chosen on the first keystroke, not on an average query.
A limit stated beats a limit hidden. The free plan sets hard ceilings, and the one that would have hurt was the one that failed silently. Telling the user that a workspace is no longer being saved was cheaper and more honest than any storage scheme that pushed the wall further out.
A dry run is not a deploy. The deploy-target ADR expected small surprises at the first real deploy, and both were in the tooling: wrangler login reported a timeout after writing valid credentials, and a bare wrangler deploy only warned that the relay config carried an e2e environment without a rate limiter. Both relay deploy scripts now name their environment.
Key decisions
01 A Yjs CRDT with a Durable Object relay
One Yjs document per workspace as the source of truth, persisted to IndexedDB with y-indexeddb and synced through a Cloudflare Durable Object relay built on y-partyserver; all querying happens in the browser.
Offline writes and conflict-free merges come from the CRDT, with no sync protocol of my own to get wrong. Electric needs an always-on service holding a replication slot, which breaks the 0 EUR budget; Zero does not support offline writes, by its own documentation; Triplit's future was uncertain after its acquisition; PowerSync is a managed black box. y-durableobjects matched the design but had been dormant for about 11 months, while y-partyserver is maintained by Cloudflare.
ElectricSQL + PGlite stayed the documented fallback, with the Phase 3 runes bridge as the point to pivot; the bridge held (SyncTicket docs/adr/0001).
rejected: ElectricSQL + PGlite · Rocicorp Zero · Triplit · PowerSync · y-durableobjects as the relay library
02 No accounts: the workspace link is the access
No authentication. A workspace is a random 128-bit id in the URL that acts as a bearer capability: whoever has the link can read and write. The relay routes only names of the shape the app mints, and limits WebSocket upgrades to 30 per 60 seconds per IP.
The demo is opening a link in a second browser and watching it sync; a sign-up step kills that moment and adds a session store to the free-plan budget. Short ids can be enumerated, so the id has to stay long and random.
Measured against the deploy: 60 concurrent upgrade attempts from one address got 42 connections, so the limit is an order of magnitude, not a count. The ADR keeps its own correction, struck through and dated, of an abuse control it once claimed and that had never been built. It is a permanent demo, and no accounts are planned (SyncTicket docs/adr/0004, PRODUCT.md).
rejected: Better Auth accounts from day one · Anonymous device ids with allowlists · Short, readable workspace ids
03 virtua for the list, a cap for the board
virtua's Virtualizer renders the issue list as real list items inside a real list; the Kanban board is not virtualized but capped at 100 cards per column, and a drop still orders against the cards behind the cap.
Measured first: filtering and sorting 10,000 issues took 2 ms and rendering them 5,019 ms, so the DOM was the whole problem. svelte-virtuallists had not been published for 18 months and renders only divs; TanStack's Svelte adapter returns a legacy store, which the project's runes-only rule bans; svelte-tiny-virtual-list needs a fixed pixel height. The drag library indexes a column's direct children, so virtualization and drag-and-drop exclude each other.
List render 5,019 ms → 33 ms; a column of 2,028 cards 907 ms → 194 ms at the cap (SyncTicket docs/adr/0005).
rejected: svelte-virtuallists (the roadmap's pin) · @tanstack/svelte-virtual · svelte-tiny-virtual-list · Virtualizing the board too · Rendering everything and slimming the row
04 FlexSearch in a worker, with an opt-in embedding model
FlexSearch for lexical search in a module Web Worker; an embedding model (all-MiniLM-L6-v2 through Transformers.js) loads only when a user asks for it, and the two rankings are fused with Reciprocal Rank Fusion. The model and the ONNX runtime are served from the app's own origin, with remote models switched off.
The first keystroke is a single letter that matches nearly everything, and there MiniSearch took 3.96 ms against FlexSearch's 0.005 ms at 10,000 issues, a gap that grows with the corpus, although MiniSearch has the better API and updates thirteen times faster. Orama assumes it owns both indexes, and the ranking was meant to be explicit and testable. A 23 MB model download is not something to impose on a first visit.
Self-hosting (ADR 0009) means semantic search depends on no third party at demo time; an end-to-end test asserts that no request reaches Hugging Face or jsDelivr, and a missing local file falls back to lexical search instead of reaching the network (SyncTicket docs/adr/0006, docs/adr/0009).
rejected: MiniSearch · Orama · The existing substring filter · Semantic search on by default · Fetching the model from Hugging Face and the runtime from jsDelivr (the first design, replaced by ADR 0009)
05 Presence liveness owned by the client
Each client sends a heartbeat every 10 seconds while anyone else is known, hides a peer it has not heard from in 25 seconds and forgets it after 2 minutes; the relay's onError runs the same cleanup as onClose.
The documented 30-second sweep never runs: y-partyserver clears its interval on both client and server, so a peer that vanished without closing its socket stayed on screen forever, and hibernation empties the room's presence map as well. Pruning by broadcast would let one client's bad connection evict a healthy peer from every screen, and an unconditional beat would wake an idle room about 8,600 times a day.
The connection had the same shape of gap: the provider records when it last heard from the server and never reads it, so a half-open socket looked connected forever. The fix closes a suspect socket and lets the library's own backoff reconnect; it never uses disconnect() then connect(), a recipe a probe showed does not reconnect and permanently turns off sync between tabs (SyncTicket docs/adr/0007, docs/adr/0008).
rejected: Trusting the protocol's 30-second awareness timeout · Restoring the protocol's sweep on the client · Pruning with removeAwarenessStates, which broadcasts · A heartbeat that never stops
06 Report the snapshot ceiling, do not raise it
The relay checks each snapshot's size against a 1.9 MB guard before writing it, keeps the last good snapshot instead of attempting a write that would fail, records the failure in a health row that survives hibernation, and tells every client, including those that connect later. Saves are debounced at 2 s with a 5 s ceiling.
Above Cloudflare's 2 MB row cap the write throws, and y-partyserver swallows the error into a log line: the room keeps syncing in memory and loses everything since its last good save at the next eviction. Chunking trades that predictable failure for an intermittent one in the daily row budget; V2 buys 13–37% headroom, but a V2 payload applied as V1 returns silently with an empty document; compaction mints new client ids, and the next merge brings back what it discarded.
The old 300 ms debounce could reach up to about 288,000 row writes a day from one room, against an account-wide budget of 100,000; the new timing caps the bursty case at about 43,200. The daily-row-budget route to the same failure is still reported only generically (SyncTicket docs/adr/0010).
rejected: Chunking the snapshot across rows · Switching to Yjs's V2 update encoding · Compaction · An append-only delta log · Refusing writes or closing sockets at the ceiling
Evaluation results
| Metric | Value | Status |
|---|---|---|
| List render at 10,000 issues, before → after virtualization | 5,019 ms → 33 ms | measured DOM nodes 168,011 → 383, list items 10,000 → 20. Filtering and sorting all 10,000 issues took 2.0 ms before the change: the DOM was the bottleneck, not the reactive layer. |
| Board column render, every card → capped at 100 | 907 ms → 194 ms | measured One column held 2,028 cards: 38,979 DOM nodes before the cap, 1,922 after. The board cannot be virtualized, because the drag library needs every card in the DOM. |
| First-keystroke query at 10,000 issues, MiniSearch → FlexSearch | 3.96 ms → 0.005 ms | measured The price: FlexSearch builds its index in 101 ms against 49 ms, and updates one document in 1.3 ms against 0.10 ms. |
| Search worker bundle, Vite's default worker format → ES modules | 500 KB → 51 KB | measured The default format inlines dynamic imports, so the embedding library would have reached every visitor, opt-in or not. |
| Two-browser convergence, median of five round trips, three runs | 29 / 30 / 30 ms | measured Against a local relay on the same machine, no sample above 36 ms; the acceptance criterion was under 1 s. Not a production number: the internet round trip between two real browsers is not in it. |
| Largest workspace the relay still saves | ~6,130 issues | measured About 310 encoded bytes per generated issue at 6,000 issues, against a 1.9 MB guard under Cloudflare's 2 MB row cap. Longer descriptions lower it: ADR 0010 measured 2,760 issues at the 2 MB cap with descriptions of about 400 characters. |
Measured: SyncTicket README.md, Phase 6, and docs/adr/0005 (measured locally in Chromium) · SyncTicket docs/adr/0005 (measured locally in Chromium at 10,000 issues) · SyncTicket README.md, Phase 8, and docs/adr/0006 (measured locally in Node on 10,000 generated issues) · SyncTicket README.md, Phase 8 · SyncTicket README.md, Phase 9 (re-measured in Phase 13, docs/ROADMAP.md) · SyncTicket PRODUCT.md, docs/adr/0010 and src/lib/sync/snapshot-capacity.test.ts
Cost & latency
| Metric | Value | Status |
|---|---|---|
| Monthly infrastructure cost | 0 EUR/month | measured The free plan's ceilings are the product's limits: 100,000 requests and 100,000 rows written a day, account-wide. |
| Deployed upload, app Worker / relay Worker | 1,579.82 KiB / 333.83 KiB | measured Plus 42 static assets; the largest is the 23.6 MB ONNX runtime, under Cloudflare's 25 MiB per-file cap. |
Measured: SyncTicket PRODUCT.md, Positioning, and docs/DEPLOY.md (both Workers on the Cloudflare free plan) · SyncTicket docs/DEPLOY.md, The deploy that exists (2026-10-22)
Safety & failure modes
Risks
- Anyone with a workspace link can read and edit it, and pass the link on
- A flood of requests can spend the account's free daily budget and take both Workers offline until 00:00 UTC
- A workspace past the 2 MB row cap, or a day past 100,000 row writes, stops being saved while it still appears to sync
- A peer's display name and colour come from anyone who holds the link
Mitigations
- A random 128-bit id that cannot be found by brute force, no accounts by design, and a demo that says not to enter real data
- A room-name shape check before any Durable Object is created and 30 WebSocket upgrades per 60 seconds per IP, measured permissive in production (42 of 60 concurrent attempts connected): it turns a flood into a trickle and guarantees nothing
- The size is checked before every save, the last good snapshot is kept, a health row survives hibernation and every client is told; the row-budget route is reported generically
- Names are stripped of control and bidi characters and length-capped; a colour travels as a hue number rendered through oklch(), never as a string