原文是 Dennis Brotzky 的 How's Linear so fast? A technical breakdown。作者没在 Linear 工作过,内容是他从 Linear 的产物、官方博客和演讲里拆出来的。下面的技术栈和数字都以原文为准,我加的是自己的理解。
Linear 为什么这么快:数据库在浏览器里
Linear 快的核心原因是它把数据库放在了浏览器的 IndexedDB 里,所有修改先应用到本地,再异步推到服务端,服务端通过 WebSocket 把增量广播给其他客户端。
普通 CRUD 应用改个标题大概是这样:
async function updateIssue(issue, title) {
showSpinner()
const res = await fetch(`/api/issues/${issue.id}`, {
method: 'PATCH',
body: JSON.stringify({ title }),
})
setIssue(await res.json())
hideSpinner()
}
点一下要等一个来回,原文估算 300ms 左右。Linear 里是这样:
issue.title = '新标题'
issue.save()
第一行改的是内存里的 MobX observable,界面同步重渲染;save() 只是把这次改动排进事务队列,同步引擎攒一批再发给服务端。用户感知到的耗时就是几毫秒。
作者一句话说得很直白:你要对抗的最大瓶颈是网络。所以能不让用户等网络,就别让他等。
Linear 联合创始人 Tuomas Artman 在演讲里说过,他写的第一行代码就是同步引擎。
首屏怎么做到秒开
本地有数据,操作当然快。但第一次打开还是得下载代码。原文说 Linear 现在仍然有大约 21 MB minify 后的 JS,它的做法是拆小、提前拿、拿过就缓存:
- 构建管线换了四次:Parcel → Rollup → Vite → Rolldown。只面向现代浏览器,不打 polyfill、不转 ES5。按 Linear 官方博客的数据,发出去的代码少了 50%,Safari 下活跃 issue 视图的首次绘制快了 59%。
modulepreload解决瀑布流:chunk 拆成几百个后,浏览器要解析完一个才知道下一个要什么。Linear 在index.html里把关键路径上的 chunk(比如vendor-mobx、SyncWebSocket、DatabaseManager)全部列成<link rel=modulepreload crossorigin>,并行下载。- Service Worker 预缓存约 1200 个资源:停在登录页的几秒钟里,整个应用就被后台拉完了。之后的访问直接从 Service Worker 缓存读,离线也能看、能建、能改 issue。
- 每个 npm 包单独一个 vendor chunk:升级一个依赖只让这一个 chunk 失效,不会把整个
vendor.js废掉。 - 应用外壳内联在 HTML 里:关键 CSS 和一小段启动脚本直接写在
<head>,从localStorage读出侧边栏宽度、颜色、暗色模式,先把加载画面画出来,bundle 还没到。
我觉得最有意思的是最后一条:先渲染,再鉴权。常规流程是验 session → 拿用户 → 拿 workspace → 渲染,动辄一两秒。Linear 的启动脚本只看 localStorage.ApplicationStore 存不存在,存在就直接把 IndexedDB 里缓存的 workspace 画出来。真正的 token 在 cookie 里,如果过期了,下一个请求(WebSocket 握手、同步、HTTP 都算)会拿到 401,再跳登录页。
这和改数据用的是同一个思路:默认走成功路径,后台再校验。
Linear 同步引擎的三根柱子
Linear 的同步引擎靠三件事做到快:数据启动时就在内存里、修改不等网络、服务端回来的增量只更新对应的那一格。原文没有细讲内部实现,想看细节可以读文末 wzhudev 的逆向分析。
1. 数据已经在那了。 启动时从 IndexedDB 把数据灌进内存里的 MobX 对象池,UI 查询直接打对象池,没有"加载 issue 中"这个状态。数据量最大的 Issue 和 Comment 两张表是按需懒加载的,所以启动耗时跟 workspace 的结构有关,跟大小关系不大。原文说一万个 issue 的 workspace 和一百个的启动差不多快。
2. 修改不等网络。 改一个状态,几乎同时发生三件事:更新 MobX observable、写进 IndexedDB 里持久化的事务队列、排队发往服务端。服务端拒绝的话 observable 回滚,界面闪一下。但大部分非法操作在创建事务之前就被拦掉了,所以很少见。刷新页面事务也不会丢,因为队列在 IndexedDB 里。
3. 一个增量只改一格。 服务端推回来的确认(自己的或别人的改动)是很小的 JSON,写到对应的 MobX observable 上。每个 model 的每个属性都是单独的 observable,读它的组件包在 observer() 里,所以改一个字段只重渲染读这个字段的组件。批量改 50 个 issue,就是 50 个格子重渲染,列表本身不动。
三件事少一件都不行:没有本地库,首屏要等网络;没有乐观写,保存要转圈;没有细粒度 observable,更新会卡。
同步失败和冲突怎么处理
Linear 同步失败时,靠持久化在本地的事务队列重发或回滚;两个人改了同一个字段,按服务端最后处理的那次写入为准(last-writer-wins)。
先改本地、后同步,绕不开这两个问题。原文只提了一句"服务端拒绝就回滚",下面的细节来自 wzhudev 的逆向分析,不是官方文档。
请求失败分两种。
- 断网、请求没发出去:事务已经写进 IndexedDB 的
__transactions表,网络恢复后自动重发。关掉浏览器也不会丢,下次启动读回来重放。 - 服务端拒绝(没权限、数据不合法):事务调
rollback,把内存里 MobX model 的改动撤掉,界面闪回原值。
有个细节:IndexedDB 里 Issue 这些数据表要等服务端确认后才写,先落盘的只有事务队列。所以回滚只需要撤内存,不用清本地库。
如果客户端落后太多,比如 lastSyncId 丢了、本地 model 过期,就不追增量了,直接全量重新拉一次。
冲突:服务端最后处理的那次写入赢。
服务端每处理一个事务,就发一个递增的 syncId。同一个字段,谁的 syncId 大谁就是最终值。算的是谁后到服务端,不是谁先点,网络慢的人先点反而可能赢。
拿改负责人举例,我改成 Bob,同事改成 Alice。
同事先到服务端:
服务端: 100 = Alice(同事) → 101 = Bob(我)
我的屏幕:Bob → 收到 100,rebase,仍显示 Bob → 收到 101 确认 → Bob
Alice 那条增量到的时候,我的事务还在队列里。Linear 会对队列里的事务做 rebase:把事务里记的"原值"换成 Alice,屏幕继续显示我的 Bob。这样界面不会闪,万一我的事务被拒,回滚也是回到 Alice。
我先到服务端:
服务端: 100 = Bob(我) → 101 = Alice(同事)
我的屏幕:Bob → 收到 100,事务完成 → 收到 101 → Alice
这次我输了,负责人从 Bob 变成 Alice,没有任何提示,只能在 issue 历史里看到。
什么情况算冲突?
冲突按字段算,不按整条 issue 算。更新事务只带改过的字段,改负责人的请求体里就只有一个 assigneeId,所以:
- 不冲突:同一个 issue 的不同字段。 我改负责人、同事改状态,服务端各改各的,两边都保留。
- 不冲突:issue 描述。 描述用的是 ProseMirror + Yjs(CRDT),两个人改同一段也是按字符合并,写的都留着。
- 冲突:同一个单值字段。 负责人、状态、优先级、标题这类,后到服务端的覆盖先到的,就是上面 Bob 和 Alice 的情况。
- 说不准:标签这类数组字段。 issue 的标签存成一个
labelIds数组,算同一个字段。如果事务发的是整个数组,我加标签 A、同事加标签 B,后到的会把先到的覆盖掉;如果发的是"加一个 A"这种增量,两个都会留下。逆向分析没写标签改动是怎么序列化的,我也没验证过。
另外,重放不幂等的事务可能报错,比如去删一个已经不存在的对象。
交互和动画也在省时间
引擎再快,如果点三层菜单才能改个状态,用户还是觉得慢。Linear 把键盘当主输入:单个字母改当前 issue,两个字母组合做导航,⌘K 打开命令面板。命令面板搜的也是本地 MobX 对象池,不走网络。快捷键在界面上到处都标着,新手也能用鼠标完成所有操作。
动画上 Linear 很克制,基本只动 transform 和 opacity,偶尔动 background-color、border-color,列表项完全不加过渡。这和我之前在 CSS 动画入门 里记的原则一致:只触发合成的属性最便宜,width、margin 这类会触发布局的别动。
时长全部收在 CSS 变量里:
--speed-highlightFadeIn: 0s;
--speed-highlightFadeOut: .15s;
--speed-quickTransition: .1s;
--speed-regularTransition: .25s;
--speed-slowTransition: .35s;
注意第一个是 0s:高亮、弹层出现时没有动画,消失时才淡出 150ms。出现是用户在等的,所以要快;消失是用户已经不关心的,慢一点没关系。
不自己写同步引擎,能先抄哪几招
自己做一套同步引擎成本很高,大部分团队不会做。但原文里有几招是现在就能用的:
-
乐观更新:SWR 和 TanStack Query 都支持,先改缓存再发请求,失败了回滚。
js// SWR:先改本地缓存,第三个参数 false 表示先别重新请求 mutate(`/api/issues/${id}`, { ...issue, title }, false) await fetch(`/api/issues/${id}`, { method: 'PATCH', body: JSON.stringify({ title }) }) mutate(`/api/issues/${id}`)TanStack Query 写法多几行,但回滚是显式的:
onMutate里先存一份快照再改缓存,onError用快照恢复,onSettled不管成败都重新拉一次对齐服务端。jsconst queryKey = ['issue', id] useMutation({ mutationFn: (title) => patchIssue(id, { title }), onMutate: async (title) => { await queryClient.cancelQueries({ queryKey }) // 防止进行中的请求把乐观值盖掉 const previous = queryClient.getQueryData(queryKey) queryClient.setQueryData(queryKey, (old) => ({ ...old, title })) return { previous } }, onError: (_err, _title, ctx) => queryClient.setQueryData(queryKey, ctx.previous), onSettled: () => queryClient.invalidateQueries({ queryKey }), }) -
关键 chunk 加
modulepreload,别让浏览器一层层发现依赖。 -
启动画面需要的状态放
localStorage,内联脚本先画壳,比如主题色、侧边栏宽度,避免闪一下。 -
别让全局初始化挡住所有页面:用户信息、实验开关这类请求放到真正需要的页面里等,别在根布局里一起等。
原文最后提了一点:Linear 没用 Next.js,也没用 TanStack,就是客户端渲染 + 自己的同步引擎,心智模型反而简单。
常见问题
Linear 用的是 CRDT 吗? issue 的状态、负责人这类字段不是,冲突按服务端最后处理的写入为准,过程见上面「同步失败和冲突怎么处理」。多人同时编辑的 issue 描述用的是 ProseMirror + Yjs,这部分才是 CRDT。
Linear 离线能用吗? 能。离线时可以读、建、改 issue,改动排在本地事务队列里,联网后再发出去。
workspace 很大的时候也这么快吗? 不一定。Linear 在 2021 年的 Improving performance at scale 里承认过,让大部分用户觉得快的那些设计,也是部分大 workspace 变慢的原因。当时客户端启动要把整个 workspace 的数据拉下来存进 IndexedDB,再在内存里建好对象图才渲染,数据一多就慢。上面说的 Issue/Comment 懒加载就是后来为这个做的。
相关链接
原文:
- How's Linear so fast? A technical breakdown,Dennis Brotzky,2026-05-03
Linear 官方:
- Scaling the Linear Sync Engine:Tuomas Artman 的演讲,讲同步引擎扩展时遇到的问题和现在的工作方式
- Improving performance at scale:2021 年大 workspace 变慢的复盘
- localfirst.fm #15:Tuomas Artman:播客,聊 Linear 为什么一开始就做同步引擎
同步引擎实现细节:
- wzhudev/reverse-linear-sync-engine:社区对 Linear 同步引擎的逆向分析,讲事务、增量包、ModelRegistry 和 IndexedDB 存储,Linear CTO 本人认可过
- I reversed Linear's sync engine to see how it works:同一作者的文章版
中文资料:
- 点击的同时就完成的应用,秘密何在:从 local-first 讲 Linear 的速度
延伸:
- SWR Mutation、TanStack Query Optimistic Updates:不写同步引擎时的乐观更新
- animations.dev:Linear 设计工程师 Emil Kowalski 的动画课