Cutting the Halodoc Website's First-Load JavaScript by 19% (and Its Main Bundle by 60%)

Measure the Eager Set, Not main.js: Cutting Angular First-Load JavaScript by 19%

Web Development Nov 13, 2026

At Halodoc, most people looking for a doctor or ordering medicine reach us on a phone, often over a mobile connection, so how much JavaScript a page needs before it responds matters. Most bundle-size advice starts and ends with main.js, but with Angular's esbuild-based builder that is only a small part of what loads first, and shrinking it can leave users downloading exactly the same bytes.

This post walks through a method that works: measure the whole first load, remove bytes one pattern at a time, and lock the result in with a budget. Applied to the Halodoc website, it took 19.3% off the JavaScript loaded before first render (20.5% gzipped) and made the main bundle 60% smaller. The same loop worked on two more Angular apps and two shared libraries, and at the end we share it as an open-source Claude Code skill.

Prerequisites: Angular 17+ with the esbuild-based application builder (the default for new projects since v17). The measurement and most fixes work on any of those versions. The SSR-specific fix in pattern 2, @defer (hydrate on idle), needs Angular 19+ with incremental hydration enabled (provideClientHydration(withIncrementalHydration())).

Why main.js Is the Wrong Number

With Angular's esbuild-based application builder, main-*.js is not the only file that loads up front. main imports other chunks statically, and the generated index.html preloads them with <link rel="modulepreload">. All of those download before the first render.

In our case, main was 308.4 KB raw out of 2,656.5 KB loaded up front: about 12%. That ratio is typical for a large esbuild-built app, and it creates two problems if main is the only number you watch:

  • It hides most of the cost. You optimize 12% of the download and ignore the rest.
  • It can improve without helping anyone. Move code from main into a chunk that still loads immediately, and main shrinks while the user downloads exactly the same bytes.

What you need instead is a view of the whole bundle that loads first, where every byte in it comes from, and a guard that stops removed bytes from coming back.

Why Existing Tools Fall Short

Before writing anything, it is worth checking what is already available. Two tools are common in Angular projects: webpack-bundle-analyzer and Angular's built-in build budgets. Here is how they compare against the four things this method needs:

What you need webpack-bundle-analyzer Angular budgets Metafile analyzer
Works with Angular's esbuild builder No, expects webpack stats Yes Yes, reads the metafile
Separates first-load chunks from lazy ones No Enforces a total, can't show what is in it Yes, from index.html, per chunk and per package
Attributes bytes to the package and the repo that owns it Package only No Yes
Proves a fix moved bytes, before vs after Manual No Yes, against a committed baseline

A treemap of every chunk doesn't tell you which ones the user waits for. Knowing a package is big doesn't tell you whether the fix is in your app or in a shared library another team owns. Angular's initial budget does measure the right total, which makes it the right tool for Step 3, but it can't tell you why the total is what it is. A small, dependency-free script that reads the esbuild metafile fills that gap.

How We Reduced It: Measure, Reduce, Lock In

The method has three steps: measure the whole bundle that loads first, remove what doesn't need to be there one pattern at a time, and lock the result in with a budget so it can't come back. Each step below explains what to do, how we did it, and what to watch for.

Step 1: Measure the Eager Set

We call everything the browser downloads before any lazy route loads the eager set. It is the number users actually pay on a first visit. Track it next to main, always raw and gzipped.

What the browser downloads before the first render Eager set (what users wait for) Lazy chunks (on navigation) index.html main-*.js<script> polyfills-*.js<script> chunk-*.jsmodulepreload chunk-*.jsmodulepreload chunk-*.jsstatic import chunk-*.jsstatic import Teleconsultation pageroute chunk Pharmacy pageroute chunk Swiperfirst carousel only Chat assistant@defer (hydrate on idle) import() <script>, modulepreload or static import: loads up front dynamic import(): loads when needed

ng build --stats-json writes an esbuild metafile listing every output file and what it imports. To find the eager set:

  • Start from the files the browser loads first: the <script> and modulepreload tags in the generated index.html.
  • Follow every static import. Skip dynamic import() calls, because those load later.
  • Attribute every byte reached to an npm package, using the size after tree-shaking and minification, and gzip each output file to get the transfer size.

