← All articles

The Library Grew by 0.1KB, but the Angular App's Initial JavaScript Shrank by 685KB

Why replacing Ionic root imports with granular entrypoints in a provider library cut an Angular app's initial synchronous JavaScript closure by 36.5%, even though the library itself became slightly larger.

Published Updated
The Library Grew by 0.1KB, but the Angular App's Initial JavaScript Shrank by 685KB cover image

To speed up an Angular app's startup, I was changing Ionic imports to more granular entrypoints.

Making the same change in the app itself barely affected the initial bundle. However, making it in a shared library used from ApplicationConfig.providers produced a dramatically different result.

SeatKeep production build Initial JS synchronous closure
Before 1,875,956 bytes
After 1,190,987 bytes
Difference βˆ’684,969 bytes (βˆ’36.5%)

Interestingly, the shared library itself did not get smaller.

@rdlabo/ionic-angular-kit npm tarball Root FESM
22.0.0-5 397.0KB 114.0KB
22.0.0-6 397.1KB 114.3KB

The library grew by 0.1KB, yet the initial JavaScript of the app using it shrank by about 685KB.

Why did an import change that had almost no effect in the app itself make such a difference in a shared library?

As I investigated, I found that the important factor was not the amount of code in the library, but the dependency graph the library passed on to the consuming app. In particular, a library registered with Angular's providers and loaded at startup affects initial JavaScript differently from a component on a lazy-loaded page.

This article uses the actual change to separate Angular DI from JavaScript loading, then examines the Ionic 9 granular entrypoints that made the improvement possible.

The only change was where Ionic was imported from

The library in question is @rdlabo/ionic-angular-kit, which is used by multiple Ionic Angular apps. It provides authentication, HTTP interceptors, overlays, storage, and other features, and apps register it as follows.

import {
  provideKitHttp,
  provideKitOverlay,
} from '@rdlabo/ionic-angular-kit';

export const appConfig: ApplicationConfig = {
  providers: [
    provideKitHttp(() => ({ /* ... */ })),
    provideKitOverlay(),
  ],
};

In broad terms, the kit's public FESM previously imported Ionic controllers from the root entrypoint.

import {
  ActionSheetController,
  AlertController,
  LoadingController,
  ModalController,
  NavController,
  PopoverController,
  ToastController,
} from '@ionic/angular';

I changed these to the individual entrypoints exposed by Ionic 9.

import { ActionSheetController } from '@ionic/angular/action-sheet-controller';
import { AlertController } from '@ionic/angular/alert-controller';
import { LoadingController } from '@ionic/angular/loading-controller';
import { ModalController } from '@ionic/angular/modal-controller';
import { PopoverController } from '@ionic/angular/popover-controller';
import { ToastController } from '@ionic/angular/toast-controller';
import { NavController } from '@ionic/angular/common';

You can review the change in the pull request.

https://github.com/rdlabo-dev/ionic-angular-library/pull/91

The figures in this article come from rebuilding SeatKeep commits 3d905d17364c and 36943f34cf3e with the following command. The only change between these commits is the kit update from 22.0.0-5 to 22.0.0-6.

npx ng build app --configuration production --stats-json

Within stats.json, I treated outputs whose entrypoints were src/main.ts and angular:polyfills as roots. From there, I recursively followed only import-statement edges, excluding dynamic-import, source maps, and CSS. The table reports the sum of stats.outputs[*].bytes for the JavaScript reached this way. These are uncompressed raw bytes, not gzip or Brotli sizes.

The public API and Angular DI tokens did not change. The consuming app only needed to update the kit version.

Before
app.config
  └─ kit root
       └─ @ionic/angular root
            β”œβ”€ Controller
            β”œβ”€ Component
            β”œβ”€ generated proxies
            └─ Ionic Core utility

After
app.config
  └─ kit root
       β”œβ”€ @ionic/angular/alert-controller
       β”œβ”€ @ionic/angular/modal-controller
       β”œβ”€ @ionic/angular/loading-controller
       └─ @ionic/angular/common

The kit's FESM stayed almost the same size. From the consuming app's perspective, however, the entrypoint for static imports became narrower, removing Ionic's standalone root index from the initial JavaScript's synchronous closure.

