DEV Community

Cover image for From 10 Seconds to Under 1: How I Made a Site 10x Faster
Isaque Silva Chaves
Isaque Silva Chaves

Posted on

From 10 Seconds to Under 1: How I Made a Site 10x Faster

How I Cut 60% of the Bundle and 76% of the Boot CPU of a Large Web App

In production, the home page of the app took more than 10 seconds. Now it takes under 1 second.

No single trick did that. I cut the JavaScript and CSS that every page downloads from 6,070 KB to 2,434 KB gzip, and the code that runs before the first paint from 6.1 s to 1.5 s of CPU. I moved more than 1,000 templates out of the main bundle and loaded each one with its route. Then I served the lazy code from a CDN I designed, in chunks built from the module graph.

In this article I'll walk through how I found where the time went, the tools I built to measure it, each cut with its numbers, the lazy-loading patterns I kept, the traps that broke features, and what the CDN changed.

The case: a large web app with about 1,150 client templates, React panels inside them, and about 9,000 lazy modules. It runs on Meteor 2.16. Some limitations of this work come from Meteor, and I'll name them. The method does not depend on Meteor.

The Result

Home page in production Before After
Time until the page starts to show 12.6 s 0.8 s

I measured the "after" number in production, after the CDN release, logged in: 0.89 s at the median of five loads.

The other numbers in this article come from the lab, and they are higher than the production number. That is expected. The lab starts every load with an empty cache, so the browser downloads and compiles all the code each time. It runs on a local server, with no CDN and no HTTP/2. Its timings count until the largest element of the page appears. A second lab profile also slows the network to Fast 3G and the CPU by 4x, close to what a slow phone gets. I used the lab only to compare two versions of the code with the same test.

What every page downloads. The measurement is deterministic, so these numbers are exact.

Eager bundle, gzip Start End Change
JavaScript 5,666 KB 2,042 KB −64.0%
JavaScript + CSS 6,070 KB 2,434 KB −59.9%

A cold load of /home in the lab. Median of five loads. I measured both columns on the same day.

Measurement Start After the view tree Change
Modules in the main bundle 11,177 5,041 −54.9%
CPU spent running modules before the first paint 6,113 ms 1,454 ms −76.2% (4.2x)
Longest task 6,476 ms 2,053 ms −68.3% (3.2x)
First Contentful Paint 8,704 ms 2,900 ms −66.7% (3.0x)
Largest Contentful Paint 8,988 ms 4,068 ms −54.7% (2.2x)
LCP, 5 core routes, one load each 9.6–10.8 s 3.9–4.8 s −53% to −62%

The timings come from the view-tree commit. Three later cuts took another 352 KB gzip out of the JavaScript, and I did not measure their timings in the lab. The bundle table includes them.

Where the Time Went

Before I had a profile, I did two rounds of bundle work: the error-tracking SDK, a second copy of pdf.js, a timezone library, and a few feature routes. They took 622 KB gzip out of the JavaScript. On Fast 3G with a 4x slower CPU, the median LCP of the five core routes fell by 11 s. On localhost, the LCP of /home went from 8,684 to 8,172 ms, and the ranges of the two builds overlapped. Fewer bytes helped the slow network. They did not shorten the wait on a fast one.

So I measured where the time went. The first runtime measurement was a timeline of one cold load of /home, on the build after those two rounds:

Moment Time
The HTML arrives 21 ms
The CSS arrives 181 ms
The JavaScript (5,043 KB gzip, 23,189 KB raw) downloads 59–1,322 ms
One long task: the bundle parses and runs 1,432–6,747 ms
The WebSocket opens 6,878 ms
First Contentful Paint 6,884 ms
Largest Contentful Paint 7,724 ms

On the five core routes of that build, the LCP was 7.7 to 9.8 s, and the shape was the same on every route. The network delivered the bundle in 1.3 s. Then the tab spent 5.3 s in one long task, with nothing on the screen. The first paint came 6 ms after that task ended. No data request could start before it, because the code that opens the socket was inside the same bundle.

