A technical deep-dive into Medal Engineering's journey from a 40MB+ Electron renderer bundle to just 2.7MB — using Vite, Rolldown, code splitting, and aggressive dead-code elimination. Real numbers, real code, and actionable techniques for your Electron app.
Medal is a desktop application built on Electron that lets gamers clip, edit, and share gameplay moments. When Rick Zhang joined the Medal Frontend team in 2024, the Electron renderer bundle was over 40MB. That's the JavaScript — not assets, not native modules — just the compiled frontend code that every user downloads and parses on every app launch.
A 40MB bundle means slow startup, delayed time-to-interactive, and a poor user experience on mid-range hardware. It also blocks feature iteration: every change required a full build and the team couldn't even see their code propagate to the screen during local development.
The team's CEO, Ken Colton, made the first decisive move: switch the local build to Vite for Hot Module Replacement. This was, in Rick's words, "the moment we discovered fire."
Rather than tackling individual bloated components head-on, Rick approached the problem from the outside in: macro, then micro. First fix the developer experience and build tooling, then delete dead code, then optimize what remains.
Medal migrated from a multi-repo setup with versioned component libraries to a PNPM monorepo. This eliminated version drift, merge conflicts, and the nightmare of knowing "what was shipped and when." Everything — components, hooks, utils, types — went into one repository with clear package boundaries.
With Vite powering local development, the team got instant HMR. Changes appeared on screen in milliseconds instead of tens of seconds. This was the foundation that made every subsequent optimization possible.
The team systematically identified and removed dead code. Rick used AI to correct import paths at scale and ensure every import was explicit — specifying the package, clear path, and file extension. This made it possible for tools to find truly dead code with confidence.
// Before: barrel imports that bundle everything
import { Button, Input, Modal, Dropdown } from '@/components';
// After: direct imports with file extensions
import { Button } from '@/components/Button/Button.tsx';
import { Input } from '@/components/Input/Input.tsx';
Direct paths and explicit file extensions let tree-shaking work properly. Relative imports remained, but barrel files were eliminated as a policy. Code consolidation across Electron and web (client-agnostic packages for utils, hooks, types) further reduced duplication.
Medal had been using Vite for local development but Rollup for production builds — two build systems, two configurations, two sources of truth. Unifying on Vite meant one build pipeline for both dev and production. More importantly, Vite's Glob Imports made code splitting dramatically easier.
Barrel files are index.ts files that re-export everything from a directory.
They seem convenient but they completely defeat tree-shaking: when you write
import { Button } from '@/components', the bundler doesn't know which of the
dozens of re-exported modules you'll actually use, so it bundles them all.
// Typical barrel file — bundles everything
export { Button } from './Button';
export { Input } from './Input';
export { Modal } from './Modal';
export { Dropdown } from './Dropdown';
export { Tooltip } from './Tooltip';
// ... 30+ more exports
Medal adopted a strict "death to barrel files" policy. Every import must specify the exact file path. This alone saved 2.6 MB — more than most small applications' total bundle sizes.
Sound alert files (SoundAlertsData) in .wav format were being bundled into the renderer. Medal externalized these files — loaded them at runtime instead of inlining them into the JavaScript bundle. This saved 3.4 MB without any code changes to the audio playback logic.
// Vite config — externalize asset files
export default defineConfig({
build: {
rollupOptions: {
external: [/\.wav$/],
},
},
});
Rollup (and Rolldown) can tree-shake ESM imports much more effectively than CommonJS.
CJS require() is dynamic and opaque — the bundler cannot statically analyze
what's being used. ESM import declarations are static and analyzable. Medal's
switch to ESM throughout the codebase unlocked an additional 4 MB in savings.
// CJS — bundler can't tree-shake
const lodash = require('lodash');
// ESM — bundler can eliminate unused exports
import { debounce } from 'lodash-es';
This was the single biggest win. Medal was bundling all translation files for every supported locale into the renderer. Users who only speak English were downloading French, German, Spanish, and a dozen other locale files.
import { defineAsyncComponent } from 'vue';
// Dynamic import — load translations only for the active locale
async function loadTranslations(locale: string) {
const messages = await import(`./locales/${locale}.json`);
i18n.global.setLocaleMessage(locale, messages.default);
}
Switching from static imports to dynamic import() for i18n files saved
13 MB — nearly a third of the original bundle. Each user now downloads
only their locale's strings. This is a technique every multi-language application should adopt.
Medal migrated from React Router v5 to v7 to enable proper route-level code splitting. With React Router v7's lazy route support, each page loads only its own components, hooks, and dependencies — nothing from other pages leaks into the initial bundle.
import { createBrowserRouter } from 'react-router-dom';
const router = createBrowserRouter([
{
path: '/',
lazy: () => import('./pages/Home'), // lazy-loaded
},
{
path: '/clips',
lazy: () => import('./pages/Clips'), // separate chunk
},
{
path: '/settings',
lazy: () => import('./pages/Settings'), // separate chunk
},
]);
The React Router v5 → v7 migration was a major refactor — it touched nearly every file in the routing layer — but it was the prerequisite for route-level splitting. Combined with Vite's automatic chunk splitting, this saved 5.4 MB.
With the bundle down to ~22 MB from these optimizations, Rick set his sights on Rolldown — a Rust-based bundler designed as a drop-in replacement for Rollup. Rolldown offers dramatically faster builds and better tree-shaking through its native compilation.
The migration wasn't plug-and-play. Some dependencies conflicted with Rolldown's bundling system and had to be cleaned up first. But after that cleanup, the switch to Rolldown took the renderer bundle from 22 MB down to 2.7 MB — an 88% reduction on top of all previous optimizations.
// rolldown.config.js
import { defineConfig } from 'rolldown';
export default defineConfig({
input: 'src/main.tsx',
output: {
format: 'esm',
dir: 'dist/renderer',
},
// Rolldown automatically tree-shakes better than Rollup
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx'],
},
});
After the optimization journey, Medal's frontend toolchain looks like this:
Local development with HMR
Production bundling (Rust)
Monorepo management
Unit and integration tests
Component library (migrated from Grommet)
Lazy route-based code splitting
Medal's journey is more than a feel-good story — every technique they used is applicable to existing Electron applications. Here's a practical checklist for your own bundle audit:
vite build --analyze
or source-map-explorer) to find the biggest contributors.index.ts files and replace
barrel imports with direct file imports.For a broader overview of how modern build tooling is evolving, see my hands-on guide to Vite+ Beta — the unified toolchain that integrates Vite, Vitest, Oxlint, and Rolldown into a single CLI. And for Electron-specific performance features coming to the platform itself, read about Electron 43 beta: startup improvements, caching, and LTO optimizations.
components/index.ts that re-exports every component). They defeat tree-shaking because bundlers cannot statically determine which specific exports are used — they include everything. Medal removed barrel files to save 2.6MB and adopted a strict no-barrel-files policy throughout the codebase.Source: "W-Key in Frontend: Synergizing Technology and Product" by Rick Zhang, Medal Engineering (June 23, 2026). Featured in JS Weekly #793 (July 7, 2026).
I help teams reduce bundle size, improve startup performance, and modernize their Electron build toolchain. Free initial consultation.