Here, β€œsynchronous closure” means the JavaScript reached by recursively following static imports from main and polyfills in the production build's stats.json. This compares raw bytes; it does not mean that compressed transfer size or actual browser startup time improved by 36.5%.

Even so, the production build confirmed that dependencies unnecessary for the initial route were removed from the synchronous closure instead of merely moving between chunks.

Lazy creation through DI and lazy loading of JavaScript are different

To understand this result, separate instance creation through dependency injection from ES module loading.

Suppose a library exposes the following function.

export const provideLibrary = (): EnvironmentProviders => {
  return makeEnvironmentProviders([
    LibraryOverlayService,
  ]);
};

The app registers it in ApplicationConfig.providers.

import { provideLibrary } from '@example/library';

export const appConfig: ApplicationConfig = {
  providers: [
    provideLibrary(),
  ],
};

The LibraryOverlayService instance might not be created until it is actually injected. But calling provideLibrary() first requires loading the JavaScript for @example/library.

app.config.ts
  └─ @example/library
       β”œβ”€ provideLibrary
       β”œβ”€ LibraryOverlayService
       └─ dependencies imported by the service at runtime

Allowing DI to create a service lazily is not the same as keeping the JavaScript that defines the class out of the initial bundle.

Angular's official documentation explains that providers can be registered at application bootstrap, on a component or directive, or on a route, with different scopes and lifecycles.

https://angular.dev/guide/di/defining-dependency-providers

From the bundle's perspective, a library statically imported by an initial file such as app.config.ts enters the initial dependency graph regardless of when the service is instantiated.

The cause is not the provider mechanism itself. What matters is that registering the provider requires statically importing the library from an initial file.

Ionic 9 root and granular entrypoints

Ionic 9's package structure made this improvement possible.

The root of @ionic/angular in Ionic 9 points to the standalone-oriented index. It is a convenient entrypoint, but it re-exports not only controllers, but also navigation features, standalone components, generated proxies, and Ionic Core utilities.

At the same time, Ionic 9 exposes separate entrypoints for components, controllers, common, and provide.

Purpose Example import
Convenient root entrypoint @ionic/angular
Component @ionic/angular/ion-button
Overlay controller @ionic/angular/alert-controller
Shared Angular features @ionic/angular/common
Bootstrap provider @ionic/angular/provide

The complete export list is available in Ionic 9.0.0's package.json.

https://github.com/ionic-team/ionic-framework/blob/v9.0.0/packages/angular/package.json

The root entrypoint is not inherently bad. Importing multiple components from one place offers a good developer experience, and tree-shaking is sufficient in many situations.

On the other hand, narrowing runtime value imports can be especially valuable in a shared library that must be loaded at startup. Controllers and providers have clear usage targets, making them a good fit for granular entrypoints.

A type-only import type disappears from the JavaScript output. Type imports and runtime value imports can therefore be audited separately.

import type { AlertOptions } from '@ionic/angular';
import { AlertController } from '@ionic/angular/alert-controller';

Why was the difference small on the app's lazy-loaded pages?

Pages in an app are often lazy-loaded by route.

export const routes: Routes = [
  {
    path: 'settings',
    loadComponent: () =>
      import('./settings.page').then((m) => m.SettingsPage),
  },
];

An Ionic component referenced only by SettingsPage will normally remain in the lazy chunk.

import { IonButton } from '@ionic/angular';

With Ionic 9, this can also be changed to an individual entrypoint.

import { IonButton } from '@ionic/angular/ion-button';

However, if the Angular builder was already removing unused exports and splitting dependencies into appropriate lazy chunks, the initial bundle may barely change. A dependency may also enter a shared or initial chunk if it is used on the startup path as well.

Tree-shaking and code splitting are different. Removing unused code from the final bundle does not guarantee that chunk boundaries will be divided exactly as desired. An Ionic issue also discussed cases where tree-shaking of standalone components worked but code splitting was less than ideal because of Webpack or esbuild constraints.

https://github.com/ionic-team/ionic-framework/issues/30114

This does not mean deep imports have no value in app code. The difference can simply be small relative to the change when lazy loading and tree-shaking are already working on a page.

In contrast, every app loaded this kit FESM at startup. Fixing one root import narrowed the initial dependencies of multiple apps at once. That is why the library-side change had such a large effect.