Two more numbers explained the size of the problem:

  • Only a third of the code ran. V8 coverage on /home showed 7.7 MB of the 23.1 MB of eager JavaScript in use. The browser downloaded and decompressed the other 15.4 MB, and V8 still pre-parsed it, but none of it ran.
  • A slow network made it extreme. On Fast 3G with a 4x slower CPU, the LCP of the five core routes was 51 to 55 s.

So the problem had two parts: too many bytes, and too much CPU at boot. They turned out to be separate problems, with separate fixes.

Measure First: Three Small Tools

The usual tools gave me wrong answers, so I wrote three small scripts.

1. Exact bytes per module. A bundle visualizer that reads the source map puts the bytes of generated code (the compiled templates, for example) on a neighboring file, because the map has no entry for them. measureEagerBundle.js parses the module tree of the built bundle instead, so each module gets its exact byte span. It is deterministic: two builds of the same code give the same numbers.

2. CPU per module. A sampling profile said that one file, the one that creates the app's 112 database collections, cost 2,160 ms. That was wrong. The profile reads positions through the same source map. measureModuleTime.js replaces the module installer before the bundle runs and wraps every module function with a timer. The real cost of that file was about 280 ms. This is the core of it, simplified:

// Runs before the bundle (Playwright addInitScript).
// Meteor gives every module tree to one global installer.
// The hook wraps each module function with a timer.
const stack = [];
window.__moduleTimes = [];

function wrapTree(tree, prefix) {
  const out = Array.isArray(tree) ? [] : {};
  for (const [key, value] of Object.entries(tree)) {
    const modulePath = prefix ? `${prefix}/${key}` : key;
    if (typeof value !== 'function') {
      out[key] = value && typeof value === 'object' ? wrapTree(value, modulePath) : value;
      continue;
    }
    out[key] = function timedModule(...args) {
      const frame = { child: 0 };
      stack.push(frame);
      const started = performance.now();
      try {
        return value.apply(this, args);
      } finally {
        const elapsed = performance.now() - started;
        stack.pop();
        // The parent's own time does not include the time of its children.
        if (stack.length) stack[stack.length - 1].child += elapsed;
        window.__moduleTimes.push({ path: modulePath, self: elapsed - frame.child });
      }
    };
  }
  return out;
}

let wrapped;
Object.defineProperty(window, 'meteorInstall', {
  configurable: true,
  get: () => wrapped,
  set(realInstall) {
    wrapped = (tree, options) => realInstall(wrapTree(tree, ''), options);
    Object.assign(wrapped, realInstall);
  },
});
Enter fullscreen mode Exit fullscreen mode

No source map is involved. One rule made the report useful: a large number means "this module is the door to an expensive package". npm packages go through a separate copy of the installer, so they have no row of their own. Their cost goes to the first app module that requires them.

3. The doors. edges.js walks the static import graph from every file that loads on every page, and stops at each dynamic import(). It answers two questions: which npm packages a page can reach, and which eager file is the first import on the chain to a module. I call that file a door. Close the door, and everything behind it leaves the bundle.

node scripts/perf/edges.js "$PWD" npm:material-react-table   # the eager importers of one package
node scripts/perf/edges.js "$PWD" <directory>                # the chains that reach a folder
Enter fullscreen mode Exit fullscreen mode

For page timings, one run was useless. Five cold loads of the same build varied by up to 940 ms on localhost. I took the median of five loads per side, and I used Fast 3G with a slower CPU when I needed to separate two results.

The Cuts, One Door at a Time

First, every step on one table. The bytes are exact. The timings are cold loads of /home on the same machine, with the same script and the same account.

Step Eager JS, gzip Saved Module evaluation Longest task LCP
Start 5,666 KB — 6,113 ms 6,476 ms 8,988 ms
1. Error-tracking SDK, second pdf.js, timezone library, feature routes 5,044 KB −622 KB 5,319 ms 5,772 ms 8,172 ms
2. Video recorder 5,014 KB −30 KB 4,290 ms 4,438 ms 7,028 ms
3. Table library 4,712 KB −302 KB 3,516 ms 3,534 ms 5,708 ms
4. Server code, date pickers, AI chat panels 4,496 KB −216 KB 2,712 ms 3,364 ms 5,828 ms
5. Content catalogue, video-call SDK 4,398 KB −164 KB¹ — 3,457 ms² 5,716 ms²
6. View tree 2,394 KB −2,004 KB³ 1,454 ms 2,053 ms 4,068 ms
7. Server-only reporting helpers 2,345 KB −49 KB — — —
8. Material UI off the first paint 2,107 KB −238 KB — — —
9. jQuery UI 2,042 KB −65 KB — — —

