Skip to content
HELiX

Bundle Size Fundamentals

apps/docs/src/content/docs/components/performance/bundle-size Click to copy
Copied! apps/docs/src/content/docs/components/performance/bundle-size

Bundle size is one of the most critical performance metrics for web applications, directly impacting page load time, Time to Interactive (TTI), and user experience. For enterprise healthcare applications where every millisecond counts, optimizing JavaScript bundle size is not optional — it’s a requirement.

This guide provides technical coverage of bundle size optimization for HELiX, covering tree-shaking, code splitting, lazy loading, minification, compression, and CI enforcement of performance budgets.

Reading note: Several recipes below describe behavior beyond what @helixui/library ships today:

  • The CI ceiling is 16 KB per component / 200 KB total (bundle-budgets.json); the <5 KB / <50 KB numbers are an aspirational floor (.bundle-budget.json), not the CI gate. Exceeding the floor flags a regression; exceeding the ceiling fails the PR.
  • @helixui/library does not declare sideEffects: false — Custom Element packages can’t, because customElements.define() is a real side effect. The shipped package.json ships an explicit sideEffects allow-list for dist/index.js, dist/components/*/index.js, and CSS.
  • The published distribution ships dist/index.js plus dist/components/<tag>/index.js, not raw src/ TypeScript. The Vite config auto-discovers component entry points from the src/components/<tag>/index.ts files — the hardcoded entry list shown in some samples below is illustrative.
  • The element-class export from @helixui/library is HelixButton (matching the source filename); the React-wrapper name in @helixui/react is HxButton.
  • Earlier drafts referenced hx-modal and wc-form; the shipped library uses hx-dialog and hx-form respectively. Find-and-replace cleanups in this file moved those instances to the real names — if you see a sentence that reads tautologically, that’s why.
  • The Lighthouse plumbing in this repo is a custom lighthouse-performance hook wired in package.json, not the standalone @lhci/cli package — adapt the sample below if you want LHCI in your own pipeline.
  • The Admin Dashboard /health page tracks component health scoring (per apps/admin/src/lib/health-scorer.ts); the bundle metrics referenced in some recipes are pulled from the bundle-size report, not surfaced as a dashboard widget today.

JavaScript bundle size directly impacts three critical web performance metrics:

Larger bundles take longer to download, especially on slow or unreliable networks. Healthcare professionals often work in environments with variable network quality—hospital basements, rural clinics, or mobile connections during patient transport.

Impact:

  • A 100 KB bundle on a 3G connection (750 Kbps) takes ~1.1 seconds to download
  • A 500 KB bundle takes ~5.5 seconds
  • Every kilobyte adds ~11 milliseconds to download time

After downloading, the browser must parse and compile JavaScript before it can execute. This is CPU-intensive work that blocks the main thread.

Impact:

  • Modern desktop browsers parse ~1 MB/s of JavaScript
  • Mobile devices parse at 200-300 KB/s
  • A 500 KB bundle takes ~2 seconds to parse on mobile

Larger bundles consume more memory, which can cause performance degradation on low-end devices or when users have many tabs open.

The Total Cost: For a 500 KB bundle on a mid-range mobile device:

  • Download: ~5.5 seconds
  • Parse/compile: ~2 seconds
  • Total blocking time: ~7.5 seconds

This is why bundle size optimization is a top priority for hx-library.

Performance budgets are hard limits enforced in CI. Every component must meet these thresholds or the build fails.

MetricBudgetEnforcement
Individual component (min+gz)< 5 KBCI bundle analysis
Full library bundle (min+gz)< 50 KBCI bundle analysis
Lit core overhead~5 KB baselineExternalized in build
Time to first render (CDN)< 100msPerformance test suite
LCP (docs site)< 2.5sLighthouse CI
INP< 200msLighthouse CI
CLS< 0.1Lighthouse CI

Zero tolerance for regressions. If a PR increases bundle size, it must be justified and approved by the performance engineer.

  • CI ceiling — 16 KB per component / 200 KB total (bundle-budgets.json): blocking gate — exceeding it fails the PR.
  • Aspirational floor — <5 KB per component / <50 KB total (.bundle-budget.json): regression flag — crossing it surfaces a warning so growth doesn’t accumulate silently. Lit core at ~5 KB is externalized, so well-scoped components stay comfortably below the floor.
  • 100ms first render: Components must be interactive within 100ms of loading to meet enterprise UX requirements.
  • Core Web Vitals alignment: LCP, INP, and CLS thresholds match Google’s “Good” criteria for production sites.

Measuring bundle size accurately requires the right tools. HELiX uses a combination of build-time analysis and runtime monitoring.

Vite provides detailed bundle analysis during production builds. The HELiX configuration outputs granular metrics for each component.

Terminal window
npm run build

Output example:

dist/index.js 0.13 kB │ gzip: 0.10 kB
dist/components/hx-button/index.js 3.47 kB │ gzip: 1.42 kB
dist/components/hx-card/index.js 2.89 kB │ gzip: 1.18 kB
dist/components/hx-text-input/index.js 4.21 kB │ gzip: 1.73 kB
dist/shared/hx-form-Dy74Gm0T.js 1.56 kB │ gzip: 0.68 kB

Key metrics to monitor:

  • Unminified size: Shows raw code size before minification
  • Minified size: Size after terser/esbuild minification
  • Gzipped size: Compressed size (what’s actually transferred over HTTP)
  • Brotli size: More aggressive compression (10-20% smaller than gzip)

Bundlephobia analyzes npm packages and shows their impact on bundle size.

How to use:

  1. Publish a pre-release version of @helixui/library
  2. Enter package name at bundlephobia.com
  3. View per-component tree-shaking impact

Example analysis for hx-button:

Import: import { HelixButton } from '@helixui/library'
Bundle size: 8.2 KB (minified)
Gzipped: 3.1 KB
Tree-shakeable: ✅
Dependencies: lit@3.3.2 (5.1 KB)

Visual bundle analysis shows which modules contribute to bundle size.

Installation:

Terminal window
npm install --save-dev rollup-plugin-visualizer

Vite integration:

vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
visualizer({
filename: 'dist/stats.html',
gzipSize: true,
brotliSize: true,
}),
],
});

Output: Interactive HTML visualization showing module sizes, dependencies, and chunk relationships.

Runtime coverage analysis identifies unused code in production.

Steps:

  1. Open Chrome DevTools → Coverage panel (Cmd+Shift+P → “Show Coverage”)
  2. Load your application
  3. Interact with components
  4. View unused bytes per file

Red flags:

  • 50% unused code in a bundle suggests poor code splitting

  • Entire components loaded but never rendered indicate missing lazy loading

Tree-shaking eliminates dead code during the build process. For it to work effectively, both the library and consumers must follow strict conventions.

Tree-shaking relies on static analysis of ES module imports/exports. Bundlers like Rollup, Webpack, and Vite analyze which exports are actually imported and remove everything else.

Example:

utils.ts
export function usedFunction() {
/* ... */
}
export function unusedFunction() {
/* ... */
}
// consumer.ts
import { usedFunction } from './utils.js';
usedFunction(); // Only this code is included in bundle
// unusedFunction is removed (tree-shaken)

