← All projects

Fieldnotes: Infinite Canvas Notes

A local-first notes app built with Lit, Yjs, and CRDTs. Engineering went well. The product didn't.

I built this to answer a question I kept running into at work: what does a truly local-first app actually feel like to make?

Not local-first as a marketing word. Local-first as a technical commitment — the data lives in your browser, works offline from day one, and sync is additive rather than load-bearing. I wanted to feel the seams.

The result was a spatial notes app. An infinite canvas where you place cards, group them into boxes, drill into nested contexts. Every canvas is encrypted and stored in IndexedDB via Yjs. Sync to a server was possible but optional and never required for the app to function.

Try it — data stays in your browser. Double-click to create a card, right-click for more options. Ctrl+P to search, Ctrl+L for list view. The simulated cursors demonstrate what live collaboration looked like.

The Technical Choices

I made decisions I still think were right, even if the product didn’t survive them.

Yjs as the data layer. Yjs is a CRDT library — Conflict-free Replicated Data Type. Rather than storing notes as plain JSON in localStorage, every canvas is a live Yjs document. Changes are expressed as small binary updates that can be merged in any order without conflicts. This is what makes real-time collaboration possible without a central source of truth.

The main trade-off is that you think about data differently. There is no “update the title” operation. You work with Y.Text and Y.Map — shared types that track the full history of mutations. Getting used to this took a couple of weeks.

Web Components via Lit. I specifically avoided React for this project. I wanted to understand the platform underneath the framework: custom elements, shadow DOM, the full event system. Lit gives you reactive properties and templates with minimal abstraction. Writing a canvas renderer that only updates visible nodes — viewport culling — in plain Web Components was genuinely satisfying.

AES-GCM encryption on IndexedDB. Private canvases never leave the device. Updates are AES-GCM encrypted before they hit IndexedDB, so auditing that store in DevTools shows ciphertext, not note text. Live collab was the other mode: the in-memory Yjs document stayed readable and synced to the server over WebSocket (TLS on the wire). The backend stored raw Yjs state, so it could read shared canvases. Private scratchpad or live collab — pick one.

Hocuspocus for WebSocket sync. When a canvas is marked as “synced,” it connects to a Hocuspocus server — a WebSocket server that understands Yjs binary format. Multiple clients can merge their document states through the server without the server needing to understand the content. The server stores raw Yjs binary and routes updates. The backend was Elysia on Bun, connected to PostgreSQL via Drizzle.

Browser-first initialization. On load, the local IndexedDB sync completes first, then the WebSocket sync happens non-blocking in the background. Users see their data immediately, and remote updates arrive as diffs. This felt correct from the start and I would not change it.

What Worked

The core experience was surprisingly good. Dragging cards around an infinite canvas, drilling into nested boxes, editing markdown in place — it felt lightweight and fast. Viewport culling meant the canvas stayed responsive even with hundreds of nodes. The Yjs data layer made real-time sync feel like a natural extension rather than a feature layered on top.

Keeping notes private shaped the data layer. EncryptedIndexedDBPersistence wraps Yjs updates in AES-GCM before writing IndexedDB, so a private canvas looks like opaque bytes in DevTools. That encryption is an at-rest adapter, not end-to-end sync: the live document in memory is always plaintext. A shared canvas therefore syncs readable state to the server (encrypted only in transit), while a private one simply never connects. The honest tradeoff was private scratchpad versus live collab the server could persist.

Local-first worked exactly as intended. Canvases opened instantly. Closing the app and reopening it felt seamless. The backup system — serializing the full Yjs state to a base64 string in localStorage every second as a fast-paint fallback — meant the app was resilient in ways that typical web apps are not.

Why I Stopped

The honest answer is that I solved the engineering problem and lost interest in the product problem.

I chose the infinite canvas format because I find it intellectually interesting, not because I needed one. After building it, I found I did not actually want to use it for notes. I have tried many spatial note apps over the years and they share the same friction: organizing the space becomes a task in itself. You end up managing the canvas instead of thinking.

There was also a scope problem. The collaboration features — live cursors, awareness state, anonymous-to-authenticated migration, canvas sharing codes — were genuinely fun to build but added surface area I did not want to maintain as a solo project. Each feature needed the server, the WebSocket layer, auth, and the database to all work correctly together. That is a reasonable stack for a product. For a side project, it became a drag.

The bigger realization came later. A scratchpad for notes in the age of AI needs to be redesigned from the ground up — not just local-first storage and a nicer canvas. Auto-organization, AI-assisted merging, text-to-speech, MCP-backed search through an LLM: those are the features that would make the product feel current. Fieldnotes was getting ready before that wave hit in December 2025. Continuing as a pure spatial notes app would have meant shipping something already outdated, and I did not want to rebuild the product around AI halfway through.

What I Kept

The local-first architecture is something I think about now whenever I am working on data flows in production. The insight — that sync should be additive, that the local state should always be the source of truth, that the network is an optimization rather than a requirement — is useful well beyond note-taking apps.

The Yjs mental model also changed how I think about collaborative state. Most collaborative features in real apps are built on last-write-wins over websockets, which is fine until users edit the same thing at the same time. CRDTs are a genuinely different answer to that problem, and understanding them at the implementation level makes the trade-offs legible.

The demo above is the frontend running in local mode. Real-time collaboration and cloud sync are removed. Everything else — the canvas, cards, boxes, keyboard shortcuts, trash, export — works in your browser. Data is stored in IndexedDB and cleared when you clear site data.