¹ A merge of new code from the main branch added 66 KB between steps 4 and 5, so the column moves by 98 KB, not 164 KB.
² Median of three loads. The other LCP and longest-task cells are medians of five.
³ Step 6 holds 24 commits. The move of the view tree is the largest.

I measured the start row and step 6 on the same day. I measured steps 1 to 5 on two earlier days, when the start build had an LCP of 8,684 ms. Read the timing columns as a trend, and the byte column as exact. The table counts JavaScript only. Step 1 also cut the CSS from 404 KB to 271 KB, a revert of that CSS work came back with step 6, and the CSS is now 392 KB.

Two things stand out. Step 2 saved 30 KB and took 1 s off the boot CPU. Step 6 saved 2 MB, more than all the other steps together.

Before the profile: an error-tracking SDK (step 1)

The bundler cannot tree-shake, so a static import of @sentry/browser brought the SDK with replay, feedback and canvas recording: 916 KB minified. Twelve client files imported it.

I made one module the only door. It queues every call, and it loads the SDK in requestIdleCallback after the first route paints, with a 4 s limit. Errors raised before that go into a 20-entry buffer and reach Sentry when it starts. The Sentry packages left the bundle: −276 KB gzip.

A recorder that cost 1 second of CPU (step 2)

The CPU report put one module at the top: the app's video recorder component, at 1,032 ms. It was the first file to require react-media-recorder, which pulls standardized-audio-context. That library builds a class hierarchy when it loads. It is cheap to download and expensive to run.

Exactly one eager file imported the recorder, for a panel that renders behind a condition. I made that import lazy:

Cold load of /home, median of 5 Before After
FCP 7,276 ms 6,256 ms (−14.0%)
LCP 8,172 ms 7,028 ms (−14.0%)
Longest task 5,772 ms 4,438 ms (−23.1%)

The bundle barely moved: 5,044 KB to 5,014 KB gzip. Boot CPU and bundle size are separate problems. A byte budget alone would never have found this.

A table library behind 22 doors (step 3)

The next module was the app's table wrapper, at 795 ms, the door to material-react-table. Making that one file lazy did not help: 37 modules imported the package, through 22 different doors. Most of them were template helpers that returned a React component at module scope. Each one became a lazy component.

Result: −302 KB gzip, −774 ms of module evaluation. After the recorder and the table, the longest task on /home was 42.7% shorter than at the start.

Server code that ran in the browser (step 4)

Shared code had if (Meteor.isClient) return; at the top, but it still imported the server's event store. Those modules built a validation schema when they loaded: 234 ms of CPU in the browser, for code that never runs there. A function-level require() stopped the CPU cost, but the bundler still found the literal and kept the bytes.

The fix that removes the bytes too is a registry: the shared code has an empty slot, and a server-only file fills it.

// Shared code: the slot.
let eventStore: EventStore | null = null;

export function registerEventStore(store: EventStore): void {
  eventStore = store;
}

export function getEventStore(): EventStore {
  if (!eventStore) {
    throw new Error('The event store is not registered.');
  }
  return eventStore;
}
Enter fullscreen mode Exit fullscreen mode
// server/registerEventStore.ts: the only import of the real module.
registerEventStore({ append });
Enter fullscreen mode Exit fullscreen mode

A catalogue that the header loaded for one field (step 5)

The header called a hook, the hook called a helper, and the helper read a content catalogue of 43 items with all their text. It used one field of each item: the _id.

I moved the 43 ids to a small file, and I kept the full catalogue lazy. A unit test compares the two lists, so a new item that reaches only one file fails the build. Result: −160 KB gzip, and a feature folder fell from 60 eager modules to 12.