Tree-shaking only works with ES module syntax (import/export). CommonJS (require/module.exports) cannot be tree-shaken.

HELiX configuration:

package.json
{
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./components/*": {
"types": "./src/components/*/index.ts",
"import": "./src/components/*/index.ts"
}
}
}

The sideEffects field tells bundlers that modules are pure and safe to remove if unused.

package.json
{
"sideEffects": false
}

What this means: If a module is imported but none of its exports are used, the bundler can remove it entirely.

Exception: If a module has side effects (registers global event listeners, mutates globals, etc.), it should be listed:

{
"sideEffects": ["./src/polyfills.js"]
}

HELiX exposes each component as a separate entry point to enable granular imports.

Vite configuration:

vite.config.ts
export default defineConfig({
build: {
lib: {
entry: {
index: resolve(__dirname, 'src/index.ts'),
'components/hx-button/index': resolve(__dirname, 'src/components/hx-button/index.ts'),
'components/hx-card/index': resolve(__dirname, 'src/components/hx-card/index.ts'),
// ... per-component entries
},
formats: ['es'],
},
},
});

Consumer usage:

// ✅ Tree-shakeable (only loads hx-button)
import '@helixui/library/components/hx-button';
// ❌ NOT tree-shakeable (loads entire library)
import '@helixui/library';

