How we cut our client bundle from 26 MB to 18 MB
Max Musing
Max MusingFounder and CEO of Basedash
· June 9, 2026

Max Musing
Max MusingFounder and CEO of Basedash
· June 9, 2026

Performance matters a lot to us at Basedash, but because we’re a BI web app, most of our performance work is tied to complex data fetching and rendering after the app has loaded. We hadn’t looked at our production client bundle in a while.
Bundle size is the kind of metric that constantly drifts upward, one dependency at a time, until someone finally notices that it’s an issue. A couple weeks ago someone noticed, so we looked. The client bundle was 25.67 MiB across 552 chunks. After a bit of digging, we got it down to 18.34 MiB across 453 chunks, a 28.6% drop, without removing a single feature.
The first step was turning on rollup-plugin-visualizer and reading the treemap. Our build is React Router v7 framework mode on top of Rolldown-Vite, so we already had the plugin wired up behind a VITE_ANALYZE env var:
VITE_ANALYZE=treemap pnpm build
open build/client/stats.html
The treemap is the most useful 30 seconds of work in this whole project. You stare at the rectangles and immediately have opinions about every one of them.
Our top offenders, in rough order of “what is this even doing here”:
vendor-shiki, 5.1 MiBAgentMessageHistory, 2.41 MiBhtml2pdf vendor chunk, 1.85 MiBroot chunk, 983 KiBentry.client, 974 KiBSome of those make sense (Shiki is a full syntax highlighter). Others do not. AgentMessageHistory is a domain class that holds AI chat messages, and it should be nowhere near 2.41 MiB.
The root chunk had a 484 KiB blob labeled react-scan. react-scan is the runtime profiler that draws colored borders around components when they re-render. It’s useful in dev and has no business being on a customer’s machine.
We had it gated behind a feature flag, but the gate only controlled whether scan() started. The entire library was still in the root bundle on every page load, ready to scan in case the flag ever flipped.
// app/components/.client/ReactScan.tsx (deleted)
import { scan } from 'react-scan';
export function ReactScanMonitoring({ scanEnabled }: { scanEnabled: boolean }) {
useEffect(() => {
scan({ enabled: scanEnabled, dangerouslyForceRunInProduction: scanEnabled });
}, [scanEnabled]);
return null;
}
Two deleted files and one removed package.json entry later, the root chunk lost half a megabyte. If we ever want react-scan back, we can lazy-load it behind the same flag. There’s no reason for it to live at the top of the dependency tree.
This was the bigger one, and it’s the kind of bug that’s almost invisible without a treemap.
gpt-tokenizer is 2.3 MiB of BPE token tables, used for counting OpenAI tokens before we send a chat history to the model. Token counting is a server-side job, and we never count tokens in the browser. Yet there it was, inside a chunk called AgentMessageHistory.
AgentMessageHistory.ts is a shared file. It defines the AgentMessage shape, plus a helper called bufferHistoryToTokenLimit that trims an array of messages to fit under a context window. The helper is the only thing that imports gpt-tokenizer, but importing it from a shared file means the bundler can’t tell that only the server needs it. Both the client and server pull in the whole file, so both get the tokenizer.
We split the file. The pure type definitions stay in the shared file, and the token-counting helper moves to a .server.ts suffix, which Vite/React Router treats as server-only and tree-shakes from the client.
// app/features/ai/domain/agentMessageTokenBuffer.server.ts
import { encode } from 'gpt-tokenizer';
export function bufferHistoryToTokenLimit(/* ... */) {
// token counting that only ever runs on the server
}
// app/features/ai/core/agent.ts
import { bufferHistoryToTokenLimit } from '~/features/ai/domain/agentMessageTokenBuffer.server';
The AgentMessageHistory chunk went from 2.41 MiB to 47 KiB. That’s a 50x reduction from moving 111 lines of code into a different file.
shiki/bundle/web is the convenience entrypoint that ships every web language Shiki supports (around 53 of them) plus the Oniguruma WASM regex engine. It’s a great default if you don’t know which languages you need. We know which languages we need, and it’s not 53.
// Before
import { createHighlighter } from 'shiki/bundle/web';
const highlighter = await createHighlighter({
themes: [BasedashDark, BasedashLight],
langs: ['sql', 'javascript', 'typescript', /* ... 8 more */],
});
We replaced that with shiki/core plus fine-grained imports. Only SQL and Markdown load at init (those are by far the most common in our app, since the chat agent generates a lot of SQL inside Markdown fences). Everything else is dynamically imported the first time we see it.
// After
import { createHighlighterCore } from 'shiki/core';
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
const highlighter = await createHighlighterCore({
engine: createJavaScriptRegexEngine(),
themes: [BasedashDark, BasedashLight],
langs: [
CustomSqlTextmate,
import('@shikijs/langs/sql'),
import('@shikijs/langs/markdown'),
],
});
const LANGUAGE_IMPORTS: Record<string, () => Promise<unknown>> = {
javascript: () => import('@shikijs/langs/javascript'),
typescript: () => import('@shikijs/langs/typescript'),
python: () => import('@shikijs/langs/python'),
// ... 7 more
};
Swapping Oniguruma WASM for the JavaScript regex engine is another 608 KiB. Shiki ships an engine specifically for the languages it supports, and the trade-off (slightly slower regex matching on a one-time init) is fine for our usage.
vendor-shiki went from 5.10 MiB to 1.02 MiB, an 80% cut on its own.
There’s a class of dependencies that are huge but only needed in specific situations: PDF export, SQL formatting, emoji rendering for Slack messages, Sentry session replay. We were eagerly importing all of them, so every dashboard load paid for features the user might never trigger.
html2pdf (1.85 MiB) only runs when someone clicks “Export to PDF” in the dashboard header. We split it into its own chunk and dynamically imported it at the click site. The library now sits in a deferred chunk that the browser fetches between the user clicking the button and the PDF generating (which takes seconds anyway because rasterizing a dashboard is not cheap).
sql-formatter (285 KiB) is used in a lot of places, but none of them are on first paint. The chart agent shows formatted SQL after running a query, and the SQL editor formats on demand. None of that needs to block the initial bundle.
Several React components called formatSqlQuery synchronously and rendered the result directly, so making the function async would have forced all of them into a loading state. Instead, we wrote a small hook that shows raw SQL immediately and swaps in the formatted version once the module loads:
// app/hooks/useFormattedSql.ts
let formatterModule: typeof import('sql-formatter') | null = null;
let formatterPromise: Promise<typeof import('sql-formatter')> | null = null;
export function useFormattedSql(sql: string, dialect: Dialect) {
const [formatted, setFormatted] = useState(sql);
useEffect(() => {
if (formatterModule) {
setFormatted(formatterModule.format(sql, { language: dialect }));
return;
}
formatterPromise ??= import('sql-formatter');
formatterPromise.then((mod) => {
formatterModule = mod;
setFormatted(mod.format(sql, { language: dialect }));
});
}, [sql, dialect]);
return formatted;
}
The module is cached at module scope, so the dynamic import only fetches once per session. After that, the hook is effectively synchronous. The formatSqlQuery chunk went from 301 KiB to 2.9 KiB.
node-emoji (343 KiB, mostly its emojilib data) gets the same treatment, with a slightly different fallback. Emoji shortcodes like :wave: pass through as text until the module loads, then get replaced. The half second it takes to convert :white_check_mark: into a checkmark on the second render isn’t noticeable.
Sentry Replay (231 KiB) had a built-in escape hatch. Sentry’s SDK has a lazyLoadIntegration helper that fetches the integration code from the Sentry CDN after init, instead of bundling it. It was a one-line change:
Sentry.lazyLoadIntegration('replayIntegration').then((replayIntegration) => {
Sentry.addIntegration(replayIntegration({
maskAllText: true,
blockAllMedia: true,
}));
});
Replay still captures everything it captured before, with a brief delay after page load. For session replay specifically, missing the first 500ms is a fine trade.
This last one isn’t a bundle-size win, but it cost us 14% of build CPU time and was a weird enough bug to write down.
Our Vite config runs Babel with the React Compiler plugin over our source files. The include glob looked like this:
babel({
include: '/app/**/*',
exclude: /app\/emails\/templates/,
filter: /\.[jt]sx?$/,
}),
That works fine on a developer laptop where the project lives at /Users/derek/code/charts/app/.... The glob /app/**/* doesn’t match anything outside the project.
In Docker, WORKDIR=/app, so node_modules lives at /app/node_modules/. The same glob now eagerly matches every vendor file. Babel started processing thousands of dependencies (react-dom, html2pdf, and everything else), which triggered V8 deoptimization on a few of the larger files.
Fixing it took one line:
babel({
include: '/app/**/*',
exclude: [/node_modules/, /app\/generated\//, /emails\/templates/],
filter: /\.tsx$/,
}),
We also narrowed the file filter from .js/.ts/.jsx/.tsx to .tsx only. React Compiler only does useful work on TSX files (it’s about React components), so processing non-component code was pure waste.
| Metric | Before | After | Change |
|---|---|---|---|
| Total client bundle | 25.67 MiB | 18.34 MiB | −7.33 MiB (−29%) |
entry.client |
974 KiB | 741 KiB | −233 KiB (−24%) |
root chunk |
983 KiB | 438 KiB | −545 KiB (−55%) |
| Chunks | 552 | 453 | −99 |
| Build wall-clock | 30.2s | 30.4s | ~same |
| Build CPU time (user) | 42.6s | 36.6s | −14% |
The chunks-emitted count dropping is mostly the inverse of all the lazy loading. We have fewer small chunks because we don’t pre-split things that nobody asked for, and the things we do lazy-load are larger, more deliberate chunks.
Build wall-clock barely moved, which is the outcome we were hoping for: smaller output at the same build speed.
There’s more to find. We didn’t touch Monaco (the SQL editor’s underlying engine), which is around 2 MiB and could probably be lazy-loaded behind the SQL editor route. There’s a long tail of vendor chunks in the 50 to 150 KiB range that could be coalesced or trimmed.
We also haven’t measured the impact on real-world TTI (time-to-interactive) for our customers. A smaller bundle only matters to the extent that it lets the user do something a second sooner. We’re going to instrument that next, with the Sentry Replay we just made smaller.
A few lessons to take away:
.server.ts suffix is the cheapest fix you’ll ever ship. Splitting AgentMessageHistory.ts meant moving 110 lines, and it deleted 2.36 MiB of client code.shiki/bundle/web is a great default, but we needed to graduate to shiki/core and load only the languages we use./app is going to behave differently when WORKDIR=/app.If you want a tour of how we think about reliability and frontend perf more broadly, we have a writeup on how 97 Replicache subscriptions stalled our dashboard editor and another on what was killing our healthy Kubernetes pods. Both follow the same pattern as this one: measure, find the surprise, fix it, and measure again.
And if you want to see what’s running on the smaller bundle, Basedash is an AI-native BI platform that lets your team chat with their data instead of building dashboards by hand.
Written by

Founder and CEO of Basedash
Max Musing is the founder and CEO of Basedash, an AI-native business intelligence platform designed to help teams explore analytics and build dashboards without writing SQL. His work focuses on applying large language models to structured data systems, improving query reliability, and building governed analytics workflows for production environments.
Basedash lets you build charts, dashboards, and reports in seconds using all your data.