A video-call SDK that loaded on every page (step 5)

The chat panel sits in the default layout, and its onCreated hook loaded the video-call SDK: an 843 KB gzip chunk, on every page, for every user. Nobody had started a call.

Now the SDK loads when a chat window holds a live video. For one screen that needs it right after a click, the download starts at the click, while the modal opens and the server creates the session. 843 KB gzip stopped loading on every page.

Smaller doors

Door What it cost
A date picker package imported through its root barrel 73 KB gzip, 260 ms of CPU across 3 files
AI chat panels in 5 template helpers the door to a charting library, 97 KB gzip
A PDF viewer package that listed pdf.js twice 406 KB minified, one copy ran and exported nothing
moment-timezone from npm next to a global moment 763 KB minified, for one component

The big one: templates that load with their route (step 6)

Each fix above closed one door. The bundle was still about 4.4 MB gzip, because the framework loads every file under client/ on every page, whatever the imports say. More than 1,000 templates lived there.

The key observation: the templating layer finds a template by name when it renders, not when the file loads. So a route can wait for a chunk, and then render any template inside it. Only the templates that render on every page must load with the page: 135 of 1,148.

I moved the view tree to a lazy folder. A list of 155 files loads with the page. The other 604 files load in a route chunk. Each route group waits for its chunk:

const adminArea = createRouteChunkLoader({
  importChunk: () => import('/imports/features/admin/ui'),
});

const adminRoutes = FlowRouter.group({
  name: 'adminRoutes',
  waitOn: adminArea.waitOn,
  whileWaiting: adminArea.whileWaiting,
});
Enter fullscreen mode Exit fullscreen mode

The helper exists for one reason. The router calls whileWaiting on every route run, and a query parameter change is a route run. A plain waitOn showed the loading screen on each change, so the page lost its state. The helper shows the loading screen only while the chunk is on the way.

This table compares the start with step 6:

Measurement Start After the view tree
Eager JavaScript, gzip 5,666 KB 2,394 KB (−57.8%)
Modules in the bundle 11,177 5,041 (−54.9%)
npm packages that a page can reach 57 40
Module evaluation on /home 6,113 ms 1,454 ms (−76.2%)
Longest task on /home 6,476 ms 2,053 ms (−68.3%)
LCP on /home, median of 5 8,988 ms 4,068 ms (−54.7%)
Share of the eager code that runs on /home 34.6% 50.2%

On the five core routes, LCP fell between 53% and 62% in the lab, and between 42% and 46% on Fast 3G with a 4x slower CPU. The longest task fell between 68% and 75%.

Three more doors after the view tree (steps 7 to 9)

  • Reporting helpers that only the server calls. 48 files moved to a server folder, which never enters the client bundle: −49 KB gzip.
  • Material UI on the first paint. Four static imports still carried it: a barrel file of the component library that 37 files read for the theme, the entry of the confirmation dialog, a menu with its own table, and a demo page. The barrel went away. The dialog entry now loads the dialog on its first call, so its 131 call sites did not change. Result: −238 KB gzip, and the Material UI components in the bundle fell from 73 to 33. The 33 that stay belong to the header, which paints on every page.
  • jQuery UI. The file (247 KB raw) sat in an eager folder, and no file on the first-paint path called a widget. It moved to a lazy folder, and the 67 modules that call a widget import it: −65 KB gzip.

After the Bundle: the Data Waterfall

A smaller bundle moved the first paint earlier, and then the data became visible. After the long task, the page opened its socket and sent 28 subscriptions for 109 documents. The payload was small. The cost was the number of round trips and their order.

