Code Splitting Chart Libraries in Dashboards
Open the treemap for almost any analytics dashboard and the same shape appears: one enormous rectangle for the charting library, and everything else — the router, the design system, the application’s own code — crowded into the margins. A full-featured charting library with its scales, controllers, interaction plugins, and animation runtime is routinely 90–160 KB gzipped, which is the entire initial payload budget of 150 KB set out in route-based code splitting and dynamic import strategies, spent on a widget the user may never scroll to.
The naive fix makes things worse. Wrapping each chart component in its own React.lazy produces this in the Network panel:
revenue-chart-9f2a.js 200 script 4.1 kB 180 ms
sessions-chart-2b81.js 200 script 3.8 kB 190 ms
funnel-chart-77dc.js 200 script 4.4 kB 205 ms
retention-chart-c30e.js 200 script 3.9 kB 188 ms
chart-vendor-a71f.js 200 script 118 kB 940 ms
Five requests to render one screen, four of which are trivially small, and the library itself still arriving on the critical path. The split created request overhead without removing the thing that actually costs.
Root cause: the boundary is drawn per component, not per dependency
The unit worth splitting is not the chart component — it is the library the chart components share. Four 4 KB wrappers around a 118 KB dependency are, for splitting purposes, one 118 KB thing with four entry points.
This is the distinction the parent guide on component-level code splitting beyond routes frames as unique subtree size: what matters is the code a boundary uniquely owns. Splitting per component divides the wrappers, which own almost nothing, and leaves the shared dependency to be handled by whatever chunking heuristics happen to apply — usually landing it in a shared async chunk that every chart request must wait for anyway.
The fix: one async chart module, loaded on intersection
Collapse every chart behind a single async module, and trigger that module’s import from whichever chart container reaches the viewport first.
// charts/index.js — the single async boundary for all chart code
// Webpack 5: the magic comment keeps the chunk name stable in analyzer output.
export { LineChart } from './LineChart';
export { BarChart } from './BarChart';
export { FunnelChart } from './FunnelChart';
export { RetentionChart } from './RetentionChart';// ChartSlot.jsx — React 18+: one boundary, viewport-triggered
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
const loadCharts = () => import(/* webpackChunkName: "charts" */ './charts');
const chartCache = new Map();
function lazyChart(name) {
if (!chartCache.has(name)) {
// Every chart type resolves from the SAME module promise, so the
// library is fetched once no matter how many slots mount.
chartCache.set(name, lazy(() => loadCharts().then((m) => ({ default: m[name] }))));
}
return chartCache.get(name);
}
export function ChartSlot({ type, height = 320, ...props }) {
const ref = useRef(null);
const [near, setNear] = useState(false);
useEffect(() => {
if (near || !ref.current) return;
const io = new IntersectionObserver(
([entry]) => entry.isIntersecting && setNear(true),
// Start the fetch well before the container is visible so the
// chunk is usually resolved by the time the user scrolls to it.
{ rootMargin: '600px 0px' }
);
io.observe(ref.current);
return () => io.disconnect();
}, [near]);
const Chart = near ? lazyChart(type) : null;
return (
// The reserved height is the whole layout-shift story: the box exists
// at its final size before any chart code has been fetched.
<div ref={ref} style={{ height }}>
{Chart && (
<Suspense fallback={<div style={{ height }} aria-busy="true" />}>
<Chart {...props} />
</Suspense>
)}
</div>
);
}The chartCache map is what guarantees one request. Without it, lazy() is called fresh on every render for every slot, producing distinct lazy component types that each hold their own promise — the network request is still deduplicated by the module registry, but React remounts the subtree on every parent render, which is worse than the problem you set out to solve.
Shrinking the library itself
Splitting moves the bytes off the critical path; registration removes them entirely. Modern charting libraries ship a modular entry point where controllers, scales, and plugins are registered explicitly, and anything unregistered is tree-shaken away — provided you import from the modular entry rather than the package root.
// charts/setup.js — register only what this dashboard renders
// Importing from the package root instead would defeat all of this.
import {
Chart,
LineController, LineElement, PointElement,
BarController, BarElement,
CategoryScale, LinearScale,
Tooltip,
} from 'chart.js';
// Radar, polar, bubble, scatter, financial controllers and their scales are
// never referenced, so the bundler drops them: roughly 60% of the library.
Chart.register(
LineController, LineElement, PointElement,
BarController, BarElement,
CategoryScale, LinearScale,
Tooltip
);
export { Chart };If the numbers do not move after this change, the library is being pulled in whole by something else — a barrel re-export, a plugin importing the root, or a CommonJS build the bundler cannot analyse. Those causes and their fixes are covered in refactoring barrel files to reduce bundle bloat and auditing and replacing heavy dependencies.
Step-by-step verification
-
Count the requests. Load the dashboard with an empty cache and scroll to the bottom. Exactly one chart chunk should be requested, regardless of how many chart slots rendered.
-
Confirm charts below the fold cost nothing. Load the dashboard without scrolling. If the viewport shows no charts, no chart chunk should be fetched at all.
-
Check the registration saving. Compare the chart chunk’s gzipped size against the library’s full build. If they are within 10% of each other, registration is not taking effect.
-
Watch for layout shift. Record a performance trace while scrolling the dashboard end to end and confirm no layout-shift entries are attributed to chart containers.
-
Verify no remount storms. With React DevTools profiling, trigger a parent re-render and confirm chart components do not unmount and remount — that is the missing-cache symptom.
-
Re-check the treemap. Reopen the analyzer output described in reading webpack-bundle-analyzer treemaps and confirm the library now appears in the chart chunk only, not in the route chunk.
Edge cases and gotchas
Charts that must print or export. A print stylesheet or a PDF export path renders every chart at once, including ones never scrolled to. Provide an explicit “load all charts” call for those flows rather than relying on intersection, or the export silently produces empty boxes.
Server-rendered dashboards. Under SSR, IntersectionObserver does not exist and the initial HTML must not depend on it. Render the reserved container on the server, and only attach the observer after hydration — the constraints are the same ones described in code splitting for SSR and React Server Components.
Two charting libraries. Dashboards accumulate them: one library for the main charts, another that arrived with a third-party widget. The treemap will show both. Consolidating on one is usually a larger win than any amount of splitting.
Animation on first paint. A chart that animates in when its chunk resolves draws attention to the deferral. Disable entry animation for viewport-triggered charts and the split becomes invisible to the user.
FAQ
Why did splitting six charts make my dashboard slower?
Because each chart got its own chunk, and each of those chunks separately pulled in the charting library — or, if the bundler deduplicated it, the dashboard now issues six requests where one would do. Six small chunks on a high-latency connection cost more in round-trips and runtime registration than one larger chunk containing the same code. Route all charts through one shared async module so the library is fetched exactly once.
Should charts load on viewport intersection or on route entry?
Viewport intersection, with a generous root margin, for anything below the fold. A dashboard typically shows two or three charts above the fold and several more below; loading only what is visible removes the rest from the critical path entirely for sessions that never scroll. Use a root margin of several hundred pixels so the chunk request starts before the container is actually visible, which hides the fetch behind the scroll.
How much of a charting library can tree-shaking remove?
With a modular library and explicit registration, typically 50–70% — a dashboard rendering line and bar charts does not need the radar, polar, bubble, and financial controllers, their scales, or their interaction plugins. With a monolithic build imported from the package root, close to nothing: the library’s own barrel re-exports every controller, and the bundler cannot prove any of them unused. The registration style you choose matters more than the library you choose.
Related
- Component-Level Code Splitting Beyond Routes — the parent guide on splitting below the route boundary
- Lazy Loading Modal and Dialog Components — the interaction-triggered counterpart to viewport triggering
- Auditing and Replacing Heavy Dependencies — deciding whether the charting library itself is the right one
- Reading webpack-bundle-analyzer Treemaps — confirming where the library’s bytes ended up