洗碗機螢幕顯示「清洗中」,計時器在倒數,但水泵根本沒啟動。等了兩小時,碗還是油的。
影片預覽頁面上,某個片段標記著「重新生成中」,loading spinner 轉個不停。使用者盯著螢幕,以為系統正在努力工作。實際上後端完全靜止,沒有任何任務被觸發,沒有 API 被呼叫,log 檔案裡什麼都沒有。
UI state 和實際 state 的分裂
追查下去才發現,前端的邏輯是比對「預期應有的片段數量」和「資料庫中實際存在的片段」。只要發現缺漏的 index,就自動把那格標記成 regenerating 狀態。問題是這個標記是純前端的 UI state,設計上沒有附帶「自動向後端發送重新生成請求」的行為。
換句話說,前端自己判斷「這個片段應該要重新生成」,然後自己顯示 loading spinner,但沒有通知後端。UI 顯示系統很忙,後端完全不知道有這件事。
更糟的是,前端本身也沒有提供重新觸發的按鈕或入口。使用者唯一的操作就是繼續盯著那個圈圈轉。沒有 retry 按鈕,沒有 cancel 按鈕,什麼都沒有。最後只能繞過前端,直接手動呼叫後端 API 才解除卡住。
UI 不該自作主張
這個 bug 的根源在於前端越權了。UI 的職責是呈現 state,不是推測 state。當資料庫裡缺少某個片段時,前端應該顯示「缺少」或「錯誤」,而不是自己腦補「應該正在重新生成」。
如果真的要顯示 regenerating 狀態,那這個狀態應該來自後端。後端告訴前端「這個片段正在處理中」,前端才顯示 loading spinner。不是前端自己看到資料不完整,就假設後端正在處理。
更致命的是,這種設計讓前後端的 state 徹底失去同步。前端認為系統在工作,後端根本不知道。使用者看到的畫面和系統實際狀態完全脫節。Debug 的時候你會懷疑自己眼睛,明明 spinner 在轉,為什麼 log 檔案裡什麼都沒有?
修法很簡單,但設計要重來
技術上的修法很簡單。把 regenerating 的判斷邏輯搬到後端,讓前端單純讀取後端回傳的狀態。或者在前端偵測到缺漏時,直接發送一個 API 請求,真的觸發重新生成。
但這個 bug 暴露的是設計問題。當初設計這個功能時,沒有把「缺漏片段」和「重新生成中」當成兩個不同的狀態。前端自己把兩者混為一談,用同一個 UI 呈現。結果就是使用者看到的畫面和系統實際行為脫節。
loading spinner 是給使用者的承諾:系統正在處理你的請求,請稍等。當這個承諾是假的,使用者只能乾等。
— 邱柏宇
延伸閱讀
The spinner that spun for nothing
The dishwasher display shows “Washing”, the timer counts down, but the water pump never started. Two hours later, the dishes are still greasy.
On the video preview page, a segment shows “Regenerating” with a loading spinner. The user stares at the screen, assuming the system is hard at work. In reality, the backend is completely idle. No tasks triggered, no API calls made, nothing in the logs.
When UI state diverges from reality
Digging deeper revealed the issue. The frontend compares “expected segment count” with “actual segments in database”. When it detects a missing index, it automatically marks that slot as regenerating. The problem: this marker is pure frontend UI state, with no logic to actually send a regeneration request to the backend.
The frontend judges “this segment needs regeneration” and displays a loading spinner, but never notifies the backend. The UI shows a busy system while the backend has no idea anything is wrong.
Worse, the frontend provides no manual trigger button. No retry, no cancel, nothing. The only option is to stare at the spinning circle. Eventually we had to bypass the frontend entirely and manually call the backend API to unblock the situation.
UI shouldn’t make assumptions
The root cause: the frontend overstepped its role. UI should present state, not infer it. When a segment is missing from the database, the frontend should display “missing” or “error”, not assume “probably regenerating”.
If you want to show a regenerating state, that state must come from the backend. The backend tells the frontend “this segment is being processed”, then the frontend shows the spinner. Not the other way around—frontend detecting incomplete data and assuming the backend is working.
This design completely desynchronizes frontend and backend state. Frontend thinks the system is working, backend is oblivious. What users see bears no relation to actual system behavior. When debugging, you question your sanity: the spinner is clearly spinning, why is the log file empty?
The fix is easy, the design isn’t
The technical fix is straightforward. Move the regenerating logic to the backend, let the frontend simply read returned state. Or when the frontend detects a gap, send an API request to actually trigger regeneration.
But this bug exposes a design flaw. When originally designing this feature, no one treated “missing segment” and “currently regenerating” as two distinct states. The frontend conflated them, using the same UI for both. The result: what users see diverges from what the system does.
A loading spinner is a promise to users: the system is handling your request, please wait. When that promise is false, users can only wait in vain.
— 邱柏宇