Back to Blog
engineeringperformance

191 Script Tags to 1 Bundle: Real Electron Startup Numbers

We ran 400 cold starts across 4 configurations to measure what bundling and startup optimization actually do. Median startup dropped 29.6%. Here are all the numbers.

Callipso TeamApril 1, 202613 min read

191 Script Tags to 1 Bundle: Real Electron Startup Numbers

VS Code completed a two-year migration from AMD modules to native ESM in October 2024 and reported "massive" startup improvement. They never published millisecond numbers. TypeScript's own migration to modules reported a 46% package size reduction but no startup delta. Slack published Chrome tracing methodology without concrete before/after comparisons.

We could not find a single Electron app that published real, reproducible, before-and-after startup benchmark data for this kind of optimization. So we did it ourselves: 400 cold starts across 4 configurations, same machine, same methodology.

The Setup

Callipso is an Electron app for terminal orchestration and voice routing. The renderer process loads a single HTML file — overlay.html — that contains the entire UI. No React. No Vue. No framework. 210 vanilla JavaScript modules, each wrapped in an IIFE, attaching to the window.Callipso namespace.

The HTML file is 3,602 lines long. It contains 191 sequential <script> tags and 61 <link> tags for CSS. Every module loads in a strict order — core infrastructure first, then managers, then feature modules, then state sync. The order is implicit. It lives in the HTML file, not in the code.

This is how the app shipped. This is how it grew through 1,500 commits. It works. But every script tag is a synchronous file read, followed by V8 parse and evaluate. We wanted to know: how much does that actually cost?

What Does Each Module Look Like?

Every one of the 210 renderer modules follows the same pattern:

javascript
window.Callipso = window.Callipso || {};
Callipso.clipboard = (function() {
    // Grab dependencies from the global namespace
    const events = Callipso.events;

    function sendClipboard() { /* ... */ }

    return { sendClipboard };
})();

The module puts its public API onto window.Callipso.clipboard and hopes that Callipso.events was already loaded by a script tag earlier in the HTML. There is no import statement. There is no dependency declaration. The only thing enforcing load order is the position of each <script> tag in overlay.html.

This pattern has a name: the revealing module pattern. It was standard practice in 2012. In 2026, every major Electron app has moved past it. VS Code uses ESM with esbuild. Obsidian bundles plugins via rollup. Discord and Notion use webpack or Vite. No serious Electron app ships 200 unbundled script tags.

But nobody has published what the actual performance difference is. Theory says bundling helps. We wanted numbers.

The Instrumentation

We added four timing markers to the Electron main process. The goal: break startup into segments so we know exactly where time is spent.

typescript
// Very first lines of main.ts — before any imports
const STARTUP_T0 = process.getCreationTime?.() ?? Date.now();
const STARTUP_T1 = Date.now();

// ... 49 import statements ...

app.whenReady().then(async () => {
    const STARTUP_T2 = Date.now();
    logInfo('Startup',
      `process-create → app-ready: ${STARTUP_T2 - STARTUP_T0}ms`);

    // ... bootstrap phases: orphan cleanup, health check,
    //     DI container, adapters, HTTP server, handlers ...

    mainWindow = createMainWindow(/* ... */);

    mainWindow.webContents.once('did-finish-load', () => {
        const STARTUP_T3 = Date.now();
        logInfo('Startup',
          `TOTAL process-create → renderer-ready: ${STARTUP_T3 - STARTUP_T0}ms`);
        logInfo('Startup',
          `Breakdown: electron=${STARTUP_T1 - STARTUP_T0}ms` +
          ` | imports=${STARTUP_T2 - STARTUP_T1}ms` +
          ` | bootstrap+render=${STARTUP_T3 - STARTUP_T2}ms`);
    });
});

The Four Markers

