在 Linear 裡按下 Enter 改一個 issue 標題,UI 在你手指離開鍵盤之前就已經更新完畢。網路請求這時候還沒送出去。
How is Linear so fast——把資料庫搬進瀏覽器後,每一層都得跟著改
速度這件事很容易被歸功給某個單一招式。有人說 Linear 快是因為它用了 WebSocket,有人說是因為前端框架選得好,有人說是因為後端 PostgreSQL 調得兇。這些猜測都有一個共同的隱含假設:存在一個瓶頸,找到它、修掉它,就快了。但把 Linear 的前端拆開來看,會發現一個更不舒服的結論——它快,不是因為某一層特別快,而是因為從瀏覽器儲存層到 CSS transition 的每一層都被重新設計過,而且這些設計彼此咬合,抽掉任何一塊,速度都會塌下來。
這篇拆解把「Linear 為什麼這麼快」當成一個調查題。我們先把那個違反直覺的觀察講清楚——一個寫操作的回饋時間竟然和網路延遲無關;然後逐一檢驗三個看起來合理、實際上都不完整的假設;最後收斂到真正的答案。每一個假設都對了一部分,但都不是全貌。真正的全貌,是一個 local-first 系統的形狀。
謎題:寫操作的回饋時間和網路延遲脫鉤了
先把違反直覺的地方說精準。在一個傳統的 web app 裡,你改一筆資料的流程是:UI 觸發事件,送一個 request 到伺服器,伺服器寫資料庫、回 response,前端收到 response 才更新畫面。這條鏈路上每一個環節都要等,而其中最不可控、最慢的一段就是網路往返。一個跨洲的使用者,RTT 動輒 200ms 以上,意思是他每改一個欄位、每拖一次卡片,都要先盯著一個還沒變的畫面等五分之一秒。
Linear 不是這樣。它的寫操作回饋時間,無論你在東京還是聖保羅,無論網路是 4G 還是光纖,都是同一個數字——接近零。網路延遲對 UI 回饋的影響,被徹底切斷了。這就是謎題:一個需要把資料寫進伺服器資料庫的操作,怎麼可能讓 UI 回饋和網路延遲無關?資料明明還在路上。
下面這個模擬把兩條路徑並排放在一起。上面那條是 Linear 的 optimistic 寫入路徑,下面那條是傳統的 server round-trip。拖動 RTT 滑桿改變網路延遲,按 play 送出一個 mutation,看兩條路徑各自在什麼時候讓 UI 變綠(代表使用者看到結果)。
drag RTT slider, press play to dispatch a mutation · two parallel write paths
同一個 mutation,兩條路徑
Linear 的寫入先更新本地 MobX observable,UI 回饋與 RTT 完全無關,伺服器確認只是驗證不是許可。
模擬講清楚了一件事:在 Linear 路徑上,伺服器確認那一刻(confirm 標記)落在 RTT 之後,但它對 UI 回饋完全沒有影響——UI 早在 0ms 就轉綠了。伺服器確認的角色從「許可」變成了「驗證」。傳統路徑上,UI 變綠的那一點直接黏在 RTT 上,你把滑桿拉到 400ms,那個綠點就跟著跑到 400ms。這就是脫鉤的證據。但證據只說明了現象,沒解釋機制。下面三個假設,是工程師看到這個現象時最常提出的解釋。
假設一:是 WebSocket 讓它變快
最直覺的猜測是傳輸層。Linear 確實用 WebSocket 推送伺服器的 deltas,而 WebSocket 比起每次都重開 HTTP 連線,省掉了 TCP 與 TLS 握手的開銷,連線保持常開,訊息也更小。所以——是不是因為換成了一個更快的傳輸通道,往返才快到讓人感覺不到?
這個假設站不住腳,理由很簡單:WebSocket 再快,也快不過光速。聖保羅到法蘭克福的物理 RTT 就是擺在那裡的一百多毫秒,任何傳輸層協定都繞不開。如果 UI 回饋真的依賴一次往返,那無論用 HTTP/2、HTTP/3 還是 WebSocket,跨洲使用者都會感覺到延遲。模擬裡傳統路徑的綠點黏在 RTT 上,正是這個道理——換傳輸協定只是把那條鏈路稍微縮短,並沒有把 UI 從鏈路上拿下來。
真正的關鍵字不是「更快的往返」,而是「不往返」。Linear 的 UI 根本不從伺服器讀資料。它讀的是瀏覽器裡的 IndexedDB——那才是 UI 眼中真正的資料庫。開機時,整個 workspace 從 IndexedDB hydrate 進一個放在記憶體裡的 MobX object pool,之後 UI 的每一次讀取都打在這個本地 object pool 上,連 ⌘K command palette 的搜尋都是查本地 pool,不是查伺服器。WebSocket 在這個架構裡的角色,是把伺服器的 deltas 推回來同步本地副本,而不是 UI 讀取的來源。假設一對了一半——WebSocket 確實在用——但它解釋錯了因果:它是同步用的,不是讀取用的。
那寫入呢?這就是 optimistic mutation。一行 issue.title = "..."; issue.save() 先更新本地的 MobX observable,UI 立刻得到回饋,save() 只是把這筆變更非同步排進一個 transaction store,背景慢慢推給伺服器。伺服器回來的確認是驗證而非許可——它驗證這筆寫入合法,而不是 UI 在等它放行。模擬裡 Linear 路徑那條 sage 色的「instant feedback」帶子,就是這段本地優先回饋。
假設二:granular observable 只是省了重繪,不是速度的核心
第二個假設方向相反:承認 local-first 是對的,但認為「先更新本地再非同步推送」這個寫入模型才是全部,至於 MobX 的細粒度 observable,只是個錦上添花的渲染優化,跟「快不快」的本質無關。
這個假設低估了批次更新的破壞力。考慮一個常見場景:伺服器推回一個 delta,一次更新了 50 筆 issue 的狀態。如果用的是粗粒度的反應式模型——比方說一個 list component 訂閱了整個 issues 集合——那這 50 筆更新會觸發整列重繪,list 裡每一個 cell 都要重新 render 一次,哪怕它的資料根本沒變。在一個有上萬筆 issue 的 workspace 裡,這種串接重繪會讓「同步」這件背景工作直接卡死主執行緒,使用者會看到捲動掉幀、輸入延遲。本地優先的寫入模型救不了這種卡頓,因為卡頓發生在讀取側的渲染。
MobX 的細粒度 observable 正是用來拆掉這個串接的。每一個 observable 是 per-property 的,元件用 observer() 包起來之後,只會在它實際依賴的那幾個欄位變動時才重繪。於是一個 50 筆的批次更新,觸發的是 50 個 cell 各自重繪,而不是整列從頭 render——重繪成本和「實際變了多少資料」成正比,而不是和「列表有多長」成正比。下面這個 widget 把這個對比放出來:拖動滑桿改變批次大小 N,看細粒度模型和粗粒度模型各自要付多少次 cell 重繪。
drag batch size · compare granular observer() vs coarse subscription repaints
細粒度模型的重繪成本是 N,隨批次線性成長;粗粒度模型固定付出整列 10,000 次重繪,與批次大小無關
observer() 細粒度重繪隨批次大小線性成長,粗粒度訂閱固定付出整列一萬次重繪,N=1 時差距最大。
所以假設二也對了一部分卻錯了重點:細粒度 observable 不是錦上添花,它是讓「同步」這件背景工作不會反過來癱瘓 UI 的前提。少了它,本地優先的寫入照樣成立,但每一次伺服器 delta 推回來都會掀起一場整列重繪風暴,使用者依然會卡。local-first 的讀寫模型和細粒度反應式渲染,是互相支撐的兩根柱子,不是主與副。
假設三:bundle 那麼大,啟動一定慢,跟「感覺快」是兩回事
第三個假設把焦點移到啟動。Linear 的前端 minified JavaScript 大約 21MB。直覺上,21MB 的 JS 應該意味著漫長的下載、解析、執行,第一次打開應該慢得令人髮指。如果啟動這麼慢,那「感覺快」頂多是進到 app 之後的事,跟首次載入無關——這是假設三的主張。
實測卻不是這樣。Linear 一路換過四個打包器:Parcel → Rollup → Vite → Rolldown,每換一次都在啟動成本上往下壓。最近一次遷移的成果是:送到瀏覽器的程式碼少了 50%,壓縮後又再小 30%,Safari 上 active-issues 視圖的 Time-to-First-Paint 快了 59%。21MB 這個總量是 minified 後、切成數百個 route-level chunk 的總和,任何一次載入都只會抓到其中和當前路由相關的一小撮。
scroll into view to reveal the four-bundler progression · real migration numbers
條長是相對 TTFP,越短越快(示意比例)
從 Parcel 換到 Rolldown,程式碼量少一半、壓縮後再縮 30%,Safari 首次繪製時間加快 59%。
但條長變短只是結果,機制藏在好幾個刻意的取捨裡。第一,Linear 只 target 現代瀏覽器——打包設定大致是 target: 'esnext',完全不做 ES5 transpilation、不塞 polyfill。光是這一刀就砍掉了大量為了相容老瀏覽器而存在的程式碼與 runtime helper。第二,aggressive dead-code elimination:用不到的分支在打包時就被剃掉。第三,route-level chunk 把程式碼按路由切開,登入後第一個畫面只需要它自己那塊。
然後是最反直覺的一招。21MB 切成數百個 chunk,照理說會引發一場 import waterfall——entry 解析後才知道要 A,抓 A、解析 A 才知道要 B,一層等一層,往返堆疊起來比一個大檔案還慢。Linear 的解法是在 index.html 裡塞進數百個 <link rel="modulepreload">,把所有關鍵 chunk 的位址預先告訴瀏覽器,讓它在解析 entry script 之前就平行抓取全部。那條原本要一層層往下走的 waterfall,被壓成了一次平行的批次抓取。
更狠的是 service worker。它在背景把大約 1,200 個 hashed 資產——route chunk、icon、字型——預先 precache 下來,這個填充是在首次登入後 lazy 進行的。一旦填好,之後的導覽完全跳過網路,從本地 cache 取檔,這也是為什麼 Linear 用久了會越來越像一個裝在瀏覽器裡的原生 app。所以假設三錯在哪裡?它把「bundle 大」直接等同於「啟動慢」,卻沒看到 modern-only target、route chunk、modulepreload、service worker precache 這一整套機制,把 21MB 這個嚇人的總量拆解成了一次只付一小部分、且大多時候根本不付的成本。
還沒進 JS 之前——auth、app shell 與動畫紀律的偷跑
前面三個假設都在談 app 跑起來之後的事。但 Linear 最精巧的一段提速,發生在任何 JavaScript bundle 載入之前。傳統 app 在 render 之前要先驗證 session:打一個 API 確認你登入了,才知道要畫已登入還是未登入的版面。這一個前置往返,又把首屏黏回了網路延遲上。
Linear 不在 render 前驗 session。它的 inline boot script 直接檢查 localStorage.ApplicationStore 在不在——在,代表你來過,workspace 資料就躺在 IndexedDB 裡,那就樂觀地畫已登入的 app shell;不在,才畫登出版面。真正的 session token 放在 HttpOnly cookie 裡,驗證延後到第一個 async 請求(WebSocket 握手或 API call)才發生,萬一那個請求收到 401,再 fallback 到登入頁。也就是說,它賭「你還登入著」這件大概率為真的事,先把畫面畫出來,被打臉了再退回去——這跟 optimistic mutation 是同一個賭法,只是賭的對象從一筆寫入換成了整個 session。
app shell 本身也是預先備好的。<head> 裡 inline 了 critical CSS,所以畫 loading shell 不需要再去抓一支外部樣式表;另一段 inline boot script 讀 localStorage 裡存的偏好(sidebar 寬度、深淺色、主題色),在任何 bundle 載入前就套到 document.documentElement 上。於是當你在網址列按下 Enter,瀏覽器幾乎是立刻畫出一個版面正確、主題正確的 app 骨架,給人一種「它早就準備好了」的錯覺——而這個錯覺,技術上是真的:它確實早就準備好了。
還有更細的一層,細到連 CSS transition 都不放過。一個 app 即使資料即時、啟動極快,只要動畫一掉幀,整體的「快」就會被破壞。Linear 的動畫紀律是:只動 GPU 能合成的屬性。transform 和 opacity 走的是 compositor,不觸發 layout、也不觸發 paint 階段的重排;非 layout 的 paint 屬性如 background-color、border-color 可以動。而會觸發 layout 的屬性——width、height、top、left、margin、padding——絕對不 animate,因為動它們會逼瀏覽器在每一幀重算版面,這是掉幀的頭號來源。
時間上也很克制。transition 的速度被收進幾個 CSS 變數,從 --speed-quickTransition: .1s 到 --speed-slowTransition: .35s,多數預設落在 150ms 以下。而且 entry 與 exit 不對稱:元素在使用者操作的當下幾乎瞬間出現(讓回饋感覺即時),消失時才花約 150ms 淡出(讓畫面不要突兀)。這種不對稱本身就是一種對「感知速度」的精算——出現要快,因為那是回饋;離開可以慢,因為那只是收尾。
輸入這一端也遵守同一條紀律:能在本地解決的,絕不去問伺服器。Linear 幾乎每個常用操作都有鍵盤捷徑——單一字元就能改當前 issue 的狀態、兩個字母的組合用來導航——讓熟手的手指不必離開鍵盤,也不必等任何一次往返。最能說明問題的是 ⌘K 喚出的 command palette:它搜尋的是記憶體裡那份 MobX object pool,而不是打一支搜尋 API 到後端。於是你每敲一個字、結果就即時收斂,沒有 debounce、沒有 loading spinner、沒有「正在搜尋」的空窗。把搜尋這種典型「一定要問伺服器」的操作也搬回本地,是 local-first 架構額外白賺的紅利——資料既然已經在瀏覽器裡,為什麼還要繞一圈網路去找它?這也是為什麼 ⌘K 在 Linear 裡感覺不像一個「功能」,而像作業系統本來就該有的反射。
收斂:沒有銀彈,是整個系統一起長出來的
回到最初的謎題。寫操作的回饋時間為什麼和網路延遲無關?現在答案完整了,而且它不是任何單一假設的形狀。WebSocket(假設一)是對的,但它是同步通道,不是讀取來源;真正讓讀取脫離網路的是 IndexedDB 加記憶體裡的 MobX object pool,讓寫入脫離網路的是 optimistic mutation。細粒度 observable(假設二)是對的,但它不是渲染上的小優化,而是讓背景同步不會反咬 UI 的承重柱。bundle 工程(假設三)是對的,但「21MB 所以慢」的推論錯了,modulepreload 與 service worker precache 把總量拆成了一次只付一點的成本。再加上 render 前不驗 session 的 app shell 偷跑,和只動 GPU 屬性的動畫紀律——這六層疊起來,才是那個接近零的回饋時間。
值得強調的是這些層的咬合關係。optimistic mutation 之所以敢先畫再推,是因為 IndexedDB 裡有一份可信的本地資料庫可以馬上更新;細粒度 observable 之所以必要,是因為伺服器 deltas 會帶來批次更新,沒有它本地優先反而會卡;app shell 之所以敢樂觀畫已登入版面,是因為 IndexedDB 裡確實有 workspace 在。抽掉本地資料庫,optimistic write 和 app shell 偷跑都失去地基;抽掉細粒度 observable,同步會癱瘓 UI;抽掉 modulepreload,21MB 會變回 waterfall。沒有哪一層能單獨成立,這正是它難以被抄走的原因——不是某個聰明的 trick,而是一個從儲存層長到動畫層、彼此依賴的整體。
the six interlocking layers, storage at the bottom · remove any and speed degrades
最底層是 IndexedDB 加記憶體 MobX object pool——其上每一層都依賴它
六層彼此依賴:本地資料庫是地基,optimistic write 與 app shell 偷跑都建立在它之上,抽掉任何一層速度都退化。
把鏡頭拉到整個 stack,這個「彼此依賴」的氣質就更明顯了。前端是 React + TypeScript + MobX,協作編輯用 ProseMirror 加 y-prosemirror 的 CRDT,UI primitive 用 Radix,原子化 CSS 用 StyleX,IndexedDB 包了一層 idb,Worker RPC 用 Comlink,打包是 Rolldown 接在 Vite 上。後端是 Node + TypeScript,PostgreSQL 跑在 Cloud SQL 上、issues 表 partition 成 300 份,Redis 當 event bus 與 sync cursor,Kubernetes 跑在 GCP 上、一個 workload 管一件事,邊緣是多區域的 Cloudflare Workers proxy。這份清單裡沒有任何一個「快取神器」或「魔法資料庫」——每一個都是普通的選擇,快的是它們被組裝起來的方式。
對要在下週寫程式的人來說,這篇拆解的可遷移結論不是「去用 MobX」或「去換 Rolldown」。是一個更基本的判斷:如果你的 UI 回饋還黏在網路往返上,那再怎麼優化傳輸層、再怎麼縮短 RTT,都是在一條注定要等的鏈路上省幾毫秒。真正的位移是把 UI 從那條鏈路上拿下來——讓它讀本地、樂觀地寫、把伺服器降級成驗證者。而一旦你這麼做,細粒度渲染、啟動工程、app shell、動畫紀律就不再是可選的修飾,它們會變成讓這個架構不退化的必要條件。這就是為什麼速度沒有銀彈。
Take-away:下次看到一個「快得不像 web app」的產品,先別問它用了什麼更快的傳輸或框架——問它的 UI 到底還讀不讀伺服器。把資料庫搬進瀏覽器之後,速度不是某一層的功勞,而是每一層被迫一起重寫的結果。