This article analyzes innei.in/posts/tech/lobehub-performance-dx-optimization. The original uses LobeHub, a ChatGPT/LLM client project, as an example to describe a series of optimizations across frontend components, the Electron main process, the Next.js developer experience, and more. Rather than repeat the original narrative, this article distills its scattered individual changes into an engineering framework that can be applied elsewhere: four cost categories × shifting where the cost occurs.
Related notes
- [[笔记/web/Java/JVM 原理、性能调优与故障排查]]: JVM tuning follows a similar idea to this article: both are about “spreading out small, high-frequency costs.”
1. Start with the intuition: small infrastructure costs scale with the number of components
The common premise behind LobeHub's series of optimizations is: a 0.1 ms operation isn't expensive on its own, but multiply that by the number of components and it becomes a different story.
The article gives a rough multiplication:
0.1 ms × 500 个组件 = 50 ms
A smooth frame has a 16.7 ms budget. Once a tiny operation in the infrastructure becomes something that “runs during every component render,” multiplying it across a hundred or two hundred components can turn it from “negligible” into “noticeably janky.”
This intuition is important. It explains why LobeHub optimized things that seem completely insignificant to an ordinary reader:
- the execution of a
<Flexbox>component function itself; - a
useStyles()Hook call; - the initialization of a Tooltip's floating layer;
- the Hooks attached to a normally invisible Modal in the React tree.
All of these are “small infrastructure-level operations.” Optimizing any one of them won't bring a page from 1,000 ms down to 10 ms, but together they can make an app that mounts hundreds of components just to open its home page feel noticeably smoother.
Every concrete change that follows can be understood as moving that 0.1 ms operation from “happening every time” to “happening only once”, or moving it to “happening only when it's actually needed.”
3. Replace react-layout-kit: Move the CSS-in-JS runtime work to module load time
The Flexbox component in react-layout-kit may look like nothing more than a layout component, but under the antd-style / Emotion system, it still has to do the following internally:
Five different prop combinations:
Each can generate different CSS:
Even on a cache hit, the code still has to execute the component function, construct the style arguments, allocate an object, serialize it, compute the hash, and query the cache. Development builds also have to store debugging information and source maps, making memory pressure more noticeable.
New approach: all instances share one set of CSS rules
LobeHub built its own internal Flex, keeping the original API but switching the implementation to fixed classes:
The component only maps props to CSS variables:
The result is:
Costs eliminated:
- no longer generating a new hashed class name for every gap value;
- no longer continuously inserting rules into
<style>; - no longer needing Emotion cache lookups.
New costs:
- constructing a small
cssVarsobject on every render; - building the
classNamestring.
The latter is clearly cheaper than the former. The article puts the per-component overhead at roughly 0.1 ms; the real problem is hundreds of components × 0.1 ms.
Why CSS variable defaults have to be set on every Flex
CSS custom properties inherit by default. For example:
The child does not explicitly define --lobe-flex-gap, so it may inherit the parent's 16px, adding unwanted spacing to the child layout.
The internal CSS therefore resets the defaults on every .lobe-flex:
This layer of defaults is a necessary cost. When you encounter code that “uses CSS variables to drive component styles,” you can check directly whether every level resets the defaults.
5. Keeping the Home page resident: React Activity and “cache after first visit”
LobeHub's Home page (DesktopHomeLayout) contains a lot:
- a sidebar;
- recent history;
- an input area;
- a skills list;
- multiple modal entry points;
- lots of store selectors;
- data-restoration logic.
The old routing structure was roughly:
Going to Settings and then back to Home required the full unmount-and-remount cycle:
The article measured a return to DesktopHomeLayout at close to 500 ms.
The fix: move the Home page out of the route branch
The refactored structure is roughly:
The index route no longer creates the Home component separately.
The Home page now checks the path internally:
When mode="hidden", Activity behaves as follows:
display: nonehides the content;- the component's React State is preserved;
- the corresponding DOM is preserved;
- the component's Effects and subscriptions are cleaned up;
- while hidden, it may still respond to new props at low priority;
- when shown again, it restores the previous state and recreates the Effects.
You can think of it this way:
Fiber、State、DOM:保留
Effect、订阅:清理
视觉:display: none
When returning, the main work becomes:
hidden → visible
↓
恢复 Effect
↓
浏览器重新展示已有 DOM
The entire DOM tree is no longer rebuilt.
hasActivated: cache after the first visit, not pre-rendering at startup
The actual code adds:
The logic is:
用户从未访问首页 → 不创建首页
用户第一次访问首页 → 创建首页
用户以后离开首页 → 隐藏但保留
This is “cache after visiting,” not “unconditionally pre-render at app startup.” Otherwise, a user who goes straight to /settings would make the app mount the heavy Home page in the background anyway, increasing startup time and memory usage.
Absolute positioning and its cost
The resident Home container uses:
Because the Home page is no longer part of the normal route output, absolute positioning keeps it out of other pages' document flow. When visible, it covers the main content; when hidden, Activity makes it invisible.
This optimization comes at the cost of permanent residency:
- Fiber memory;
- DOM memory;
- component State;
- cached data;
- browser layout objects.
Best suited to:
- a home page users return to frequently;
- editors;
- chat sessions;
- pages that need to preserve input state.
Not suited to:
- pages users rarely return to;
- reports with particularly large DOMs;
- pages containing lots of
<video>/<audio>/<iframe>elements.
After <video>, <audio>, and <iframe> elements are hidden, their DOM still exists and they may continue their own behavior, so you need to explicitly pause or release them in the Effect cleanup function.
7. Making Tooltip a singleton: sharing one floating layer
Every instance of the old Tooltip could have all of the following:
useFloatingpositioning logic;- mouse enter/leave timers;
- Focus and Hover interaction Hooks;
- a Portal;
- Arrow positioning;
- Open State;
- Motion animations;
ResizeObserver;- Scroll listeners;
- automatic position-update logic.
If a page contains 200 icons, each wrapped in a Tooltip:
Even when none of the tooltips is open, every instance still has to create its trigger and Hooks. The article's TooltipFloating is nearly 200 lines and includes Floating UI, debouncing, AnimatePresence, a dynamic transformOrigin, content-switching animations, arrow logic, a Portal, and layout animations.
Singleton Tooltip structure
Each Tooltip registers only:
触发节点
title
placement
回调
The entire Group shares one actual floating layer:
一个 Root
一个 Portal
一个 Floating Content
一个 Arrow
一套定位状态
Once the pointer enters a trigger, it updates only the current payload:
The floating layer is repositioned to the new anchor, instead of every button creating a complete floating-layer component tree of its own.
The actual implementation is based on Base UI's <BaseTooltip.Root handle={handle}>, with the Group Context sharing the handle. A regular Tooltip detects that it is inside a Group and switches to shared mode; controlled Tooltips and Tooltips with defaultOpen or an explicit standalone keep their independent implementations.
Why Base UI is easier to optimize than a full-featured UI library
Base UI is a headless component library that mainly provides:
- accessibility;
- interaction state;
- positioning;
- keyboard behavior;
- ARIA;
- a basic Portal.
The project controls its own styles and animations. Compared with a full component library, this means carrying less:
- a compatibility layer;
- theme wrappers;
- multiple layers of Context;
- default DOM;
- default animations;
- style-injection logic.
The more accurate statement is not “Ant Design is slow, Base UI is fast,” but “LobeHub can build a smaller component tree and a shared floating layer on Base UI.” Whether switching your base library is worthwhile depends on how many similar scenarios your own project has.
9. Electron bundle size and the three requirements for native modules
The Electron main process might originally have depended directly on:
If the build tool externalizes every dependency, the output only retains require('some-package'), and the runtime has to ship the entire node_modules/some-package. The whole node_modules also contains:
- unused files;
- READMEs;
- type declarations;
- Source Maps;
- multiple module formats;
- test code;
- optional assets;
- duplicate dependencies.
Bundle ordinary JavaScript dependencies instead
After the optimization, electron-vite no longer externalizes every dependency. Ordinary JavaScript packages are bundled by Rollup into:
dist/main/index.js
dist/preload/index.js
Rollup also:
- performs tree shaking;
- deduplicates code;
- minifies code;
- inlines dependencies;
- removes unreferenced code.
The electron-builder configuration is:
This excludes node_modules as a whole and adds back only the native dependencies that must be preserved.
Native modules can't be bundled with the rest
For example:
better-sqlite3
sharp
node-mac-permissions
These packages may contain .node files, which are native dynamic libraries, not JavaScript. Node.js needs a real file path to load one:
So a native module has to satisfy three requirements:
Rollup does not bundle it
The generated output still contains require('node-mac-permissions').
electron-builder places it in the packaged application
Unpack it from ASAR
The final files land in app.asar.unpacked/node_modules/....
The project also recursively reads each native module's package.json and adds its transitive dependencies as well. Otherwise, the native module itself may be present while startup still fails because a child dependency is missing.
All three lists must agree:
external 列表 = builder include 列表 = asarUnpack 列表
Miss any one of them and you may get a “successful build that fails at runtime.” This is the Electron packaging pitfall newcomers are most likely to run into.
Removing Electron language resources
The Electron Framework ships with Chromium's localized resources:
en.lproj
zh_CN.lproj
ja.lproj
fr.lproj
These are Chromium's native UI resources, not the app's own i18n JSON.
After the build, the afterPack hook traverses:
Electron Framework.framework/
Versions/A/Resources/*.lproj
and keeps only the English-related directories:
You can't delete them all, because the Electron Framework still needs some basic localization resources.
11. IPC type safety: from string channels to a controller registry
The old approach:
Spelling inconsistencies would go unnoticed by TypeScript, and channel names, parameters, return values, main-process implementations, and renderer-process calls were all independent. When refactoring a main-process method, callers could still compile successfully and fail only at runtime.
Controller + decorators
The main process becomes:
The channel name is inferred automatically as windows.minimizeWindow. Controllers are registered centrally in controllerIpcConstructors, which is used both to install IPC handlers at runtime and to extract service types for TypeScript.
The renderer process uses a Proxy
ensureElectronIpc() can use a JavaScript Proxy to collect property accesses:
The TypeScript types come from the controller registry, allowing it to infer:
- available Controllers;
- available methods;
- parameters;
- return values.
But TS types exist only at compile time. IPC crosses a process boundary, and the renderer process may be attacked, so the main process must still validate parameters at runtime; you can't rely on TS alone.
13. Next.js to Vite: splitting frontend and backend build responsibilities
LobeHub's approach isn't simply to “delete Next.js”; it splits the responsibilities:
When working on the UI alone, you start only Vite—not the full Next.js App Router, RSC, server-side bundling, or route-analysis pipeline.
Why large Next.js projects use so much memory in development
During development, an App Router project may maintain all of the following at once:
- the client module graph;
- the server module graph;
- the RSC module graph;
- the route tree;
- Server Actions;
- Route Handlers;
- middleware;
- hot-reload state;
- Source Maps;
- the build cache.
The same module may also exist in client, server, and RSC-reference versions. Vite's pure SPA mode mainly maintains a browser-side ESM graph and transforms modules on demand, so its process model is much simpler when you're working only on the UI.
The article's Next.js: 10 GB+, Vite: around 1 GB are results from LobeHub's own experiments, not a fixed tenfold gap that every project will have. Project size, plugins, Source Maps, the dependency tree, and the Node.js version all affect the result.
Migration costs
After migrating, you'll need to rework:
- route mapping from App Router to React Router;
- SSR and SEO;
- injecting server-side configuration into the frontend;
- authentication and route guards;
- proxying the Next API;
- running the Vite and Next development servers side by side;
- static asset paths;
- differences between Electron and Web builds;
- RSC component boundaries.
This is an architecture-level DX optimization, not a matter of replacing next dev with vite. If Next.js is already working well for you, the move may not be worth it.
References
[1] LobeHub Performance and DX Optimization. innei. https://innei.in/posts/tech/lobehub-performance-dx-optimization
[2] antd-style createStaticStyles. antd-style. https://github.com/ant-design/antd-style
[3] Base UI. MUI. https://base-ui.com/
[4] React Activity (Offscreen Component). React. https://react.dev/blog/2025/04/23/react-19-updates
[5] class-variance-authority. https://github.com/joebell.co.uk/cva
[6] electron-vite. https://electron-vite.org/
[7] electron-builder. https://www.electron.build/