MarkerAPIWhat It Captures
t0process.getCreationTime()OS process birth — before any JavaScript runs. This is an Electron-specific API that returns the millisecond the OS created the process.
t1Date.now() at first lineFirst line of main.ts executes. The gap t1 - t0 is pure Electron/Chromium bootstrap — loading V8, initializing the browser engine.
t2Date.now() inside app.whenReady()App is ready. The gap t2 - t1 is our 49 import statements being resolved and executed.
t3Date.now() inside did-finish-loadRenderer has loaded overlay.html and all scripts. The gap t3 - t2 includes our bootstrap phases plus the renderer loading and executing scripts.

Three Segments

These four markers divide startup into three segments:

|-- Electron --|-- Imports --|-- Bootstrap + Render -------------|
t0             t1            t2                                  t3
(process       (first JS     (app ready)     (overlay.html +
 created)       line)                          scripts done)

Methodology

We followed VS Code's performance measurement approach, scaled up to 100 runs per configuration:

  • 100 runs per configuration, 400 total. 10 runs showed too much variance to draw reliable conclusions. 100 runs give stable percentile distributions.
  • Clean desktop. All other applications closed before each benchmark set. No browser, no IDE, no background processes competing for CPU or disk.
  • Kill between runs. The process is killed and restarted for each measurement. A 3-second pause between runs allows the OS to reclaim resources.
  • Apple Silicon Mac. All 400 measurements on the same machine, same session.
  • Same binary. The bundled and unbundled benchmarks use the same compiled app. The only difference: whether overlay-bundled.html (1 script tag) or overlay.html (191 script tags) is present. The app checks at startup and loads whichever exists.

We did not clear V8 code cache between runs. This means we are measuring warm-cache startup, which is what users experience after the very first launch.

Two Optimizations

We tested two independent changes and measured them in isolation:

1. Bundling the renderer

An esbuild script reads all 182 IIFE scripts from overlay.html in their original order, wraps each in a try/catch for error isolation, and concatenates them into a single dist/overlay-bundle.js. Same code, same execution order, same global namespace pattern. The only change is that Chromium reads 1 file instead of 182.

No source files were modified. No modules were rewritten. The bundle is a build artifact.

2. Deferring the version gate

Callipso checks a server manifest at callipso.dev/api/version on every launch to detect deprecated or yanked versions. Originally, this HTTP call blocked startup with await — the window was not created until the response arrived or the 5-second timeout expired.

We moved it to fire after the window's did-finish-load event. The user sees the UI immediately. If the version is deprecated, the warning appears a moment later. Same safety, no startup penalty.

typescript
// Before: blocks window creation
const versionGate = await checkVersionGate();
mainWindow = createMainWindow(/* ... */);

// After: window loads first, check runs in background
mainWindow = createMainWindow(/* ... */);
mainWindow.webContents.once('did-finish-load', () => {
    checkVersionGate().then(versionGate => {
        if (versionGate.warned) {
            mainWindow.webContents.send('version-warning', { ... });
        }
    });
});

The Results: 400 Runs Across 4 Configurations

MetricOriginal+ Deferred gate+ Bundled+ Both
Best1,735ms1,557ms1,628ms1,520ms
P101,810ms1,572ms1,655ms1,529ms
Median2,202ms1,585ms1,706ms1,550ms
Average2,549ms1,605ms1,786ms1,564ms
P903,885ms1,644ms1,990ms1,606ms
Worst4,257ms2,201ms3,631ms1,908ms
P10–P90 spread2,075ms72ms335ms77ms

Each optimization isolated

OptimizationMedian impactWhat changed
Deferring version gate-617ms (-28.0%)1 line: moved await to .then() after window load
Bundling 182 scripts-496ms (-22.5%)100-line build script, zero source changes
Both combined-652ms (-29.6%)Median: 2,202ms → 1,550ms

What the Numbers Tell Us

The version gate was the biggest single win. Moving one await keyword — removing a blocking HTTP call from the startup path — dropped the median by 617ms and collapsed the P10-to-P90 spread from 2,075ms to 72ms. The variance was never about file loading. It was about a network call with unpredictable latency blocking the entire startup sequence.

Bundling is a clean, consistent improvement. With the version gate deferred, bundling still adds a 35ms median improvement (1,585ms to 1,550ms). The benefit is modest in absolute terms but it is reliable — it appeared in every percentile across 100 runs. More importantly, the bundled version has a tighter worst case (1,908ms vs 2,201ms).