Moving a provider to route providers alone does not make it lazy-loaded

Moving a heavy provider into route scope may look like it would remove it from the initial bundle. However, statically importing that provider from the root route definition keeps its JavaScript in the root dependency graph.

// app.routes.ts
import { provideAdminFeature } from '@example/admin';

export const routes: Routes = [
  {
    path: 'admin',
    providers: [provideAdminFeature()],
    loadChildren: () => import('./admin/admin.routes'),
  },
];

The provider's scope is limited to the admin route, but provideAdminFeature is still statically imported from app.routes.ts. DI scope and bundle boundaries are different.

To lazy-load the JavaScript that defines the provider as well, register it in the routes file that is itself lazy-loaded.

// app.routes.ts
export const routes: Routes = [
  {
    path: 'admin',
    loadChildren: () => import('./admin/admin.routes'),
  },
];
// admin/admin.routes.ts
import { provideAdminFeature } from '@example/admin';

export const routes: Routes = [
  {
    path: '',
    providers: [provideAdminFeature()],
    loadComponent: () => import('./admin.page'),
  },
];

Angular Router creates an EnvironmentInjector from a route's providers for use by that route and its children.

https://angular.dev/guide/routing/define-routes

You therefore need to inspect the file boundary as well, depending on whether the goal of moving a provider is only to change the instance scope or also to lazy-load its JavaScript.

Measure the effect in the consuming app, not the library

In this case, the npm tarball and root FESM grew slightly, while the consuming app's initial JavaScript shrank.

Library package size, public FESM size, and the consuming app's initial chunk are different metrics. When optimizing a library that provides providers, you need to production-build a real consuming app and compare the synchronous closure from stats.json.

Actual browser startup time should be measured separately.

// main.ts
performance.mark('script-start');

bootstrapApplication(AppComponent, appConfig);

This mark records when the body of main.ts begins executing. With ES modules, static import resolution and evaluation of imported modules happen before that point.

navigation start
  β”œβ”€ download HTML/modules
  β”œβ”€ resolve dependency graph
  β”œβ”€ parse/compile/link
  β”œβ”€ evaluate imported modules
  └─ main.ts body
       └─ script-start

The interval from navigation start to script-start can include not only network transfer, but also loading the dependency graph, parsing, compilation, linking, and evaluating imported modules.

A 36.5% reduction in the synchronous closure does not guarantee a 36.5% reduction in startup time. Check Evaluate Script and Compile Script in a Chrome Performance trace, and compare before and after using fresh browser profiles.

Use the production build to verify that an initial dependency was actually removed, and a real browser to determine how many seconds changed. Keeping these measurements separate makes it less likely that moving code between bundles will be mistaken for a speedup.

What to check in libraries registered with providers

Based on this experience, I now check the following in libraries registered with startup providers.

  • Whether the root entrypoint exports optional runtime features
  • Whether provider implementations import a dependency library's root barrel
  • Whether type-only and runtime imports are separated with import type
  • Whether heavy features can be separated into secondary entrypoints
  • Whether route-specific providers can be placed in lazy-loaded route files
  • Whether the synchronous closure was compared in a production build of a consuming app
  • Whether parse, compile, and evaluate time was measured with a fresh browser profile

Rather than enforcing deep imports uniformly across every app, I think it is easier to find meaningful improvements by first auditing shared libraries statically imported from startup files such as app.config.ts.

When using ESLint, it also seems more practical to target runtime imports in shared libraries loaded at startup instead of banning @ionic/angular throughout the entire app.

Conclusion

The key to this improvement was not when DI creates a service, but which JavaScript the initial files statically import. Even before a service is instantiated, a library used by startup providers enters the initial dependency graph.

The size of a library by itself is also insufficient for judging the effect of a change. Here, the library grew by 0.1KB, but production-building the consuming app showed that its initial JavaScript shrank by 685KB. Use a production build to verify that initial dependencies were removed, then a browser to measure the actual change in seconds.

There is no need to mechanically ban every root import. For libraries registered with Angular's providers, however, the dependency graph passed to consuming apps deserves as much attention as the public API. Start with shared libraries loaded at startup, and you may find a substantial improvement.

See you next time.