Only 3 of the 28 held the first paint. That fact told me which fixes would move LCP and which would not:

  • Duplicate subscriptions. Two call sites asked for the same data, so the server ran two publications twice. I gave each subscription one owner: 28 became 26. LCP did not move. I had predicted that, because the duplicates did not hold the paint. The page still did less work.
  • A 3-second timer from 2018. Six subscriptions waited for a setTimeout of 3,000 ms that no longer had a purpose. They held 80 of the 109 documents. Without the timer, notifications were ready 506 ms after the first subscription message, instead of 3,042 ms. LCP did not move here either: none of the six held the paint.
  • A blocking waitOn. / and /home waited for a full publication before they rendered anything, and the same data was already on its way through another subscription. I removed the wait.
  • A publication that read a whole user document. One of the 3 that hold the paint read the full user document, which grows with every login session, to get one field. With a projection it went from 126 ms to 18 ms. The 3 run in parallel, so the gate went from 126 ms to 56 ms, a gain too small for the lab to show in LCP.

Cutting requests that do not hold the paint does not move LCP. Find the requests that do, and make them shorter.

Patterns I Kept

A React panel that loads on its first render

// Rendered only when its panel opens, so it loads on demand.
const SecurityPanelLazy = lazyComponent(
  () => import('/imports/features/account/ui/SecurityPanel'),
  'SecurityPanel',
);
Enter fullscreen mode Exit fullscreen mode

lazyComponent shows a spinner until the module arrives. When the download fails, it renders nothing, reports the failure, and shows a toast that offers a reload.

Code that runs after a click

'click button[data-action="create-project"]'(event, template) {
  // The handler stays synchronous: the template layer drops the promise
  // of an async handler, so a failed chunk would leave the button dead.
  import('/imports/features/projects/ui/CreateProjectDialog')
    .then(({ showCreateProjectDialog }) => showCreateProjectDialog(userId))
    .catch(error => {
      reportError(error, { context: 'CreateProjectDialogLoadFailed' });
    });
},
Enter fullscreen mode Exit fullscreen mode

A library that loads at the moment of use

let chartPromise;

export const loadChart = () => {
  if (!chartPromise) {
    chartPromise = import('chart.js').then(({ default: Chart }) => {
      Chart.defaults.global.pointHitDetectionRadius = 1;
      return Chart;
    });
    // A failed download clears the promise, so the next chart tries again.
    chartPromise = chartPromise.catch(error => {
      chartPromise = undefined;
      throw error;
    });
  }
  return chartPromise;
};
Enter fullscreen mode Exit fullscreen mode

Every caller waits for the same download, and the global setup of the library runs once. After the await, check that the component still exists.

A small index of large data

Keep the part that eager code reads in a small eager file, keep the full data lazy, and add a test that fails when the two differ. I used this for the content catalogue and for a list of program definitions.

Traps That Broke Features

A lazy load changes when code runs. Each of these broke a feature during the work:

  • A side-effect module stopped running. A file that registers custom validators moved to a lazy folder, and only the server imported it. Eight forms failed validation with Expected undefined, got string.
  • A global stopped existing. Seven files assigned helpers to a global namespace and moved with no importer. Creating a form failed with ... is not a function.
  • The CSS cascade changed. A lazy stylesheet loads last, so its rules win. One component stylesheet imported the global styles and restyled every button.
  • A route waited for its own chunk. A redirect module sat inside the chunk that waits for a route, and the route waited for the redirect module. The page stayed on the loading screen.
  • Two modules changed order. Both read a value that clears when it is read. A deep link after a login landed on the home page.
  • A pre-commit hook rewrote a vendor file. eslint --fix changed var to let in a minified jQuery UI file, and every view that used it failed to load.
  • A moved file left an import on the old path. Neither ESLint nor the build reported it. The browser threw Cannot find module only when the import ran.

The last one is the most dangerous, because nothing fails before production. I test every changed page three ways: a cold deep link in a private window, a hard reload, and an in-app navigation, then again on Fast 3G.

The CDN

Lazy loading moved cost from boot to navigation. After the view tree left the bundle, /home asked for its route code in one request to the app server: 1,021 KB, between the first paint at 2,900 ms and the LCP at 4,068 ms. The same request had a second problem. After a deploy, the server answered with the code of its new build, so an open tab could receive modules from another build.