The combined effect is not additive. Deferring the gate saves 617ms. Bundling saves 496ms. Together they save 652ms — not 1,113ms. This makes sense: the version gate's variance was amplified by I/O contention from 182 concurrent file reads. Remove either the contention (bundling) or the concurrent request (deferring), and you eliminate most of the variance. Removing both gives diminishing returns on top.

The P90 tells the real story. The original P90 was 3,885ms — nearly 4 seconds. One in ten launches felt broken. The final P90 is 1,606ms. Every launch now feels responsive. This is the metric that matters for user experience, not the best-case.

How Does the Bundler Work?

The bundler is a 100-line Node.js script using esbuild. It runs at build time, after TypeScript compilation:

javascript
// Extract script paths from overlay.html in load order
const scriptPaths = extractScriptPaths(htmlPath);

// Concatenate with per-module error isolation
const parts = scriptPaths.map((p, i) => {
    const source = readFileSync(join(projectRoot, p), 'utf-8');
    return `// ===== ${p} =====\ntry {\n${source}\n} catch (e) {\n  console.error('Failed:', e);\n}`;
});

// Single esbuild pass — no bundling needed, just emit one file
await esbuild.build({
    entryPoints: [entryPath],
    outfile: 'dist/overlay-bundle.js',
    bundle: false,
    format: 'iife',
    platform: 'browser',
    sourcemap: true,
});

Each module is wrapped in a try/catch so a failure in one module does not prevent subsequent modules from loading — the same error isolation that the app's module loader provides at runtime.

The app's createWindow.ts checks for the bundled HTML at startup:

typescript
const bundledHtml = path.join(__dirname, 'overlay-bundled.html');
const sourceHtml = path.join(__dirname, '../overlay.html');
const htmlPath = fs.existsSync(bundledHtml) ? bundledHtml : sourceHtml;
window.loadFile(htmlPath);

Development mode uses the individual scripts (easier to debug). Production uses the bundle.

Can I Reproduce This?

If you have an Electron app with many <script> tags, the instrumentation is four lines:

typescript
const T0 = process.getCreationTime?.() ?? Date.now();
const T1 = Date.now();
app.whenReady().then(() => { /* T2 = Date.now() */ });
mainWindow.webContents.once('did-finish-load', () => { /* T3 = Date.now() */ });

Run your app 100 times, collect the T3 - T0 values, compute percentiles. Then concatenate your scripts into one file and run again. The comparison will show whether your app has the same I/O contention pattern we found.

Two things to check first:

  1. Is there a blocking network call in your startup path? Audit everything between app.whenReady() and your first window.loadFile(). Any await fetch(), await net.request(), or DNS resolution will dominate your startup time and mask other improvements. Defer it.
  2. How many files does your renderer load? Open DevTools, go to the Network tab, reload. Count the requests. If it is under 20, bundling will not help much. If it is over 100, you are likely paying a real I/O penalty.

What We Did Not Do

We did not convert the 210 modules to ES modules with import/export. That migration — estimated at 3,000-6,000 lines of diff — would unlock tree-shaking (removing dead code), explicit dependency graphs, and TypeScript in the renderer. Those are real benefits, but they are separate from the file-count reduction we measured here.

The improvement we measured comes from two changes: removing a blocking network call from the startup path, and reducing 182 synchronous file reads to 1. Neither required modifying application source code. The version gate deferral was a one-line change. The bundler is a build-time concatenation script.

What Is Next

The concatenation approach is a quick win, but it leaves the IIFE/global namespace pattern intact. The next step is converting the 210 modules to ES modules — replacing window.Callipso.X = (function() { ... })() with import/export. This would enable tree-shaking (the bundler can remove functions that are never imported), TypeScript in the renderer (currently only the main process is typed), and explicit dependency graphs (no more hoping the right script tag loaded first).

We expect modest additional startup improvement from tree-shaking, but the bulk of the loading gain is already captured. The ES module migration is primarily a maintainability investment — and we will measure that too.

Share: