Keep an Excel export library out of Angular's first download with injectAsync

Two horizontal bars comparing the first download of the same Angular page. Before, with the export service injected normally: 1,057 kB, of which 945 kB is exceljs. After, with injectAsync: 112 kB, and the 945 kB exceljs chunk sits outside the bar, loaded on the first click.
COMPANION REPO · LIVE FROM GITHUB

My demo app's first download was 1,057 kB, and 945 kB of it was exceljs, a library that only the Export to Excel button uses. That's almost 90% of the JavaScript every visitor waits for, spent on a button most of them will never click. A fresh Angular 22 workspace won't even build it: the default budget stops at 1 MB, and ng build fails with an error.

Angular 22 added injectAsync, which gets a service through a dynamic import. With it, the first download dropped to 112 kB, and exceljs moved into a chunk of its own that loads on the first click. If one action needs a heavy library, I'd put that service behind injectAsync, keep every other import of the library type-only, and compare ng build's initial total before and after. That last step matters, because one stray import puts the whole library back.

Why routes don't help here

Most apps already lazy-load their routes. The export library still ships in the first download, because a service imports it and a component on the first screen injects that service. Splitting routes doesn't change that.

injectAsync is stable since Angular 22.0. Instead of the service, it gives you a function that returns a promise of the service. The service has to be provided in root (@Injectable({ providedIn: 'root' }) or @Service()). A providers array would need the class itself, which is a normal import again.

Here's the button. Look at the loader, and at the await in the click handler:

export class ExportButton {
  private readonly exporter = injectAsync(() =>
    import('./report-export.service').then((m) => m.ReportExportService),
  );

  async exportOrders() {
    this.busy.set(true);
    this.status.set('Preparing the file…');
    const exporter = await this.exporter();
    const file = await exporter.toXlsx(ORDERS);
    saveFile(file, 'orders.xlsx');
    this.status.set(`Saved ${ORDERS.length} rows.`);
    this.busy.set(false);
  }
}

I first wrote the loader as () => import('./report-export.service'), the way the short example on the API page reads. It didn't compile. The loader has to resolve to the class, so you either add the .then() or make the service the file's default export. The error says Property 'default' is missing, which is clear once you know that, and not before.

The instance is the normal root one. Two calls, a second injectAsync elsewhere, or a plain inject() all got the same object in my tests. The code behind it is a cached promise and an ordinary injector.get().

What the first click costs

I served the builds locally and clicked Export in headless Chromium, logging every request with its time. The exceljs chunk wasn't requested at load, nor in the three seconds after. It went out 40 ms after the click, and the file arrived 187 ms after the click. A second click requested nothing new.

PAGE LOAD · CLICK · CHUNK · FILE
THE PAGE
Export to Excel
THE NETWORK
main + chunk (Angular, the app) 112 kB
click: injectAsync calls import()
report-export-service (exceljs) 945 kB
at 4 Mbit/s with 100 ms latency: the button was ready at 349 ms (964 ms with exceljs in the first download), and the first export took 797 ms from the click. The second took 44 ms.

On localhost that's nothing, so I throttled the connection to 4 Mbit/s with 100 ms latency and ran each case three times. Times are in milliseconds:

variant                  button ready   first click   second click
eager                             964            67             38
lazy, click at once               349           797             44
idle, click at once               357           768             45
idle, click after 3 s             359            58             42

So the wait moves from the page load to the first click. The button appeared 615 ms sooner, and the first export took about 730 ms longer. For an export button I think that's the right trade. Exporting is something people choose to do, and they expect a report to take a moment. I disable the button and show "Preparing the file…" during the await, so nobody clicks twice on a slow link.

One thing surprised me in the source. If the chunk fails to load, injectAsync keeps that failed promise, and every later call fails the same way without trying again. Catch it and ask the user to reload.

Prefetch on idle, or wait for the click

injectAsync takes a second argument, { prefetch: onIdle }, which starts the download when the browser is idle. I expected idle to mean some time later. On a quiet page it's almost immediate: the chunk went out about 45 ms after the page's own files.

That changes what prefetch buys you. Every visitor downloads exceljs (about 218 kB compressed), clicked or not. You keep the faster first render, but you don't save any bytes. And it only helps once it has finished: clicking straight away with prefetch on still waited 768 ms.

For an export button I'd leave prefetch off. Most visitors never export, and the ones who do wait under a second. I'd turn it on for something most users reach soon after the page loads, like an editor behind a tab, where the wait would otherwise land on nearly everyone.

The import that undoes it

This is what I'd push back on hardest in a code review. The split only holds while nothing in the first download imports the library, or the service file, as a value. I built two variants with one extra import each, and both went straight back to 1,058 kB:

// Safe: TypeScript erases it when it compiles.
import type { Column } from 'exceljs';

// Undoes the split: a string constant, but it loads the whole service file.
import { XLSX_TYPE } from './report-export.service';

// Undoes it too: ValueType is an enum, a real object at run time.
import { ValueType } from 'exceljs';

The constant is the one I'd expect to slip through review. It looks harmless, and it sits in the same file as the service, so exceljs comes along with it.

A plain import { Column } used only as a type was safe as well. TypeScript dropped it, and the build produced the same files, down to the hashed names. That depends on verbatimModuleSyntax being off, so I'd still write import type and not count on it.

Where it's worth it

I wouldn't do this for every service. The await spreads into the calling code, and for a service that adds 5 kB you won't see the difference in the ng build table. My line is a library that shows up clearly in that table and is used by one action. exceljs, at 945 kB, is far past it.

If your app has an export or a report button, run ng build and note the initial total. Move that one service behind injectAsync, search the code for the library's name so every other import says import type, and build again. In this demo, the difference was 945 kB, from a change to one component.