One trap: Angular marks every lazy-loaded component as an "entry point" in the metafile. If you start from all entry points, the whole app looks eager. Starting from index.html avoids that, because it is exactly what the browser loads first.

The first run reported 2,656.5 KB raw / 684.8 KB gzip eager and 308.4 KB raw / 88.2 KB gzip for main. The raw eager figure matched an earlier measurement of the same repo made by a different method. The scripts are plain Node with no dependencies, in the web-bundle-optimizer folder on GitHub, so anyone can re-run the measurement:

"analyze:prod": "ng build --configuration=production --stats-json && npm run analyze:report",
"analyze:report": "node scripts/analyze-bundle.mjs --stats dist/stats.json --dist dist",
"analyze:compare": "node scripts/compare-bundle.mjs --baseline bundle-baseline.json --current bundle-report.json --md bundle-delta.md --metric eager-gzip"

Step 2: Reduce It, One Pattern at a Time

Almost everything we removed, across three apps and two libraries, fell into one of five patterns. They are common in Angular codebases, so each one below starts with the symptom to look for in yours.

1. Side-effect imports in main.ts

Symptom: a library is imported in main.ts only to register something globally.

A typical case is a carousel library that ships as web components: main.ts calls register() once, and the whole library joins the first load of every page. On the Halodoc website this put Swiper's 181.8 KB raw on every page, although only a few lazy pages show a carousel.

Fix: move the registration into a small loader that imports the library the first time a component needs it, and share that one import across every component on the page.

// app/utils/swiper-loader.ts
let pending: Promise<void> | null = null;

export function loadSwiperElement(): Promise<void> {
  // No custom-element registry on the server; the browser re-runs this after hydration.
  if (typeof customElements === 'undefined') {
    return Promise.resolve();
  }
  // Module-scoped so every carousel on the page shares one import, not one each.
  pending ??= import('swiper/element/bundle').then(({ register }) => {
    register();
  });
  return pending;
}

Each carousel waits for the loader before setting itself up:

// before
ngAfterViewInit() {
  if (this.utilityService.isBrowser()) {
    this.initializeSwiper();
  }
}

// after
ngAfterViewInit() {
  if (this.utilityService.isBrowser()) {
    void loadSwiperElement().then(() => this.initializeSwiper());
  }
}

customElements.define() upgrades elements that are already in the DOM, so markup rendered before registration still becomes a working carousel. Only the imperative setup has to wait.

What it costs: the bytes don't disappear for pages that use the library; they move from the first load to a second request. With server-rendered slide markup, the slides are visible immediately and become swipeable once the library arrives. Verify that the server-rendered HTML is unchanged, and if the carousel is above the fold, check for layout shift during the upgrade.

2. Libraries that don't tree-shake: all or nothing

Symptom: you remove most imports of a package and the bundle doesn't move.

Some libraries ship as a single module, so one remaining import pulls in the whole thing. In our case, an in-app chat assistant reached the first load through three imports: its chat shell, an instrumentation provider and an API service, 81.0 KB raw together. Removing two of the three would have saved nothing.

Fix: list every eager importer first, then remove them all in the same change. For a component that should still be server-rendered, @defer (hydrate on idle) keeps the server markup and downloads the code only when the main thread is idle:

@if (assistantRouteService.currentConfig(); as assistantConfig) {
  @defer (hydrate on idle) {
    <app-assistant-shell
      [contentKey]="assistantConfig.content_key ?? ''"
      [hasFooter]="assistantConfig.has_footer ?? false"
      [instrumentationSource]="assistantConfig.instrumentation_source ?? ''"
      [sourceContext]="pageContext()!"
    />
  }
}

Any service that still needs the package loads it with a dynamic import(). In a zoneless app, Angular doesn't wait for a plain dynamic import during server rendering, so register it as a pending task to keep the page from being serialized too early:

const configTaskDone = this.pendingTasks.add();
from(import('@org/assistant'))
  .pipe(
    switchMap(({ AssistantApiService }) =>
      this.injector.get(AssistantApiService).getConfigs(),
    ),
    take(1),
    takeUntilDestroyed(this.destroyRef),
    catchError(() => EMPTY),
    finalize(() => configTaskDone()),
  )
  .subscribe(/* ... */);

What it costs: a user who taps the component before the main thread is idle sees the server-rendered shell, and the first interaction waits for the download. On a page busy with third-party scripts, "idle" can be a few seconds away. That is a good trade for a secondary action, and a poor one for a primary call to action.

The all-or-nothing rule is easy to underestimate. In another app, a PDF viewer had three eager paths into the bundle. Closing two of them saved exactly 0.0%; the saving only appeared when the last path, through two shared libraries, closed. Always re-measure after each change rather than assuming partial progress.

3. Runtime enums and value imports

Symptom: a file only needs types from a package, but the package still ships.

Type-only imports are erased at build time, but only if the compiler knows they are type-only. Switching to import type fixes most cases. Enums are the exception: an enum exists at runtime, so one enum reference keeps the whole package loaded. In our case, a single enum used by a status ticker kept a 70.1 KB shared library in the first load. Comparing plain strings removed the last runtime reference:

