PageSourceSearch

https://www.palanikannan.com/assets/local-first-in-production-fosdem-2026-recap-CaIrAZ68.js

js palanikannan.com collected 2026-10-08 06:46:18 UTC 20,753 bytes, 127 lines download raw bytes

1import{j as e}from"./main-CsRtcZnr.js";import{u as o}from"./index-CsJ-uLA8.js";const h={title:"Local-First in Production — FOSDEM 2026 Recap",description:"A short recap of my FOSDEM talk on building Plane’s collaborative Wiki with Yjs, the production failures that taught us the most, and the slides for anyone who couldn’t make it.",date:"2026-02-02",readingTime:"6 min",image:"https://1txs4adwec.ucarecd.net/1bdcc90e-fe57-4387-ac20-bf3e2bc2b97e/f2d91c550ab4472198eba076fd1be4bb1.webp"};function i(s){const n={a:"a",code:"code",em:"em",figure:"figure",h2:"h2",h3:"h3",li:"li",ol:"ol",p:"p",pre:"pre",span:"span",strong:"strong",ul:"ul",...o(),...s.components},{EventCard:r,MasonryGallery:a,VideoPlayer:l}=n;return r||t("EventCard"),a||t("MasonryGallery"),l||t("VideoPlayer"),e.jsxs(e.Fragment,{children:[e.jsx(n.p,{children:"FOSDEM 2026 is done. I’m still processing it."}),`
2`,e.jsx(n.p,{children:"A few months ago this was just a nervy announcement post. Now it’s a real memory: the room, the questions, the energy, and the very surreal moment of talking about Yjs and local-first systems in front of people I’ve been learning from."}),`
3`,e.jsx(n.p,{children:"This was my first international conference talk, and it meant a lot."}),`
4`,e.jsx(n.p,{children:"There was a quick AV reset at the start that ate about 10–13 minutes, so I ended up compressing the talk on the fly. The organizers handled it smoothly and kept everything moving, and I really appreciated the calm energy in the room."}),`
5`,e.jsx(n.h2,{children:"The Talk (Now Done)"}),`
6`,e.jsx(r,{title:"Local-First in Production: How We Built Plane's Collaborative Wiki with Yjs",date:"February 1, 2026",time:"10:00 - 10:30 CET",room:"K.3.201",location:"ULB Campus, Brussels",track:"Local-First, sync engines, CRDTs",url:"https://fosdem.org/2026/schedule/event/TCH8XQ-plane-wiki-local-first-in-production-with-yjs/"}),`
7`,e.jsx(n.h3,{children:"Watch the Recording"}),`
8`,e.jsx(l,{src:"https://www.youtube.com/watch?v=Tg3nMDTF0C4",title:"Local-First in Production: How We Built Plane's Collaborative Wiki with Yjs"}),`
9`,e.jsxs(n.p,{children:["If you're new here: I work on ",e.jsx(n.a,{href:"https://plane.so",children:"Plane"}),", an open-source project management tool (projects, wiki, AI, everything in one workspace). Our Wiki is collaborative, and Yjs is the engine that makes it real-time and local-first."]}),`
10`,e.jsx(n.p,{children:"This talk was a production story, not a research talk — how we built Plane's collaborative Wiki on Yjs, the architecture choices we made, and the sharp edges we hit while shipping it for real users."}),`
11`,e.jsx(n.h2,{children:"The Story I Opened With"}),`
12`,e.jsx(n.p,{children:"Last year we upgraded a dependency. Memory usage exploded. Seven pods went down. Kubernetes kept restarting them. WebSockets were basically unusable."}),`
13`,e.jsx(n.p,{children:"Our users? Zero data lost. They just kept editing."}),`
14`,e.jsxs(n.p,{children:["That moment framed the whole talk: ",e.jsx(n.strong,{children:"how did we get to a place where a catastrophic server incident didn't destroy user work?"})," The answer wasn't magic. It was careful design, hard lessons, and a lot of Murphy's Law along the way."]}),`
15`,e.jsx(n.h3,{children:"Moments from Brussels"}),`
16`,e.jsx(a,{images:[{src:"https://1txs4adwec.ucarecd.net/c181bc39-4c72-4c0f-9167-a372506d0d64/fodembrandon.webp",alt:"FOSDEM 2026 with Brandon"},{src:"https://1txs4adwec.ucarecd.net/31f2b5c6-7cf6-4d2c-8705-ca3cebb20582/brandonfosdem.webp",alt:"Brandon at FOSDEM"},{src:"https://1txs4adwec.ucarecd.net/61a352be-b66a-4799-9ca6-d379c2a9a096/HAUJ5jiXoAA3zbN.jpeg",alt:"FOSDEM moment"},{src:"https://1txs4adwec.ucarecd.net/27b26396-1bb6-474f-8e08-9ef7203ba03f/Screenshot20260204at54812PM1.webp",alt:"Plane at FOSDEM — center stage"},{src:"https://1txs4adwec.ucarecd.net/4affccb4-bc88-445d-b68f-a02a89ff1cca/HAESsMMWcAA3Tan.jpeg",alt:"FOSDEM experience"},{src:"https://1txs4adwec.ucarecd.net/4a85e78f-b903-4aec-b746-8a515a7193ee/HAEP5eEWEAAWIIy.jpeg",alt:"FOSDEM memory"},{src:"https://1txs4adwec.ucarecd.net/c49254f9-411c-4b3c-a130-a3c09101b1a2/HAAL5yIXcAEhC2f.jpeg",alt:"FOSDEM 2026"}],columns:3,gap:"md",className:"grayscale"}),`
17`,e.jsx(n.h2,{children:"What I Shared (The Production Bits)"}),`
18`,e.jsx(n.h3,{children:"1) The One Rule: Save the Binary"}),`
19`,e.jsxs(n.p,{children:["When working with Yjs, the ",e.jsx(n.strong,{children:"Y.Doc binary is the source of truth"}),". JSON/HTML are just views."]}),`
20`,e.jsx(n.p,{children:"We initially stored JSON editor content. When we added collaborative editing, we migrated to Yjs on demand. The first time a doc was opened, we converted it and loaded it into Yjs."}),`
21`,e.jsx(n.p,{children:"Here’s the trap: if a user opens the doc and reads without editing, Hocuspocus doesn’t persist (it only saves on change). That means the converted binary never gets s
21aved. The client keeps a copy in IndexedDB, but the server still has the old JSON."}),`
22`,e.jsx(n.p,{children:"Next time they open the doc, the server converts again → new client IDs → Yjs merges two versions of the same content → duplication and chaos."}),`
23`,e.jsxs(n.p,{children:["The fix was simple: ",e.jsx(n.strong,{children:"persist the binary immediately after conversion. Don’t wait for edits."})]}),`
24`,e.jsx(n.figure,{"data-rehype-pretty-code-figure":"",children:e.jsx(n.pre,{tabIndex:"0","data-language":"ts","data-theme":"github-dark-dimmed",children:e.jsxs(n.code,{"data-language":"ts","data-theme":"github-dark-dimmed",style:{display:"grid"},children:[e.jsx(n.span,{"data-line":"",children:e.jsx(n.span,{style:{color:"#768390"},children:"// Snapshot the full Y.Doc as binary and persist it immediately"})}),`
25`,e.jsxs(n.span,{"data-line":"",children:[e.jsx(n.span,{style:{color:"#F47067"},children:"const"}),e.jsx(n.span,{style:{color:"#6CB6FF"},children:" base"}),e.jsx(n.span,{style:{color:"#F47067"},children:" ="}),e.jsx(n.span,{style:{color:"#F47067"},children:" new"}),e.jsx(n.span,{style:{color:"#DCBDFB"},children:" Doc"}),e.jsx(n.span,{style:{color:"#ADBAC7"},children:"();"})]}),`
26`,e.jsxs(n.span,{"data-line":"",children:[e.jsx(n.span,{style:{color:"#DCBDFB"},children:"applyUpdate"}),e.jsx(n.span,{style:{color:"#ADBAC7"},children:"(base, "}),e.jsx(n.span,{style:{color:"#DCBDFB"},children:"encodeStateAsUpdate"}),e.jsx(n.span,{style:{color:"#ADBAC7"},children:"(ydoc));"})]})]})})}),`
27`,e.jsx(n.h3,{children:"2) Never Overwrite, Always Merge"}),`
28`,e.jsx(n.p,{children:"CRDTs converge by merging operations, not by overwriting blobs. If your storage layer replaces documents wholesale, you’ve built Dropbox, not Google Docs."}),`
29`,e.jsx(n.p,{children:"This rule applied twice for us:"}),`
30`,e.jsxs(n.ul,{children:[`
31`,e.jsx(n.li,{children:"At the Yjs layer (operations merge)"}),`
32`,e.jsx(n.li,{children:"At the storage layer (we never overwrite, we always merge)"}),`
33`]}),`
34`,e.jsx(n.h3,{children:"3) The Fallback That Saved Us"}),`
35`,e.jsx(n.p,{children:"When WebSockets die, HTTP takes over."}),`
36`,e.jsx(n.p,{children:"Pseudocode for the fallback:"}),`
37`,e.jsxs(n.ul,{children:[`
38`,e.jsx(n.li,{children:"Fetch latest from DB"}),`
39`,e.jsx(n.li,{children:"Merge into local doc"}),`
40`,e.jsx(n.li,{children:"Save back"}),`
41`,e.jsx(n.li,{children:"Repeat every few seconds, with a manual save button too"}),`
42`]}),`
43`,e.jsx(n.p,{children:"Because CRDT merges are idempotent, it converges. Even when two clients race to save, the next cycle pulls both changes back together."}),`
44`,e.jsx(n.p,{children:"This fallback used to be our primary sync path in early days. It held up under pressure."}),`
45`,e.jsx(n.h3,{children:"4) What We Monitor Now"}),`
46`,e.jsx(n.p,{children:"The incident taught us what actually matters in production:"}),`
47`,e.jsxs(n.ul,{children:[`
48`,e.jsxs(n.li,{children:[e.jsx(n.strong,{children:"Docs per pod"})," (know when to scale)"]}),`
49`,e.jsxs(n.li,{children:[e.jsx(n.strong,{children:"Memory per doc"})," (binary size as proxy for “monster” docs)"]}),`
50`,e.jsxs(n.li,{children:[e.jsx(n.strong,{children:"Update rate"})," (who’s hammering the server)"]}),`
51`,e.jsxs(n.li,{children:[e.jsx(n.strong,{children:"Persist latency/failures"})," (early warnings)"]}),`
52`,e.jsxs(n.li,{children:[e.jsx(n.strong,{children:"Force-close codes"})," (4001/4002 for overload)"]}),`
53`,e.jsxs(n.li,{children:[e.jsx(n.strong,{children:"Time-to-first-content"})," (is caching really working?)"]}),`
54`]}),`
55`,e.jsx(n.p,{children:"We learned this the hard way. You don’t have to."}),`
56`,e.jsx(n.h2,{children:"The Debugging Story (Murphy’s Law)"}),`
57`,e.jsx(n.p,{children:"We upgraded to Hocuspocus v3, did all the homework, rolled out on a Friday, and I even posted a confident tweet about it."}),`
58`,e.jsx(n.p,{children:"Then a huge customer onboarded that same day with massive tables. Memory climbed, pods restarted, WebSockets dropped, and memory never went back down."}),`
59`,e.jsxs(n.p,{children:["The bug was subtle: ",e.jsx(n.span,{"data-rehype-pretty-code-figure":"",children:e.jsx(n.co
59de,{"data-language":"plaintext","data-theme":"github-dark-dimmed",children:e.jsx(n.span,{"data-line":"",children:e.jsx(n.span,{children:"process.nextTick"})})})})," runs before Promise continuations."]}),`
60`,e.jsxs(n.p,{children:["The unload check ran ",e.jsx(n.em,{children:"before"})," a debounce flag cleared, so it thought the doc was still executing and never unloaded it. Docs stayed in memory forever."]}),`
61`,e.jsxs(n.p,{children:["The fix was a small change in the debounce utility: an ",e.jsx(n.span,{"data-rehype-pretty-code-figure":"",children:e.jsx(n.code,{"data-language":"plaintext","data-theme":"github-dark-dimmed",children:e.jsx(n.span,{"data-line":"",children:e.jsx(n.span,{children:"afterExecution"})})})})," callback that runs after the flag is cleared. Deterministic. No event loop roulette."]}),`
62`,e.jsx(n.h2,{children:"When Docs Get Too Big"}),`
63`,e.jsx(n.p,{children:"We also hit another sharp edge: huge pastes (like 50MB images). The gateway rejects with 413. One giant paste blocks all other saves. Deadlock."}),`
64`,e.jsx(n.p,{children:"Our solution: detect oversized docs and force-close the WebSocket with app-specific close codes (4001 for “doc too big”, 4002 for “memory pressure”). We broadcast the force-close through Redis so every server drops the doc and frees memory."}),`
65`,e.jsx(n.figure,{"data-rehype-pretty-code-figure":"",children:e.jsx(n.pre,{tabIndex:"0","data-language":"ts","data-theme":"github-dark-dimmed",children:e.jsxs(n.code,{"data-language":"ts","data-theme":"github-dark-dimmed",style:{display:"grid"},children:[e.jsxs(n.span,{"data-line":"",children:[e.jsx(n.span,{style:{color:"#F47067"},children:"export"}),e.jsx(n.span,{style:{color:"#F47067"},children:" enum"}),e.jsx(n.span,{style:{color:"#F69D50"},children:" CloseCode"}),e.jsx(n.span,{style:{color:"#ADBAC7"},children:" {"})]}),`
66`,e.jsxs(n.span,{"data-line":"",children:[e.jsx(n.span,{style:{color:"#6CB6FF"},children:"  FORCE_CLOSE"}),e.jsx(n.span,{style:{color:"#F47067"},children:" ="}),e.jsx(n.span,{style:{color:"#6CB6FF"},children:" 4000"}),e.jsx(n.span,{style:{color:"#ADBAC7"},children:","})]}),`
67`,e.jsxs(n.span,{"data-line":"",children:[e.jsx(n.span,{style:{color:"#6CB6FF"},children:"  DOCUMENT_TOO_LARGE"}),e.jsx(n.span,{style:{color:"#F47067"},children:" ="}),e.jsx(n.span,{style:{color:"#6CB6FF"},children:" 4001"}),e.jsx(n.span,{style:{color:"#ADBAC7"},children:","})]}),`
68`,e.jsxs(n.span,{"data-line":"",children:[e.jsx(n.span,{style:{color:"#6CB6FF"},children:"  MEMORY_PRESSURE"}),e.jsx(n.span,{style:{color:"#F47067"},children:" ="}),e.jsx(n.span,{style:{color:"#6CB6FF"},children:" 4002"}),e.jsx(n.span,{style:{color:"#ADBAC7"},children:","})]}),`
69`,e.jsxs(n.span,{"data-line":"",children:[e.jsx(n.span,{style:{color:"#6CB6FF"},children:"  SECURITY_VIOLATION"}),e.jsx(n.span,{style:{color:"#F47067"},children:" ="}),e.jsx(n.span,{style:{color:"#6CB6FF"},children:" 4003"}),e.jsx(n.span,{style:{color:"#ADBAC7"},children:","})]}),`
70`,e.jsx(n.span,{"data-line":"",children:e.jsx(n.span,{style:{color:"#ADBAC7"},children:"}"})})]})})}),`
71`,e.jsx(n.figure,{"data-rehype-pretty-code-figure":"",children:e.jsx(n.pre,{tabIndex:"0","data-language":"ts","data-theme":"github-dark-dimmed",children:e.jsxs(n.code,{"data-language":"ts","data-theme":"github-dark-dimmed",style:{display:"grid"},children:[e.jsxs(n.span,{"data-line":"",children:[e.jsx(n.span,{style:{color:"#ADBAC7"},children:"document.connections."}),e.jsx(n.span,{style:{color:"#DCBDFB"},children:"forEach"}),e.jsx(n.span,{style:{color:"#ADBAC7"},children:"(({ "}),e.jsx(n.span,{style:{color:"#F69D50"},children:"connection"}),e.jsx(n.span,{style:{color:"#ADBAC7"},children:" }) "}),e.jsx(n.span,{style:{color:"#F47067"},children:"=>"}),e.jsx(n.span,{style:{color:"#ADBAC7"},children:" {"})]}),`
72`,e.jsxs(n.span,{"data-line":"",children:[e.jsx(n.span,{style:{color:"#ADBAC7"},children:"  connection."}),e.jsx(n.span,{style:{color:"#DCBDFB"},children:"sendStateless"}),e.jsx(n.span,{style:{color:"#ADBAC7"},children:"("}),e.jsx(n.span,{style:{color:"#6CB6FF"},children:"JSON"}),e.jsx(n.span,{style:{color:"#ADBAC7"},children:"."}),e.jsx(n.span,{style:{color:"#DCBDFB"},children:"stringify"}),e.jsx(n.span,{style:{color:"#ADBAC7"},children:"(forceCloseMessage));"})]}),`
73`,e.jsx(n.span,{"data-line":"",children:e.jsx(n.span,{style:{color:"#ADBAC7"},children:"});"})}),`
74`,e.jsx(n.span,{"data-line":"",children:" "}),`
75`,e.jsxs(n.span,{"data-line":"",children:[e.jsx(n.span,{style:{color:"#ADBAC7"},children:"document.connections."}),e.jsx(n.span,{style:{color:"#DCBDFB"}
75,children:"forEach"}),e.jsx(n.span,{style:{color:"#ADBAC7"},children:"(({ "}),e.jsx(n.span,{style:{color:"#F69D50"},children:"connection"}),e.jsx(n.span,{style:{color:"#ADBAC7"},children:" }) "}),e.jsx(n.span,{style:{color:"#F47067"},children:"=>"}),e.jsx(n.span,{style:{color:"#ADBAC7"},children:" {"})]}),`
76`,e.jsxs(n.span,{"data-line":"",children:[e.jsx(n.span,{style:{color:"#ADBAC7"},children:"  connection."}),e.jsx(n.span,{style:{color:"#DCBDFB"},children:"close"}),e.jsx(n.span,{style:{color:"#ADBAC7"},children:"({ code, reason });"})]}),`
77`,e.jsx(n.span,{"data-line":"",children:e.jsx(n.span,{style:{color:"#ADBAC7"},children:"});"})})]})})}),`
78`,e.jsx(n.p,{children:"Clients handle this differently from normal disconnects: they don’t retry endlessly. They show the reason to the user and let them fix it."}),`
79`,e.jsx(n.h2,{children:"The Local-First UX (No Loading Screen)"}),`
80`,e.jsxs(n.p,{children:["Local-first isn’t just offline. It’s ",e.jsx(n.strong,{children:"instant content on first load"}),"."]}),`
81`,e.jsxs(n.p,{children:["We use ",e.jsx(n.span,{"data-rehype-pretty-code-figure":"",children:e.jsx(n.code,{"data-language":"plaintext","data-theme":"github-dark-dimmed",children:e.jsx(n.span,{"data-line":"",children:e.jsx(n.span,{children:"y-indexeddb"})})})})," so cached content appears immediately. The server sync runs in parallel in the background."]}),`
82`,e.jsx(n.p,{children:"We show content if:"}),`
83`,e.jsxs(n.ol,{children:[`
84`,e.jsx(n.li,{children:"The server is synced"}),`
85`,e.jsx(n.li,{children:"The server is disconnected (offline)"}),`
86`,e.jsx(n.li,{children:"The cache is ready and has content"}),`
87`]}),`
88`,e.jsx(n.p,{children:"That parallel path removes network latency from the critical path. No scary empty screen. No fake “loading” for big docs."}),`
89`,e.jsx(n.h2,{children:"Server-Side Editing Without Data Loss"}),`
90`,e.jsx(n.p,{children:"We needed to edit docs on the server while users are typing. Directly writing to the live doc risks stomping edits. We also wanted to reuse Tiptap’s APIs on the server."}),`
91`,e.jsxs(n.p,{children:["The solution was a ",e.jsx(n.strong,{children:"Shadow Y.Doc"}),":"]}),`
92`,e.jsxs(n.ul,{children:[`
93`,e.jsx(n.li,{children:"Clone the live doc into a shadow"}),`
94`,e.jsx(n.li,{children:"Run server edits on the shadow (with a fake DOM)"}),`
95`,e.jsx(n.li,{children:"Compute the delta and merge back into the live doc"}),`
96`]}),`
97`,e.jsx(n.p,{children:"Users keep typing. The server keeps editing. Yjs merges both. No overwrites."}),`
98`,e.jsx(n.figure,{"data-rehype-pretty-code-figure":"",children:e.jsx(n.pre,{tabIndex:"0","data-language":"ts","data-theme":"github-dark-dimmed",children:e.jsxs(n.code,{"data-language":"ts","data-theme":"github-dark-dimmed",style:{display:"grid"},children:[e.jsx(n.span,{"data-line":"",children:e.jsx(n.span,{style:{color:"#768390"},children:"// Shadow Y.Doc pattern for non-destructive server edits"})}),`
99`,e.jsxs(n.span,{"data-line":"",children:[e.jsx(n.span,{style:{color:"#F47067"},children:"const"}),e.jsx(n.span,{style:{color:"#6CB6FF"},children:" base"}),e.jsx(n.span,{style:{color:"#F47067"},children:" ="}),e.jsx(n.span,{style:{color:"#F47067"},children:" new"}),e.jsx(n.span,{style:{color:"#DCBDFB"},children:" Doc"}),e.jsx(n.span,{style:{color:"#ADBAC7"},children:"();"})]}),`
100`,e.jsxs(n.span,{"data-line":"",children:[e.jsx(n.span,{style:{color:"#DCBDFB"},children:"applyUpdate"}),e.jsx(n.span,{style:{color:"#ADBAC7"},children:"(base, "}),e.jsx(n.span,{style:{color:"#DCBDFB"},children:"encodeStateAsUpdate"}),e.jsx(n.span,{style:{color:"#ADBAC7"},children:"(ydoc));"})]}),`
101`,e.jsx(n.span,{"data-line":"",children:" "}),`
102`,e.jsxs(n.span,{"data-line":"",children:[e.jsx(n.span,{style:{color:"#F47067"},children:"const"}),e.jsx(n.span,{style:{color:"#6CB6FF"},children:" update"}),e.jsx(n.span,{style:{color:"#F47067"},children:" ="}),e.jsx(n.span,{style:{color:"#DCBDFB"},children:" encodeStateAsUpdate"}),e.jsx(n.span,{style:{color:"#ADBAC7"},children:"(base, "}),e.jsx(n.span,{style:{color:"#DCBDFB"},children:"encodeStateVector"}),e.jsx(n.span,{style:{color:"#ADBAC7"},children:"(ydoc));"})]}),`
103`,e.jsxs(n.span,{"data-line":"",children:[e.jsx(n.span,{style:{color:"#DCBDFB"},children:"applyUpdate"}),e.jsx(n.span,{style:{color:"#ADBAC7"},children:"(ydoc, update); "}),e.jsx(n.span,{style:{color:"#768390"},children:"// CRDT merge into live doc"})]})]})})}),`
104`,e.jsx(n.h2,{children:"Slides / Resources"}),`
105`,e.jsxs(n.ul,{children:[`
106`,e.jsx(n.li,{children:e.jsx(n.a,{href:"https://manus.im/share/file/890c3ec7-9dea-4fc5-908a-4a0371055342",children:"Slides"})}),`
107`,e.jsx(n.li,{children:e.jsx(n.a,{href:"https://fosdem.org/2026/schedule/event/TCH8XQ-plane-wiki-local-first-in-production-with-yjs/",children:"Event page"})}),`
108`]}),`
109`,e.jsxs(n.p,{children:["If you’ve read my ",e.jsx(n.a,{href:"/blogs/version-history-and-snapshots-in-yjs",children:"version history series"}),", you’ll recognize some of the production lessons. The talk w
109as the “cleaned up” version of those painful learnings."]}),`
110`,e.jsx(n.h2,{children:"If You Take One Thing Away"}),`
111`,e.jsxs(n.ol,{children:[`
112`,e.jsxs(n.li,{children:[e.jsx(n.strong,{children:"Save the binary."})," Y.Doc binary is the source of truth."]}),`
113`,e.jsxs(n.li,{children:[e.jsx(n.strong,{children:"Never overwrite, always merge."})," At the Yjs level and at the storage level."]}),`
114`,e.jsxs(n.li,{children:[e.jsx(n.strong,{children:"Build fallbacks."})," WebSockets die. Servers crash. Users keep typing."]}),`
115`]}),`
116`,e.jsx(n.p,{children:"That’s what took us from “works in demo” to “survives production.”"}),`
117`,e.jsx(n.h2,{children:"Thank You"}),`
118`,e.jsx(n.p,{children:"Huge thanks to the FOSDEM organizers and the Local-First devroom team for a warm, well-run room. The community was kind and curious, and that made it easier to do the scary thing."}),`
119`,e.jsx(n.p,{children:"I also want to thank Kevin and the entire Yjs community. This library is a ridiculous piece of engineering, and it’s the foundation for everything we’ve built at Plane."}),`
120`,e.jsx(n.p,{children:"If you want to chat about local-first systems, Yjs, or collaborative editors, I’m easy to find:"}),`
121`,e.jsxs(n.ul,{children:[`
122`,e.jsxs(n.li,{children:["X/Twitter: ",e.jsx(n.a,{href:"https://x.com/Palanikannan_M",children:"https://x.com/Palanikannan_M"})]}),`
123`,e.jsxs(n.li,{children:["GitHub: ",e.jsx(n.a,{href:"https://github.com/Palanikannan1437",children:"https://github.com/Palanikannan1437"})]}),`
124`,e.jsxs(n.li,{children:["LinkedIn: ",e.jsx(n.a,{href:"https://www.linkedin.com/in/palanikannan-m-021822200/",children:"https://www.linkedin.com/in/palanikannan-m-021822200/"})]}),`
125`,e.jsxs(n.li,{children:["Email: ",e.jsx(n.a,{href:"mailto:[email protected]",children:"[email protected]"})]}),`
126`]}),`
127`,e.jsx(n.p,{children:"— Palanikannan"})]})}function p(s={}){const{wrapper:n}={...o(),...s.components};return n?e.jsx(n,{...s,children:e.jsx(i,{...s})}):i(s)}function t(s,n){throw new Error("Expected component `"+s+"` to be defined: you likely forgot to import, pass, or provide it.")}export{p as default,h as frontmatter};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.