Barrel exports (re-exporting everything from index.ts) defeat tree-shaking.

Anti-pattern:

// ❌ src/index.ts (barrel export)
export * from './components/hx-button/index.js';
export * from './components/hx-card/index.js';
export * from './components/hx-text-input/index.js';
// ... (all components)
// Consumer can't tree-shake this
import { HelixButton } from '@helixui/library';

HELiX approach:

// ✅ src/components/hx-button/index.ts
export { HelixButton } from './hx-button.js';
// Consumer imports directly
import { HelixButton } from '@helixui/library/components/hx-button';

Test:

  1. Create a minimal consumer app
  2. Import a single component
  3. Build for production
  4. Verify bundle only includes that component + Lit core
test-app/src/main.ts
import '@helixui/library/components/hx-button';
document.body.innerHTML = '<hx-button>Test</hx-button>';

Expected bundle size: ~8-10 KB (5 KB Lit + 3-5 KB hx-button)

If larger: Tree-shaking is broken. Common causes:

  • Barrel exports in library
  • Side effects in modules
  • Non-ESM dependencies

Code splitting divides your application into smaller chunks that can be loaded on demand. This reduces the initial bundle size and improves Time to Interactive.

For applications with multiple pages, split code by route so users only load JavaScript for the current page.

React example:

import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Profile = lazy(() => import('./pages/Profile'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Route path="/dashboard" component={Dashboard} />
<Route path="/profile" component={Profile} />
</Suspense>
);
}

Impact: If Dashboard uses hx-card and Profile uses hx-form, each route only loads the components it needs.

For heavy components used in specific contexts, split them into separate chunks.

When to split:

  • Component is >10 KB (minified)
  • Component is only used in specific user flows
  • Component has heavy dependencies (charts, editors, etc.)

Example (Vite):

// Dynamically import heavy component (the @customElement decorator registers it on import)
async function loadDataTable() {
await import('@helixui/library/components/hx-data-table');
}
// Load only when needed
if (userWantsDataTable) {
await loadDataTable();
}

Separate vendor code (Lit, dependencies) from application code so users can cache vendor bundles across page loads.

Vite configuration:

export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-lit': ['lit', '@lit/reactive-element'],
'vendor-hx': ['@helixui/tokens'],
},
},
},
},
});

Result:

  • vendor-lit.js (5 KB, rarely changes, cached long-term)
  • vendor-wc.js (2 KB, changes with token updates)
  • app.js (application code, changes frequently)

Small components (<10 KB): Overhead of additional HTTP requests may outweigh benefits.

Critical path code: Components needed for first paint should be inlined, not split.

Shared dependencies: If multiple chunks import the same module, bundlers create shared chunks. Too many shared chunks increase HTTP request overhead.

Rule of thumb: Aim for 3-5 main chunks + vendor bundles. More than 10 chunks suggests over-splitting.

Lazy loading defers component registration until it’s needed. This is especially effective for components used conditionally or below the fold.

Load components when they enter the viewport.

// Register observer for lazy components
const lazyComponents = new Map([
['hx-data-table', () => import('@helixui/library/components/hx-data-table')],
]);
const observer = new IntersectionObserver((entries) => {
entries.forEach(async (entry) => {
if (entry.isIntersecting) {
const tagName = entry.target.tagName.toLowerCase();
const loader = lazyComponents.get(tagName);
if (loader) {
await loader();
observer.unobserve(entry.target); // Stop observing once loaded
}
}
});
});
// Observe all lazy component placeholders
document.querySelectorAll('hx-data-table:not(:defined)').forEach((el) => observer.observe(el));

When to use:

  • Components below the fold
  • Components in accordions/tabs (not visible on load)
  • Heavy components that aren’t critical