I moved the lazy modules to a CDN with versioned names, so every tab reads the modules of its own build. The full design will be in my next article. These are the parts that matter for speed:

  • Chunks from the module graph. The first version made one request per module. /home needs 2,591 lazy modules, so it made about 2,600 requests. In a sandbox, a public route with about 2,000 modules took 9.4 to 9.9 s through CloudFront, against 1.7 to 2.1 s through the app server. So I grouped the modules with the rules of Rollup, webpack and Turbopack. On a production build, /home now loads its 2,591 modules in 24 chunks, plus one index. The chunks carry 1.0% more bytes than the modules that /home needs.
  • A cache per module. Meteor's dynamic-import package already stores each module in IndexedDB under its version. With the CDN, that cache holds only modules of the right build. A second visit to the same build makes one request: the index of the build.
  • Small deploys stay small. After a deploy, the browser plans each chunk: it downloads the whole chunk only when the requests that it saves cost more than its unused bytes. The modules that did not change come from the local cache.
  • Edges and HTTP/2. CloudFront serves the chunks from an edge near the user, with Brotli and up to 128 parallel streams on one connection.
  • A preload that I removed. Preloading the chunks from the HTML gained 0.31 s, and cost about 12 s when the preload failed.

Not in production yet. A 30 s total timeout per request aborted the largest chunk of /home (4.3 MB before compression) on a 3G profile while its data was still arriving. The fix aborts a request only after 10 s with no data on any CDN request.

Keeping It Fast

A bundle that shrinks by 60% grows back if nobody watches it. So I wrote down 13 ways the bundle grew back, each with an example from the app's history: a new file in an eager folder, one static import into a large tree, a barrel import, a download from code that runs on every page, server code in shared folders, large data in an eager file, a second copy of a library, generated CSS, and others.

The contribution rules of the project now point to this list. A change that adds a file to an eager folder, or a dependency to client code, starts with a read of it. A release gate in CI also measures the JavaScript that / downloads on a staging server, and it fails above a budget of 2,042 KiB.

The Limitations

  • Lab and production are different setups. The lab numbers come from localhost with an empty cache and no CDN. The production "after" number is my own measurement for a returning user, on one account and one machine, not a p75 of all users.
  • A first visit to a route waits for its chunk. Lazy loading moves cost from boot to navigation. A failed chunk needs a recovery path, and every page needs a test on a slow network.
  • Meteor 2 loads every file under client/ and both/ on every page. The meteor.mainModule option turns that off, and this app does not use it. In an app of this size, every file needs an importer before that option can work, which is the same work as the view-tree move. There is no tree shaking either, so every cut needed a move, not only an import().
  • A require() inside a function still bundles the module. The bundler reads the literal. Only a registry slot removes the bytes.
  • Each lazy chunk grows the version manifest. The main bundle carries the version of every lazy module. It grew from 152 KB to 556 KB raw during this work, and it is now 288 KB gzip, 14% of the eager JavaScript.
  • Some weight is still on every page. The header keeps 33 Material UI components, 128 KB gzip of Material UI in all. The icon font stylesheet blocks the first render, and /home downloads 453 KB of fonts. About 495 method stubs in a shared folder still reach the browser.
  • The home chunk is still large. /home asks for the chunk of the whole authenticated area, not only for the templates that it renders.

Conclusion

In production, the home page went from more than 10 seconds to under 1 second for a returning user. In the lab, the same work cut 60% of the bytes and 76% of the boot CPU. These are the lessons I keep:

  • Measure bytes and CPU separately. A 1 s CPU cost hid behind a library that was cheap to download.
  • Find the doors, not the files. One static import from an eager file can bring a whole package. Close the door, and the package leaves.
  • Build your own measurements when the usual tools are wrong. Source maps put bytes and CPU on the wrong files. Five runs per side, not one.
  • The large win was structural. The fixes to single doors saved tens to hundreds of KB each. Moving the view tree to its routes saved about 2 MB gzip.
  • Fix what holds the paint. Requests that do not hold the first paint do not move LCP.
  • Give the lazy code a short path too. Versioned chunks on a CDN, a cache that matches the build, and a few well-sized requests.
  • Guard it. A written list of the known regressions and a budget in CI keep the bundle where you left it.

Top comments (0)