// before
import { Article, Ticker, TickerType, TickerWrapper } from '@org/shared';
switch (ticker.ticker_type) {
  case TickerType.scheduled_system_maintenance:
  case TickerType.system_downtime:

// after
import type { Article, Ticker, TickerWrapper } from '@org/shared';
switch (ticker.ticker_type as string) {
  case 'scheduled_system_maintenance':
  case 'system_downtime':

Why the as string: ticker_type is declared with the library's enum type, and TypeScript rejects comparing an enum-typed value with a string literal (TS2367, "types have no overlap"). With import type the enum is no longer available as a value, so the switch widens the field to string once and compares plain literals.

The same pattern applies to value imports that are only needed occasionally. A shared authentication library imported a browser-fingerprinting package at the top of the file and initialized it in the constructor, so 34.5 KB raw loaded on every page of every app that used it. Loading it on first use keeps the public API unchanged:

import type { Agent, GetResult } from '@fingerprintjs/fingerprintjs';

private agentPromise?: Promise<Agent>;

// Memoised: concurrent callers share one dynamic import and one agent.
private loadAgent(): Promise<Agent> {
  if (!this.agentPromise) {
    this.agentPromise = import('@fingerprintjs/fingerprintjs').then(
      (module) => module.default.load(),
    );
  }
  return this.agentPromise;
}

What it costs: the API shape is unchanged, the timing is not. The first call on a page now includes one extra, cacheable request. Check who calls it first: if that caller already waits on a network round-trip (a login request, for example), the extra download overlaps work the user was already waiting for.

4. Libraries packaged as one chunk

Symptom: importing one small symbol from a library costs as much as importing all of it.

This looks like pattern 2, and the root cause is the same: one module behind the barrel. The difference is who can fix it. In pattern 2 the app can stop importing the library on the critical path. Here the app genuinely needs a small piece of the library on every page, so the fix has to be in the library's packaging.

"sideEffects": false and ESM output look like correct packaging on paper. But if the library's build emits everything as one internal chunk behind the barrel, there is only one module, and nothing for the bundler to drop. Don't trust the package settings; measure: bundle one symbol, bundle the whole barrel, compare. In our case, importing a scroll service and a two-value enum from a design-system library retained 325.4 KB raw, 100% of the barrel.

Fix: a secondary entry point gives the needed code its own module, and the root barrel re-exports it so no consumer breaks:

// templates/scrolling/src/public-api.ts  (new entry point)
export * from './scrolling.service';

// templates/src/public-api.ts  (root barrel keeps working)
export * from '@org/templates/scrolling';

// app.component.ts  (the app imports the subpath)
import { ScrollDirection, ScrollingService } from '@org/templates/scrolling';

One catch: the library release alone does not give the bytes back. The root barrel still re-exports everything, so any import from the root still retains it. Consumers must switch to the subpath. Re-measure after every upgrade, even when you're sure it will work. After the fix, the same import cost 1.8 KB raw.

5. Shared NgModule barrels in the root shell

Symptom: the app shell imports a "shared" module that re-exports far more than the shell renders.

A common setup is a MaterialModule that re-exports every Angular Material module the app might need, imported into the root through a SharedModule. In one of our apps it re-exported about 34 Material and 5 CDK modules, while the shell (toolbar, side menu, profile menu, snackbars and a few dialogs) rendered about a dozen.

Fix: the root imports exactly the modules its templates use, checked one by one against each root component's template. Feature modules keep importing the shared barrel inside their own lazy chunks, where it costs nothing up front. Look for every path into the barrel: if a second eager component imports SharedModule too, both paths have to close together.

imports: [
  // Only the Material modules the root shell renders, not the
  // full MaterialModule barrel. Feature modules that need more
  // import SharedModule inside their own lazy chunk.
  MatToolbarModule,
  MatIconModule,
  MatSidenavModule,
  MatMenuModule,
  MatDividerModule,
  MatBadgeModule,
  MatTooltipModule,
  MatButtonModule,
  MatDialogModule,
  MatCardModule,
  MatSnackBarModule,
  MatProgressSpinnerModule,
  LoaderComponent,
  AppRoutingModule,
],

Step 3: Keep It Small With Build Budgets

A bundle-size reduction that isn't enforced comes back. Set Angular's build budgets to the new measured sizes, so a change that pushes the bundle back up fails the build:

"budgets": [
  { "type": "initial", "maximumWarning": "2479kb", "maximumError": "2599kb" },
  { "type": "bundle", "name": "main", "maximumWarning": "127kb", "maximumError": "133kb" }
]

How to pick the thresholds:

  • Use the right basis. Angular's initial budget counts every initial file, JavaScript and CSS together. Have the analyzer print that basis (for us: eager JS 2,144.5 KB plus initial CSS 261.8 KB = 2,406.3 KB), not the JS total alone.
  • Measured +3% for the warning, +8% for the error. On initial that gave about 72 KB of headroom before a warning and 192 KB before failure. On main, 4 KB and 10 KB.
  • Keep main tight on purpose. If code drifts back into main, it should start a conversation, not slip through.
  • Give initial some room. Feature work legitimately adds eager code, and a budget that fails on every ordinary change gets raised and then ignored. The goal is to catch a large regression, not a 5 KB one.

Two blind spots to know about. First, budgets measure one app; a regression inside a shared library only shows up when the app upgrades it, which is why the ownership report from Step 1 matters as much as the budget. Second, with micro-frontend setups such as Native Federation, the "initial" files Angular sees are only a small bootstrap shim. The real first load is fetched through the federation runtime and can grow past any budget without touching it. There, add a separate gate that compares gzipped eager JS against the committed baseline and fails the build on growth (we use 0.5%), and set the initial budget to the shim size so it at least tracks what it can see.

What the Method Delivered

On the Halodoc website, measured on the final production build:

Metric Before (raw / gzip) After (raw / gzip) Change (raw / gzip)
Main bundle (main-*.js) 308.4 KB / 88.2 KB 123.3 KB / 36.7 KB −60.0% / −58.4%
Total JS loaded before first render 2,656.5 KB / 684.8 KB 2,144.5 KB / 544.4 KB −19.3% / −20.5%
Eager chunk count 41 35 −6

Five packages left the first load entirely:

Package Before (raw) After (raw) Pattern
Carousel library (Swiper) 181.8 KB 0 KB Side-effect import
Design-system templates 157.3 KB 0 KB One-chunk library
Chat assistant 81.0 KB 0 KB All or nothing
Shared web library 70.1 KB 0 KB Runtime enum
Fingerprinting 34.5 KB 0 KB Value import

Together that is 524.7 KB, slightly more than the 512.0 KB net drop; the small loaders and lazy-import wiring account for the difference.

Two things in these numbers are worth pointing out, because they apply to any project:

  • The 60% on main is only meaningful because the eager set dropped with it. None of this code was deleted; it moved off the critical path. Total emitted JavaScript actually grew slightly (9.7 MB to 9.8 MB) because of the loaders and extra chunk boundaries. Reported on its own, the main number would have overstated the win by three times.
  • Raw and gzipped percentages track each other when what you remove is vendor code that compresses like the rest of the bundle. If they diverge sharply, check what you actually moved.

The same loop on two more apps:

App What we found Eager JS before
(gzip)
Eager JS after
(gzip)
Change
Internal portal Material barrel in the root shell (pattern 5), a PDF viewer through three eager paths (pattern 2), a dialog and the animations engine in the first load 741.5 KB 233.1 KB −68.6%
B2B ordering app (Native Federation) An unused module chain, the animations engine, and a budget that could not see the real first load 781.1 KB 663.9 KB −15.0%

The second app is the clearest case for tracking the eager set. With Native Federation, main is only a small bootstrap file, and it didn't change at all. Measured by main alone, a 15% improvement would have looked like zero.

These are byte savings, not load-time measurements; to report a faster page, record real-user LCP and INP before you start, then compare.

Making It Repeatable: The web-bundle-optimizer Skill

Nothing above is specific to one codebase, so we packaged the loop as a Claude Code skill, web-bundle-optimizer, open source in halodoc-tech/halodoc-ai under Skills/web/. Point it at any Angular repo that builds with esbuild:

git clone https://github.com/halodoc-tech/halodoc-ai.git
cp -r halodoc-ai/Skills/web/web-bundle-optimizer your-project/.claude/skills/

It runs the same loop, one commit per fix:

  1. Measure the eager set and main, raw and gzip, from a production build's metafile.
  2. Attribute every eager byte to an npm package and to the repo that owns it, and probe whether each large library actually tree-shakes.
  3. Rank candidate fixes by simulated saving. The simulation removes the candidate's eager import edges and re-walks the graph, so a fix that only relocates bytes scores zero. Candidates come from the five patterns above plus eager importProvidersFrom / forRoot() modules in the app config.
  4. Apply one fix, rebuild, and diff against the committed baseline. Three gates must pass: the package has left the eager set, tests and lint are green, and on SSR repos the server-rendered HTML of an affected route is unchanged. A fix that fails any gate is reverted.
  5. Ratchet the budget in angular.json to the new measured basis.

The scripts are plain Node with no dependencies, so you can keep measuring without the skill. It stops on webpack builds, which have no esbuild metafile.

One gate it does not have: it never opens the app in a browser. As the next section shows, that was the only check that caught the most expensive mistake. Treat it as a fourth, manual gate: before merging, load a production build in a browser and click through the routes the change touches.

Things to Watch Out For

Lazy loading changes timing

Code that used to run synchronously now waits for a download. If a user navigates away before it finishes, setup can run on a component that no longer exists and log an error that reaches monitoring, even though the user sees nothing. A null check before setup (if (this.swiperEl())) fixes it, and specs that expected synchronous setup may need vi.waitFor. Whenever you make something lazy, ask two questions: what happens if the component is destroyed before loading finishes, and what does the user see between first paint and the moment the lazy code arrives?

SSR and zoneless need extra care

@defer (when …) renders its placeholder on the server, so the deferred component disappears from every prerendered page. @defer (hydrate on idle) keeps the server markup. And without Zone.js, Angular doesn't wait for a plain dynamic import(), so the server can finish rendering before the import resolves; wrapping it in PendingTasks.add() fixes that. Confirm both by checking that the component is still present in the prerendered HTML.

Moving a module moves its providers

This is the most expensive lesson in the list. Two changes can save exactly the bytes they promise and still break the app:

  • Replacing BrowserAnimationsModule with provideAnimationsAsync() can also remove the app's only BrowserModule, because the animations module re-exports it. Bootstrap fails with NG0402 and every route renders blank.
  • Removing an unused eager module import makes that module lazy, and with it any provider it declared. If a root-level service injects one of those (in our case CurrencyPipe), it fails with NG0201.
imports: [
  // Used to arrive through BrowserAnimationsModule (exports: [BrowserModule]).
  // provideAnimationsAsync() does not provide it, so import it explicitly.
  BrowserModule,
  // ...
],
providers: [
  provideAnimationsAsync(),
  DecimalPipe,
  // Used to reach the root injector through an eager feature module.
  // Now that the module is lazy, provide it here for root-scoped services.
  CurrencyPipe,
],

Unit tests, the SSR diff, the production build and the bundle report can all pass while the app is blank. Unit tests set up their own testing module and never bootstrap the real AppModule, and the server build brings in BrowserModule by itself. Only a real browser catches it. Whenever you move a module out of the first load, check what it provided, and open the app in a browser before shipping.

Know when the fix belongs upstream

Some weight can't be removed from the app. When other libraries import a package, removing every import in your app frees very little. In our case @angular/material (501.6 KB raw), two design-system libraries (~470 KB raw together) and crypto-js (69.2 KB raw) stay in the first load for that reason. Those fixes belong to the library owners, and the ownership report from Step 1 tells them exactly which import to change. Expect some of them to be breaking changes: making a synchronous hashing function lazy, for example, turns it async for every consumer.

Conclusion

The bundle reduction loop 1Measurethe eager set,not just main.js 2Attributeevery byte to apackage and owner 3Fix one patternremove everyeager importer 4Re-measurerebuild, diff againstthe baseline 5Tighten budgetso the savingcan't come back next biggest package

Reducing bundle size properly comes down to three steps:

  1. Measure the whole bundle that loads first, not just main.js.
  2. Remove what doesn't need to be there, one pattern at a time, closing every eager path to a package in the same change.
  3. Lock the result in with a budget, so the saving can't quietly come back.

The first step matters most. A smaller main.js is only worth reporting when the whole first load shrinks with it, and measuring the eager set is how you know.

Applied to the Halodoc website, this loop removed a fifth of the JavaScript loaded before first render; applied to two more apps and two shared libraries, it cut first-load JavaScript by up to 68.6% and fixed the libraries for every app that uses them. Claude Code did most of the repetitive work: walking the dependency graph, finding importers, rewriting imports and rerunning builds. Engineers made the decisions, such as which defer option is safe for SSR, when a fix belongs in a library instead of the app, and which numbers to trust.

If your Angular app builds with esbuild, run the analyzer once and look at your eager set. You may be surprised by what is in it.


References

Join us

Scalability, reliability and maintainability are the three pillars that govern what we build at Halodoc Tech. We are actively looking for engineers at all levels — if solving hard problems with challenging requirements is your forte, please reach out with your résumé at careers.india@halodoc.com.


About Halodoc

Halodoc is the number one all-around healthcare application in Indonesia. Our mission is to simplify and deliver quality healthcare across Indonesia, from Sabang to Merauke. Since 2016, Halodoc has been improving health literacy in Indonesia by providing user-friendly healthcare communication, education, and information (KIE). In parallel, our ecosystem has expanded to offer a range of services that facilitate convenient access to healthcare, starting with Homecare by Halodoc as a preventive care feature that allows users to conduct health tests privately and securely from the comfort of their homes; My Insurance, which allows users to access the benefits of cashless outpatient services in a more seamless way; Chat with Doctor, which allows users to consult with over 20,000 licensed physicians via chat, video or voice call; and Health Store features that allow users to purchase medicines, supplements and various health products from our network of over 4,900 trusted partner pharmacies. To deliver holistic health solutions in a fully digital way, Halodoc offers Digital Clinic services including Haloskin, a trusted dermatology care platform guided by experienced dermatologists. We are proud to be trusted by global and regional investors, including the Bill & Melinda Gates Foundation, Singtel, UOB Ventures, Allianz, GoJek, Astra, Temasek, and many more. With over USD 100 million raised to date, including our recent Series D, our team is committed to building the best personalized healthcare solutions — and we remain steadfast in our journey to simplify healthcare for all Indonesians.

Tags

Muhammad Sibra

Software Developer Engineer 📍 Jakarta, Indonesia