Reading LobeHub's Performance and DX Optimization: Shifting Cost from Per-Render to One-Time
This is an analysis of innei.in/posts/tech/lobehub-performance-dx-optimization. The original article uses the LobeHub ChatGPT/LLM client project as a case study to introduce its various optimizations across frontend components, Electron main process, and Next.js development experience. This article doesn't repeat the original narrative but distills the scattered specific changes into a transferable engineering framework: Four Cost Categories × Cost Position Shifting.
Related Notes
- [[Notes/web/Java/JVM Principles, Performance Tuning, and Troubleshooting]]: JVM tuning follows a similar logic, both doing "amortizing high-frequency small costs."
1. Building an Intuition: Infrastructure Small Costs Get Amplified by Component Count
The common premise of LobeHub's optimizations: 0.1 ms per operation doesn't seem expensive, but multiplied by component count, it's a different story.
The article gives a rough calculation:
0.1 ms × 500 components = 50 ms
The budget for smooth rendering is 16.7 ms per frame. Once some tiny operation in the infrastructure becomes "something done on every component render," multiplying by a hundred or two hundred components turns it from "ignorable" to "noticeable stutter."
This intuition explains why LobeHub optimizes things that look completely insignificant to ordinary readers:
- Execution of a
<Flexbox>component function itself; - A
useStyles()Hook call; - Initialization of a Tooltip's floating layer;
- A normally invisible Modal with its Hook mounted in the React tree.
These are "infrastructure layer micro-actions." Optimizing a single one won't take a page from 1000 ms to 10 ms, but together, they can make an application that mounts hundreds of components just to open the homepage feel noticeably smoother.
All the specific changes that follow can be understood as: moving this 0.1 ms micro-action from "happens every time" to "happens only once," or to "happens when actually needed."
3. Replacing react-layout-kit: Moving CSS-in-JS Runtime to Module Loading
react-layout-kit's Flexbox looks like just a layout component, but under the antd-style/Emotion system, it still needs to:
Component executes
↓
Read props
↓
Construct style object
↓
Serialize to CSS string
↓
Compute Hash
↓
Query Emotion Cache
↓
If miss, insert CSS rule
↓
Return className
Five different prop combinations:
<Flexbox gap={8} />
<Flexbox gap={12} />
<Flexbox gap={16} />
<Flexbox gap={8} align="center" />
<Flexbox gap={8} justify="center" />
Each may generate different CSS:
.css-a1 { display: flex; gap: 8px; }
.css-b2 { display: flex; gap: 12px; }
.css-c3 { display: flex; gap: 16px; }
Even with cache hits, it still needs to: execute the component function, construct style parameters, do object allocation, do serialization, compute and query Hash. In development, it also saves debug info and source maps, making memory issues more prominent.
New Solution: All Instances Share One CSS
LobeHub wrote an internal Flex themselves, keeping the original API but switching the underlying layer to fixed classes:
:where(.lobe-flex) {
--lobe-flex: 0 1 auto;
--lobe-flex-direction: column;
--lobe-flex-wrap: nowrap;
--lobe-flex-justify: flex-start;
--lobe-flex-align: stretch;
--lobe-flex-width: auto;
--lobe-flex-height: auto;
--lobe-flex-padding: 0;
--lobe-flex-gap: 0;
display: flex;
flex: var(--lobe-flex);
flex-direction: var(--lobe-flex-direction);
flex-wrap: var(--lobe-flex-wrap);
justify-content: var(--lobe-flex-justify);
align-items: var(--lobe-flex-align);
width: var(--lobe-flex-width);
height: var(--lobe-flex-height);
padding: var(--lobe-flex-padding);
gap: var(--lobe-flex-gap);
}
The component only maps props to CSS variables:
<div className="lobe-flex" style={cssVars}>{children}</div>
The effect is:
gap=8 → shared .lobe-flex + set --lobe-flex-gap: 8px
gap=12 → shared .lobe-flex + set --lobe-flex-gap: 12px
gap=16 → shared .lobe-flex + set --lobe-flex-gap: 16px
Costs saved:
- No longer generating new Hash class names for each gap value;
- No longer continuously inserting rules into
<style>; - No longer needing Emotion Cache queries.
New costs:
- Need to construct a small
cssVarsobject on each render; - Concatenate
className.
The latter is clearly cheaper than the former. The article notes single component overhead is about 0.1 ms; what's truly serious is hundreds of components × 0.1 ms.
Why CSS Variable Defaults Need to Be Written on Each Flex
CSS custom properties inherit by default. Suppose:
<div class="lobe-flex" style="--lobe-flex-gap: 16px">
<div class="lobe-flex"></div>
</div>
If the child doesn't explicitly define --lobe-flex-gap, it may inherit the parent's 16px, causing extra spacing in the child layout.
So the internal CSS resets defaults on every .lobe-flex:
:where(.lobe-flex) {
--lobe-flex-gap: 0;
--lobe-flex-direction: column;
--lobe-flex-align: stretch;
}
This layer of defaults is a necessary cost. When reading similar "CSS variable-driven component styling" code, you can directly check whether each layer resets the defaults.
5. Permanent Homepage: React Activity and "Visit-After Cache"
LobeHub's homepage (DesktopHomeLayout) contains a lot:
- Sidebar;
- Recent history;
- Input area;
- Skill list;
- Multiple popup entries;
- Many store selectors;
- Data recovery logic.
The old route structure was roughly:
<Route path="/" element={<Home />} />
<Route path="/settings" element={<Settings />} />
Going to settings and back to homepage goes through the full unmount-remount chain:
Home Fiber unmounted
↓
Home DOM deleted
↓
Home Effects cleaned up
↓
Home child component state lost
↓
On return:
Re-execute Home
↓
Re-execute all child components
↓
Re-run all Hooks and selectors
↓
Re-create Fiber
↓
Re-create DOM
↓
Re-insert DOM
↓
Re-calculate layout and paint
The article measured DesktopHomeLayout taking close to 500 ms on return.
Fix: Move Homepage Out of Route Branch
The transformed structure is roughly:
<DesktopLayoutContainer>
<DesktopHomeLayout>
<DesktopHome />
</DesktopHomeLayout>
<Suspense fallback={<Loading />}>
<Outlet />
</Suspense>
</DesktopLayoutContainer>
The original index route no longer creates a separate homepage component.
Inside the homepage, it judges by path:
const { pathname } = useLocation();
const isHomeRoute = pathname === '/';
<Activity mode={isHomeRoute ? 'visible' : 'hidden'}>
<Home />
</Activity>
Activity behavior in mode="hidden":
- Hides content with
display: none; - Preserves component's React State;
- Preserves corresponding DOM;
- Cleans up component's Effects and subscriptions;
- May still respond to new props at low priority while hidden;
- Restores original state and re-creates Effects when shown again.
It can be understood as:
Fiber, State, DOM: preserved
Effects, subscriptions: cleaned up
Visual: display: none
On return, the main work becomes:
hidden → visible
↓
Restore Effects
↓
Browser re-displays existing DOM
No longer rebuilding the entire DOM tree.
hasActivated: Visit-After Cache, Not Startup Pre-Rendering
The actual code adds:
const [hasActivated, setHasActivated] = useState(isHomeRoute);
useEffect(() => {
if (isHomeRoute) setHasActivated(true);
}, [isHomeRoute]);
if (!hasActivated) return null;
The logic is:
User never visited homepage → Don't create homepage
User first visits homepage → Create homepage
User leaves homepage afterwards → Hide but preserve
This is "visit-after cache," not "unconditional pre-rendering at app startup." Otherwise, if a user goes directly to /settings, the app would also mount the heavy homepage in the background, increasing startup time and memory instead.
Absolute Positioning and Cost
The permanent homepage container uses:
style={{
inset: 0,
position: 'absolute',
}}
Since the homepage no longer belongs to normal route output, we need to prevent it from participating in other pages' document flow layout. When homepage is visible, it covers main content; when hidden, Activity makes it invisible.
The cost of this optimization is permanent occupation:
- Fiber memory;
- DOM memory;
- Component State;
- Cached data;
- Browser layout objects.
Suitable scenarios:
- High-frequency return homepages;
- Editors;
- Chat sessions;
- Pages needing to preserve input state.
Unsuitable scenarios:
- Rarely-returned-to pages;
- Pages with particularly massive DOM;
- Pages with many
<video>/<audio>/<iframe>elements.
After hiding, <video>, <audio>, <iframe> DOM still exists and may continue executing their own behaviors—need to explicitly pause or release in Effect cleanup functions.
7. Tooltip Singleton: Sharing One Floating Layer
Each old Tooltip instance could have:
useFloatingpositioning logic;- Mouse enter/leave timers;
- Focus and Hover interaction Hooks;
- Portal;
- Arrow positioning;
- Open State;
- Motion animation;
ResizeObserver;- Scroll listening;
- Auto-update positioning logic.
If a page has 200 icons each wrapped in Tooltip:
<Tooltip title="Edit"><Button /></Tooltip>
<Tooltip title="Delete"><Button /></Tooltip>
<Tooltip title="Copy"><Button /></Tooltip>
Even when Tooltips aren't open, each instance's trigger and Hooks still need to be created. The article's TooltipFloating is close to 200 lines, containing Floating UI, debounce, AnimatePresence, dynamic transformOrigin, content switch animation, arrow logic, Portal, and layout animation.
Singleton Tooltip Structure
<TooltipGroup>
<Tooltip title="Edit"><Button /></Tooltip>
<Tooltip title="Delete"><Button /></Tooltip>
<Tooltip title="Copy"><Button /></Tooltip>
</TooltipGroup>
Each Tooltip only registers:
Trigger node
title
placement
callback
The entire Group shares one real floating layer:
One Root
One Portal
One Floating Content
One Arrow
One set of positioning state
After mouse enters a trigger, only updates the current payload:
activeItem = {
title: 'Delete',
anchor: deleteButton,
placement: 'top',
};
The floating layer repositions to the new anchor instead of each button creating a complete floating layer component tree.
Actual implementation is based on Base UI's <BaseTooltip.Root handle={handle}>, sharing handle via Group Context. Regular Tooltips discover they're inside a Group and enter shared mode; controlled Tooltips, those with defaultOpen or explicit standalone retain independent implementation.
Why Base UI Is Easier to Optimize Than Full UI Libraries
Base UI is a Headless component that mainly provides:
- Accessibility;
- Interaction state;
- Positioning;
- Keyboard behavior;
- ARIA;
- Basic Portal.
Style and animation are controlled by the project itself. Compared to full component libraries, it can avoid:
- Compatibility layers;
- Theme wrapping;
- Multiple Context layers;
- Default DOM;
- Default animations;
- Style injection logic.
A more accurate statement isn't "Ant Design slow, Base UI fast," but "LobeHub can implement smaller component trees and shared floating layers based on Base UI." Whether it's worth switching base libraries depends on how many such scenarios are in your own project.
9. Electron Package Size and Native Modules: Three Things
The Electron main process might directly depend on:
import somePackage from 'some-package';
If the bundler externalizes all dependencies, the product only keeps require('some-package'), and runtime must carry the full node_modules/some-package. The entire node_modules also contains:
- Unused files;
- README;
- Type declarations;
- Source Maps;
- Multiple module formats;
- Test code;
- Optional assets;
- Duplicate dependencies.
Changed to Bundling Regular JS Dependencies
The optimized electron-vite no longer externalizes all dependencies. Regular JavaScript packages get rolled into:
dist/main/index.js
dist/preload/index.js
Rollup also does:
- Tree Shaking;
- Deduplication;
- Compression;
- Inlining dependencies;
- Deleting unused code.
electron-builder configuration:
files: [
'dist',
'resources',
'!node_modules',
...getFilesPatterns(),
]
Overall excludes node_modules, only re-adding native dependencies that must be kept.
Native Modules Can't Be Bundled Together
For example:
better-sqlite3
sharp
node-mac-permissions
These packages may carry .node files, which are native dynamic libraries, not JavaScript. Node.js loading them needs real file paths:
process.dlopen(...)
So native modules need to satisfy three things simultaneously:
Rollup doesn't bundle it
rollupOptions: {
external: getExternalDependencies(),
}
The generated result still keeps require('node-mac-permissions').
electron-builder puts it in the installer
files: [...getFilesPatterns()]
Unpack from ASAR
asarUnpack: getAsarUnpackPatterns()
The final file lands at app.asar.unpacked/node_modules/....
The project also recursively reads native modules' package.json to add their transitive dependencies, otherwise the native module itself exists but may still fail at startup due to missing child dependencies.
Three must match:
external list = builder include list = asarUnpack list
Missing any one may result in "builds successfully, fails at runtime." This is the most common pitfall for newcomers in Electron packaging.
Electron Language Resource Deletion
The Electron Framework carries Chromium localization resources:
en.lproj
zh_CN.lproj
ja.lproj
fr.lproj
These are Chromium native interface resources, not the app's own i18n JSON.
The afterPack hook after build traverses:
Electron Framework.framework/
Versions/A/Resources/*.lproj
Only keeps English-related directories:
new Set(['en', 'en_GB', 'en-US', 'en_US']);
Can't delete everything; Electron Framework still needs basic localization resources.
11. IPC Type Safety: From Strings to Controller Registry
Old写法:
ipcRenderer.invoke('minimizeWindow', params);
ipcMain.handle('minimize-window', handler);
Inconsistent spelling, TS can't check, channel name/parameters/return value/main process implementation/renderer call are all independent. When refactoring main process methods, callers might still compile successfully and only fail at runtime.
Controller + Decorator
Main process changed to:
export default class BrowserWindowsCtr extends ControllerModule {
static override readonly groupName = 'windows';
@IpcMethod()
minimizeWindow() {
BrowserWindow.getFocusedWindow()?.minimize();
return { success: true };
}
}
Channel name auto-derives to windows.minimizeWindow. Controllers are uniformly registered to controllerIpcConstructors, used both for runtime IPC Handler installation and for TypeScript to extract service types.
Renderer Uses Proxy
const ipc = ensureElectronIpc();
await ipc.windows.minimizeWindow();
ensureElectronIpc() can use JavaScript Proxy to collect property access:
ipc.windows.minimizeWindow()
↓
window.electronAPI.invoke('windows.minimizeWindow')
TypeScript types come from Controller registry, able to derive:
- Available Controllers;
- Available methods;
- Parameters;
- Return values.
But TS types only exist at compile time. IPC crosses process boundaries, renderer may be attacked, main process still needs runtime parameter validation, can't rely on TS alone.
13. Next.js to Vite: Splitting Frontend/Backend Build Responsibilities
LobeHub's solution isn't simply "delete Next.js" but splits responsibilities:
Vite
├─ React SPA
├─ Desktop UI
├─ Mobile UI
├─ React Router
└─ Frontend Dev Server
Next.js
├─ API Route
├─ Auth
├─ Server-side logic
├─ Serverless deployment
└─ Necessary server entry points
When developing pure UI, only start Vite, not the full Next.js App Router, RSC, server bundling, route analysis process.
Why Large Next.js Projects Have Higher Development Memory
App Router projects during development may simultaneously maintain:
- Client module graph;
- Server module graph;
- RSC module graph;
- Route tree;
- Server Actions;
- Route Handlers;
- Middleware;
- HMR state;
- Source Maps;
- Build cache.
The same module may also exist in client version, server version, RSC reference version. Vite pure SPA mode mainly maintains browser-side ESM graph, transforms modules on demand—process model is much simpler when only developing UI.
The article's Next.js: 10 GB+, Vite: ~1 GB are LobeHub project's own experimental results; not all projects will differ by exactly ten times. Project size, plugins, Source Maps, dependency tree, Node.js version all affect this.
Migration Cost
After migration, need to re-handle:
- App Router to React Router route mapping;
- SSR and SEO;
- Server config injection to frontend;
- Authentication and route guards;
- Next API proxy;
- Vite and Next two dev servers;
- Static asset paths;
- Electron and Web build differences;
- RSC component boundaries.
This is architecture-level DX optimization, not simply changing next dev to vite. If current Next.js is working well, may not be worth moving.
References
[1] LobeHub 性能与 DX 优化. 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/