Load components when the user interacts with a trigger.

// Load modal component on button click
document.getElementById('open-modal')?.addEventListener('click', async () => {
await import('@helixui/library/components/hx-dialog');
const modal = document.createElement('hx-dialog');
modal.innerHTML = '<p>Modal content</p>';
document.body.appendChild(modal);
});

When to use:

  • Modals, dialogs, popovers
  • Admin-only components
  • Components used in specific workflows

Prefetch likely-needed components during idle time to avoid loading delay when user interacts.

// Prefetch modal component during browser idle time
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = '/dist/components/hx-dialog/index.js';
document.head.appendChild(link);
});
}

Strategies:

  • Prefetch: Low-priority background download for likely-needed resources
  • Preload: High-priority download for resources needed soon
  • ModulePreload: Preload ES modules and their dependencies
<!-- Preload critical component -->
<link rel="modulepreload" href="/dist/components/hx-button/index.js" />
<!-- Prefetch likely-needed component -->
<link rel="prefetch" href="/dist/components/hx-dialog/index.js" />

Minification removes whitespace, comments, and shortens variable names to reduce code size. HELiX uses esbuild for fast, efficient minification.

vite.config.ts
export default defineConfig({
build: {
minify: 'esbuild', // Fast minifier (default in Vite)
target: 'es2020', // Modern syntax = smaller output
},
});

esbuild vs. terser:

  • esbuild: 10-100x faster, good compression (~95% of terser)
  • terser: Slower, slightly better compression (~5% smaller)

HELiX uses esbuild for development speed. For production releases, consider terser for maximum compression:

export default defineConfig({
build: {
minify: 'terser',
terserOptions: {
compress: {
drop_console: true, // Remove console.log
drop_debugger: true, // Remove debugger statements
pure_funcs: ['console.log'], // Remove specific functions
},
mangle: {
toplevel: true, // Mangle top-level names
},
},
},
});

Example: hx-button component

VersionSize
Original source12.4 KB
Minified (esbuild)4.2 KB (66% reduction)
Minified + gzip1.8 KB (85% reduction)
Minified + brotli1.6 KB (87% reduction)
  • Whitespace removal: Spaces, tabs, newlines
  • Comment removal: All comments stripped
  • Variable mangling: Long names shortened (myVariableNamea)
  • Dead code elimination: Unreachable code removed
  • Constant folding: 2 + 24
  • Property mangling (advanced): Shorten object property names (risky)

Note: Property mangling is disabled by default because it can break code that relies on property name strings.

Compression encodes assets in a more efficient format for network transfer. All modern browsers support gzip and Brotli compression.

Gzip has been the standard HTTP compression algorithm for decades. It’s universally supported and provides good compression ratios.

Compression ratios (typical JavaScript):

  • Small files (<10 KB): 50-60% size reduction
  • Medium files (10-100 KB): 65-75% size reduction
  • Large files (>100 KB): 70-80% size reduction

Server configuration (Express example):

import compression from 'compression';
app.use(
compression({
level: 6, // Compression level (1-9, 6 is default)
threshold: 1024, // Only compress files >1KB
}),
);

Static pre-compression:

Terminal window
# Pre-compress files during build
gzip -k dist/**/*.js

Nginx configuration:

gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript;
gzip_comp_level 6;

Brotli is a newer compression algorithm from Google that achieves 10-25% better compression than gzip for text-based assets.

Compression ratios (JavaScript):

  • Small files: 55-65% reduction (5-10% better than gzip)
  • Medium files: 70-80% reduction (10-15% better than gzip)
  • Large files: 75-85% reduction (15-25% better than gzip)

Why Brotli is better:

  • Larger dictionary window (reduces redundancy)
  • Context-aware compression (better for HTML/CSS/JS)
  • Multiple compression levels (1-11)

When to use Brotli:

  • Static assets (pre-compress during build at level 11)
  • Modern browsers (all browsers since 2017 support Brotli)
  • CDN delivery (most CDNs support Brotli)

