FRD: Lazy-Load Recharts in Chart Stories
| Field | Value |
|---|---|
| ID | FRD-050 |
| Owner | David Holmes |
| Status | Draft |
| Last Updated | 2026-05-26 |
| Target Release | v2.1.0 |
| Type | Refactor |
| Complexity | S |
Document Summary
Recharts (~252 kB minified) is eagerly loaded by all chart component stories and any application page that imports a chart component. This FRD defines the work to lazy-load Recharts via dynamic import so chart components emit a separate chunk loaded only when a chart is rendered.
Introduction
Overview
Bundle analysis shows that recharts (v3.8.1) contributes approximately 252 kB to the shared chunk. This chunk is loaded by every Storybook story and application page, even those that do not render charts. Lazy-loading Recharts behind a dynamic import() boundary eliminates this cost for non-chart consumers and improves initial page load performance.
Goals
- Move
rechartsimports behind dynamicimport()in chart components. - Chart components emit a separate chunk containing Recharts and its D3 dependencies.
- Non-chart Storybook stories and application routes do not load the Recharts chunk.
- Chart rendering remains functionally identical.
Non-Goals
- Replacing Recharts with a lighter charting library.
- Tree-shaking individual Recharts components (already done by the bundler for named imports).
- Changing chart component APIs or visual design.
Scope
In Scope
| Item | Description |
|---|---|
| Chart components | All components that import from recharts. |
| Lazy wrapper | React.lazy() wrapper for each chart component (or a shared chart lazy boundary). |
| Loading fallback | Shape-matched skeleton for each chart type. |
| Storybook stories | Chart stories use the lazy wrapper. |
Out of Scope
| Item | Reason |
|---|---|
| Recharts version upgrade | Separate concern. |
| Chart component redesign | Pure bundling refactor. |
| PodTerminal lazy loading | Covered by FRD-049. |
Users and Pain Points
| User | Pain Point |
|---|---|
| Application user (non-chart page) | Loads ~252 kB of JavaScript they never use, increasing page load time. |
| Storybook user (non-chart stories) | Storybook initial load includes the Recharts chunk. |
| Mobile user | 252 kB of JavaScript has a significant parse-time cost on lower-powered devices. |
Definitions
| Term | Definition |
|---|---|
| Recharts | A React charting library built on D3, used for line charts, bar charts, pie charts, and area charts in the design system. |
| Dynamic import | A JavaScript import() expression that loads a module asynchronously at runtime. |
| Chunk | A separate JavaScript file produced by the bundler, loaded on demand. |
Current State
Chart components import Recharts at the top level:
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';These static imports cause the bundler to include the entire Recharts library (and its D3 dependencies) in a shared chunk that is loaded eagerly. Bundle analysis shows this chunk at approximately 252 kB (minified, pre-gzip).
All chart stories and any application page that imports a chart component pay this cost, even if the chart is conditionally rendered or behind a tab.
Proposed Solution
Identify chart components
Grep for all files importing from recharts:
rg "from 'recharts'" src/Each file gets the lazy-load treatment.
Lazy wrapper pattern
For each chart component (or a shared chart wrapper):
import { lazy, Suspense } from 'react';import { ChartSkeleton } from './chart-skeleton';
const LineChartComponent = lazy(() => import('./line-chart-impl'));
export function LineChart(props: LineChartProps) { return ( <Suspense fallback={<ChartSkeleton width={props.width} height={props.height} />}> <LineChartComponent {...props} /> </Suspense> );}Skeleton components
Create chart-skeleton.tsx that renders a placeholder matching the chart’s expected dimensions. Use a simple gray rectangle with axis lines to suggest the chart shape.
Shared chunk
Because all chart components import from recharts, the bundler will naturally coalesce them into a single shared chunk. Verify this in the build output.
Requirements
| ID | Priority | Requirement |
|---|---|---|
| LAZY-R-01 | P0 | recharts is loaded via dynamic import only when a chart component renders. |
| LAZY-R-02 | P0 | Non-chart pages and stories do not load the Recharts chunk. |
| LAZY-R-03 | P0 | Chart rendering is functionally unchanged. |
| LAZY-R-04 | P1 | A shape-matched skeleton displays during chunk loading. |
| LAZY-R-05 | P1 | The Recharts chunk is less than 300 kB (headroom for D3 subdependencies). |
Functional Requirements
- Each chart component renders a skeleton immediately, then swaps to the real chart once the Recharts chunk loads.
- Props are forwarded without loss.
- Charts render correctly with data, axes, tooltips, and legends after lazy load.
- If the chunk fails to load, an error boundary displays a retry option.
- Multiple chart components on the same page share the Recharts chunk (single download).
- Responsive behavior (chart resizing on window resize) works correctly after lazy load.
Non-Functional Requirements
| Category | Requirement |
|---|---|
| Bundle savings | Main chunk size decreases by at least 200 kB. |
| Load latency | Recharts chunk loads in under 300ms on broadband. |
| No layout shift | Skeleton matches chart dimensions. |
| Backward compatibility | Lazy wrappers export the same props as the original chart components. |
API/Interface Requirements
No public API changes. Lazy wrappers accept the same props as the original chart components. If chart components are exported from the barrel, the barrel points to the lazy wrappers.
Accessibility Requirements
- Chart skeletons should have
role="img"andaria-label="Loading chart". - Once loaded, charts retain their existing accessibility attributes (ARIA labels, data table alternatives).
Content and Documentation Requirements
- Update any documentation referencing chart component imports.
- Add a developer note about the lazy-load boundary in chart component TSDoc.
Dependencies
| Dependency | Type | Notes |
|---|---|---|
recharts (v3.8.1) | Existing (npm) | Moved behind dynamic import. |
| D3 subdependencies | Existing (transitive) | Bundled with Recharts in the lazy chunk. |
| Chart source components | Internal | Wrapped by lazy loaders. |
Risks and Tradeoffs
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Visible loading delay before charts appear. | Medium | Low | Skeleton provides visual continuity. Charts are rarely above-the-fold critical content. |
SSR incompatibility with React.lazy. | Low | Low | Charts are client-rendered; no SSR concern. |
| Recharts internal state management conflicts with lazy loading. | Low | Low | ResponsiveContainer and other Recharts components are designed for client-only rendering and handle mount/unmount gracefully. |
Open Questions
- Should all chart components share a single lazy boundary (one
React.lazyfor the whole chart module) or each get their own? Leaning toward one shared boundary for simplicity. - Should the skeleton animate (shimmer) or be static? Leaning toward a subtle shimmer consistent with other loading states in the design system.
- Are there chart components used in critical-path rendering that should preload the chunk?
Acceptance Criteria
-
rechartsis not in the main/shared chunk. - A separate chart chunk exists in the build output.
- Non-chart Storybook stories load without fetching the Recharts chunk.
- Charts render correctly after lazy loading.
- Skeleton displays during chunk load.
- Main chunk size decreases by at least 200 kB.
-
pnpm typecheckpasses. -
pnpm vitest run --project unitpasses. -
pnpm build-storybookcompletes without errors.
LLM Handoff Instructions
When implementing this FRD:
- Run
rg "from 'recharts'" src/to find all files that import from recharts. - For each chart component file, rename it to
*-impl.tsx(e.g.,line-chart.tsxbecomesline-chart-impl.tsx). - Create a new file with the original name that uses
React.lazy(() => import('./line-chart-impl'))and wraps it in<Suspense>. - Create
src/components/ui/chart-skeleton.tsx— a responsive placeholder withrole="img"andaria-label="Loading chart". Match the design system’s existing skeleton/loading patterns. - If multiple chart components exist, consider a single
chart-lazy-boundary.tsxthat wraps all of them rather than individual wrappers (reduces boilerplate). - Update chart stories to import from the lazy wrapper files.
- Run
pnpm build-storybookand verify the Recharts chunk is separate (check build output for chunk names and sizes). - Compare main chunk sizes before and after. Record in the PR description.
- Run
pnpm typecheckandpnpm vitest run --project unit.
Decision Log
| Date | Decision | Rationale |
|---|---|---|
| 2026-05-26 | Lazy-load at the chart component level using React.lazy. | Standard React code-splitting pattern. Consistent with the PodTerminal lazy-loading approach (FRD-049). |
| 2026-05-26 | Shape-matched skeleton over spinner. | Prevents layout shift and matches the design system’s loading state convention. |
| 2026-05-26 | Single Recharts chunk shared across all chart components. | The bundler naturally coalesces imports from the same package. Separate chunks per chart type would be wasteful. |
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | 2026-05-26 | David Holmes | Initial draft. |