When to fallback to gzip:

  • Dynamic content (Brotli level 11 is too slow for on-the-fly compression)
  • Legacy browser support (use gzip as fallback)

Vite Brotli plugin:

Terminal window
npm install --save-dev vite-plugin-compression
vite.config.ts
import viteCompression from 'vite-plugin-compression';
export default defineConfig({
plugins: [
// Brotli compression
viteCompression({
algorithm: 'brotliCompress',
ext: '.br',
threshold: 1024,
compressionOptions: { level: 11 },
}),
// Gzip fallback
viteCompression({
algorithm: 'gzip',
ext: '.gz',
threshold: 1024,
}),
],
});

Output:

dist/components/hx-button/index.js (4.2 KB)
dist/components/hx-button/index.js.gz (1.8 KB) - 57% smaller
dist/components/hx-button/index.js.br (1.6 KB) - 62% smaller

Server configuration (Nginx):

brotli on;
brotli_types text/plain text/css application/json application/javascript;
brotli_comp_level 6; # Dynamic content
brotli_static on; # Serve pre-compressed .br files
  1. Pre-compress static assets: Use highest compression (Brotli 11, gzip 9) during build
  2. Dynamic compression: Use moderate levels (Brotli 4-6, gzip 6) for on-the-fly compression
  3. Cache compressed responses: Set Cache-Control and ETag headers
  4. Serve correct version: Serve .br to modern browsers, .gz as fallback
  5. Don’t compress small files: <1 KB files have more overhead than benefit
  6. Don’t double-compress: Don’t compress images, videos, or already-compressed formats

Bundle size limits are only effective if they’re continuously monitored and enforced.

HELiX uses GitHub Actions to enforce bundle size budgets on every PR.

Workflow configuration:

.github/workflows/bundle-size.yml
name: Bundle Size Check
on: [pull_request]
jobs:
bundle-size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install dependencies
run: npm ci
- name: Build library
run: npm run build --workspace=@helixui/library
- name: Analyze bundle size
run: |
# Check total bundle size
TOTAL_SIZE=$(du -sb packages/hx-library/dist | awk '{print $1}')
MAX_SIZE=$((50 * 1024)) # 50 KB
if [ "$TOTAL_SIZE" -gt "$MAX_SIZE" ]; then
echo "❌ Bundle size exceeds limit: $TOTAL_SIZE bytes > $MAX_SIZE bytes"
exit 1
fi
echo "✅ Bundle size OK: $TOTAL_SIZE bytes"

Monitor Core Web Vitals and performance scores on every deploy.

Configuration:

lighthouserc.js
module.exports = {
ci: {
collect: {
url: ['http://localhost:3150/'],
numberOfRuns: 3,
},
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.9 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
interactive: ['error', { maxNumericValue: 3000 }],
'total-blocking-time': ['error', { maxNumericValue: 300 }],
},
},
upload: {
target: 'temporary-public-storage',
},
},
};

GitHub Action:

- name: Run Lighthouse CI
run: |
npm install -g @lhci/cli
lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}

Automatically detect and report bundle size changes in PRs.

Using bundlesize:

Terminal window
npm install --save-dev bundlesize
package.json
{
"bundlesize": [
{
"path": "packages/hx-library/dist/components/hx-button/index.js",
"maxSize": "5 KB",
"compression": "gzip"
},
{
"path": "packages/hx-library/dist/index.js",
"maxSize": "50 KB",
"compression": "gzip"
}
]
}

GitHub Action:

- name: Check bundle size
run: npx bundlesize
env:
CI_REPO_OWNER: ${{ github.repository_owner }}
CI_REPO_NAME: ${{ github.event.repository.name }}
CI_COMMIT_SHA: ${{ github.sha }}
CI_BRANCH: ${{ github.ref }}

Track bundle size trends over time using the Admin Dashboard.

Metrics tracked:

  • Total bundle size (minified + gzipped)
  • Per-component bundle size
  • Shared chunk size
  • Dependency bundle size (Lit, tokens)
  • Historical trends (size changes over releases)

Location: http://localhost:3159/health → Performance section

Current bundle size metrics for HELiX (as of latest build):

FormatSize
Unminified156 KB
Minified (esbuild)52 KB
Gzipped18 KB
Brotli16 KB

Status: ✅ Under 50 KB budget (gzipped)

ComponentMinifiedGzippedBrotli
hx-button4.2 KB1.8 KB1.6 KB
hx-card3.8 KB1.6 KB1.4 KB
hx-text-input5.1 KB2.1 KB1.9 KB
hx-checkbox3.9 KB1.7 KB1.5 KB
hx-select4.8 KB2.0 KB1.8 KB
hx-alert3.2 KB1.4 KB1.2 KB
hx-badge2.8 KB1.2 KB1.1 KB

Status: ✅ All components under 5 KB budget (minified)

DependencySize (min+gz)Percentage
Lit core5.1 KB28%
@helixui/tokens1.8 KB10%
Component code11.1 KB62%

Note: Lit is externalized and only loaded once, even when using multiple components.

Import StyleBundle Size (min+gz)
Single component~8 KB (Lit + component)
Three components~12 KB (Lit + components)
Full library~18 KB (Lit + all components)

Tree-shaking savings: Importing only what you need saves ~55% vs. full bundle.

When building components for HELiX, follow these bundle size optimization practices:

  • ✅ Set sideEffects: false in package.json
  • ✅ Use ES module syntax exclusively (import/export)
  • ✅ Provide per-component entry points
  • ✅ Externalize Lit and workspace dependencies
  • ✅ Enable source maps for debugging (doesn’t affect bundle size)
  • ✅ Use esbuild minification for development, terser for production
  • ✅ Import only what you use (avoid import *)
  • ✅ Use Lit’s built-in directives (already tree-shakeable)
  • ✅ Avoid heavy dependencies (chart libraries, date pickers, etc.)
  • ✅ Lazy-load large assets (images, fonts) via CSS
  • ✅ Use CSS custom properties instead of inline styles
  • ✅ Keep component logic focused (single responsibility)
  • ✅ Enable code splitting for large applications
  • ✅ Pre-compress static assets (gzip + Brotli)
  • ✅ Set appropriate cache headers
  • ✅ Use CDN for static assets
  • ✅ Monitor bundle size in CI
  • ✅ Fail builds that exceed budgets
  • ✅ Lazy-load components below the fold
  • ✅ Use Intersection Observer for viewport-based loading
  • ✅ Prefetch likely-needed components during idle time
  • ✅ Defer non-critical components until after first paint
  • ✅ Measure real-world performance with RUM

If bundle size exceeds budgets, use this debugging workflow:

Terminal window
# Build with analysis
npm run build --workspace=@helixui/library
# Check component sizes
ls -lh packages/hx-library/dist/components/**/index.js
Terminal window
# Install bundle analyzer
npm install --save-dev rollup-plugin-visualizer
# Generate visual report
npm run build
open packages/hx-library/dist/stats.html

Look for:

  • Large dependencies (>10 KB)
  • Duplicate code across chunks
  • Non-tree-shakeable imports
// Create minimal test app
import '@helixui/library/components/hx-button';
// Build and measure
npm run build
// Expected: ~8 KB (Lit + hx-button)
// Actual: ??? KB

If actual > expected:

  • Check for barrel exports
  • Verify sideEffects: false
  • Ensure ES module syntax
// Measure component load time
const start = performance.now();
await import('@helixui/library/components/hx-button');
const end = performance.now();
console.log(`Load time: ${end - start}ms`);

Target: <50ms on desktop, <100ms on mobile

Terminal window
# Checkout main branch
git checkout main
npm run build
# Measure baseline size
BASELINE=$(du -sb packages/hx-library/dist | awk '{print $1}')
# Checkout PR branch
git checkout feature-branch
npm run build
# Measure PR size
PR_SIZE=$(du -sb packages/hx-library/dist | awk '{print $1}')
# Calculate delta
echo "Size change: $((PR_SIZE - BASELINE)) bytes"

Remember: Bundle size optimization is not a one-time task. Monitor metrics continuously, enforce budgets in CI, and treat regressions as bugs. Every kilobyte counts.