Brandon Roberts headshot rounded

Brandon Roberts

Notes to my future self

TwitterGitHubBlueskyYouTube
Angular Compilation, Type-Checking, and Build Bottlenecks

Angular Compilation, Type-Checking, and Build Bottlenecks

June 26, 2026 - 10 min read

Angular application builds aren't inherently slow — for a small or medium app you rarely think about them. But the compiler does its heaviest work whole-program, so build time grows with the size of your codebase, and at scale it becomes a real bottleneck. To find out where that time actually goes, I built a large, real-world Angular app — hundreds of components — four different ways and measured each. More of it goes to type-checking than most people expect.

How Angular compilation works

When you build an Angular app ahead-of-time, the compiler (ngtsc) does two jobs at once:

  1. Template compilation (AOT) — every component template is parsed and lowered to Ivy instructions (ɵɵelement, ɵɵproperty, ɵɵtemplate, …) emitted into the component's definition.
  2. Type-checking — your TypeScript is type-checked, and so are your templates. Angular generates a "type-check block" for each template so that a typo like {{ user.nmae }}, or a value bound to an input() of the wrong type, is a compile error.

The second job is the expensive one, and it's expensive for a structural reason: it's whole-program. To type-check a template, the compiler needs the resolved types of every directive, component, and pipe that template uses — their inputs, outputs, and generic signatures.

A standalone component lists those dependencies in its imports, but the types themselves live in other files and in third-party libraries. So the compiler can't work one file at a time. It builds and holds the whole program in memory and reasons across it.

That whole-program requirement is what makes Angular builds hard to parallelize and hard to cache at file granularity. It's also what the fast per-file compilers deliberately give up.

Four pipelines, two architectures

I built the app four ways. They split cleanly into two camps: whole-program AOT (the classic Angular compiler, however it's bundled) and per-file transpile (emit Ivy a file at a time, skip type-checking). Understanding the architecture is the whole point — it's what predicts the numbers.

Webpack + @ngtools/webpack (whole-program AOT, legacy)

The app's own production build, and the classic Angular CLI architecture. @ngtools/webpack's AngularWebpackPlugin owns a single ngtsc program for the entire application — Angular's NgtscProgram wrapping one TypeScript Program.

The plugin intercepts every .ts file, but the unit of work is the program, not the file: ngtsc runs AOT template compilation and full type-checking (TypeScript plus generated template type-check blocks) across the whole graph. Webpack then bundles the emitted JS. One single-threaded compile pass over everything, then Webpack's module graph and Terser.

Webpack-based compilation is deprecated as of Angular v22 — the modern default is the esbuild/Vite application builder — so treat it as the legacy baseline. Worth noting, though: the new default builder swaps the bundler (esbuild/Vite for Webpack), not the compiler. It still drives the same whole-program ngtsc with the same type-checking, which is exactly why the next one matters.

Angular application builder (whole-program AOT, esbuild — the modern default)

This is what ng build runs today: the @angular/build:application builder. It uses esbuild for transpilation and bundling, with the Angular compiler plugin handling AOT and type-checking, plus a persistent on-disk cache. Crucially it's the same ngtsc — the same whole-program NgtscProgram, the same AOT codegen and strict template type-checking — as the Webpack path. Only the bundler changed (esbuild for Webpack), which is exactly the point: set against Webpack, it shows what the bundler swap bought; set against the per-file pipelines below, it shows what the whole-program type-check costs.

AnalogJS fastCompile (per-file transpile)

Introduced in AnalogJS 2.5, fastCompile is a per-file, transpile-only compiler written in TypeScript — no NgtscProgram, no type-checking. For each file it finds the @Component / @Directive / @Pipe / @Service / @NgModule decorators, builds the R3 metadata, and calls @angular/compiler's public R3 emit APIs (compileComponentFromMetadata, etc.) to produce the same Ivy definitions the AOT compiler would (ɵcmp, ɵfac, ɵprov, ɵmod, …).

It still needs cross-file information — which directive a selector maps to, what a library module exports — but that comes from a lightweight registry, built once by scanning the project and reading library .d.ts files, not from a type-checker. Every file compiles independently, so the work parallelizes and caches per file.

Oxc Angular Compiler (experimental, Rust — per-file transpile)

The same per-file idea, but written in Rust on the Oxc toolchain — its Rust TypeScript parser, AST, and arena allocator. The Oxc Angular Compiler parses each file natively, lowers templates to an Ivy/R3 instruction IR through a pipeline of phases (binding specialization, sanitizer resolution, constant pooling, …), and emits the Ivy definitions in native code, with no Node or TypeScript in the hot path.

It reaches the build through a napi binding and a thin Vite plugin. Like fastCompile it skips type-checking and resolves cross-file scope from a registry; the difference is doing the parse and codegen in Rust instead of TypeScript.

To set expectations: the Oxc Angular Compiler is a research project — a way to explore what native Angular compilation could look like, not a supported tool or a roadmap item. Separately, the Angular team is running its own investigation into how Oxc could fit into Angular's tooling. I'm including the compiler here because it's a useful data point for where the codegen ceiling might be, not as something to adopt today.

All four ran on the same machine, same app, as cold production builds, each producing a bundle that boots. ("Boots" is a deliberately low bar — more on that below.)

The numbers

One caveat before the chart, because it matters: the two per-file pipelines skip type-checking, so they aren't doing the same work as the AOT ones and the raw totals aren't apples-to-apples. The chart marks which pipelines type-check (orange) and which don't (green); treat that as part of the number — we'll reconcile it at the end.

Bar chart of Angular build times

The dashed bar is the reference point: type-checking the app on its own — ngc --noEmit with strictTemplates, no codegen, no bundling — takes about 15 seconds, as long as an entire per-file build.

These are cold, production builds, the kind CI runs. The application builder keeps a persistent cache, but it barely moved the full production number here — a clean rebuild came within a second of a cold one. Caching helps incremental dev rebuilds far more than a from-scratch production build, so read this as the CI story, not the ng serve story.

A few things stand out.

The bundler helped, but it's not where the headroom is. Webpack (49s) → the esbuild application builder (36s) is a real improvement, and it's the move Angular itself made by deprecating the Webpack builder in v22. The modern default is solid — it's just not the fastest option here. It's the same ngtsc underneath, and the per-file pipelines still come in 2.5–5× faster, so the remaining headroom isn't in the bundler; it's in the compiler.

Type-checking is a large, separable cost. ngc --noEmit puts a number on it: the whole-program type-check alone is 15s — about as long as the entire fastCompile build (14.5s), and roughly twice the Oxc Angular Compiler build (~7.7s). The per-file compilers do everything the AOT compilers do except that whole-program analysis, and that single omission is most of why they're fast. (They also lean on a faster bundler — Vite and rolldown rather than esbuild — so the totals mix two effects; ngc is what cleanly isolates the type-check.)

The expensive half was never the codegen. Turning templates into Ivy instructions is cheap and trivially parallel — that's exactly what the per-file compilers demonstrate. The costly part has always been proving the templates are correct. Of the modern builder's ~36s, roughly 15 is type-checking and the rest is codegen, bundling, and optimization — and the type-check is the biggest of those pieces, and the only one that produces no output, just a pass or a fail.

(This is a comparison of compile speed, not bundle size; the outputs are broadly comparable.)

What per-file compilers still need

Skipping type-checking is the easy part. The subtler problem is that a per-file compiler, by definition, can't see other files — yet to compile a template it still needs the selectors (and input/output names) of every directive and component that template uses, and those are declared elsewhere.

In a standalone app this is tractable. A component lists its dependencies in imports, so the compiler already knows which classes a template can match; it just has to resolve their metadata from the files — or library .d.ts — where they're defined. The per-file compilers do this with a lightweight registry, scanned once from the project and its type declarations, instead of a whole-program type-checker. Only the directives a template actually references get inlined into its definition, so the output matches what ngtsc would emit.

That's a much smaller problem than type-checking — selector resolution, not full type inference — and it doesn't require holding the whole program in memory. (Older NgModule-based code needs a little more bookkeeping, since a component's dependencies come from its declaring module rather than its own file, but the same registry resolves them.)

And the fairness note in the other direction: "it boots" is not "it's correct." Skipping type-checking means template mistakes the normal build would reject slip through to runtime — that's the explicit trade, and it's why you still run the type-check, just elsewhere. These compilers are also young: matching the full AOT compiler across every Angular feature — control flow, @defer, queries, animations, i18n, host bindings, content projection — is a long tail that both projects are still working through. The point here is the shape of the cost, not a suggestion to swap your production compiler today.

What the gap comes down to

Line the numbers up and the shape is clear. The type-check (ngc --noEmit, ~15s) is its own large, self-contained cost — as long as an entire fastCompile build, twice the Oxc one. The per-file compilers are fast precisely because they don't do it; the AOT builders take longer precisely because they do, as part of the build.

So the distance between ~7–14s and ~36s isn't really about codegen, or even the bundler — it's that one set of tools runs a whole-program type-check on every build and the other doesn't. That's the same analysis your editor runs as you type and your CI runs on every push; ngc --noEmit does it standalone in ~15s. Most of what we call an Angular build's "compile" time is the work of proving the templates are correct, not producing the code.

Conclusion

At scale, most of an Angular build's time is the type-check — not the codegen, and not the bundler. A faster bundler helps, but it can't touch that cost; the per-file compilers are fast because they skip the check entirely.

Turning templates into Ivy was never the expensive part. Proving they're correct is — about 15 seconds of it, on this app, separable from everything else the build does.

Looking ahead: a native type-checker

Of the two halves of the work, type-checking is the larger — ~15s against the ~8s a per-file compiler needs to emit code. So even setting the bundler aside, the type-check is where the real time is, and it's the part poised to get faster.

That part should get much faster, eventually. The TypeScript team is porting the compiler to Go — shipping as TypeScript 7 — targeting a roughly 10× speedup in type-checking and project load. Angular's compiler is built directly on the TypeScript compiler API: ngtsc wraps a ts.Program and generates its template type-check blocks into it. So in principle, as the native compiler's API lands, Angular's type-checking could ride on top of it.

I'd hedge that, though. Angular's template type-checking leans on a lot of TypeScript's compiler surface, some of it internal, and the Go port exposing what ngtsc needs is a real undertaking, not a flag flip. The 10× is also TypeScript's headline for tsc itself; Angular's template checking sits on top and may not see the same multiple. Even a fraction of it, though, would take that ~15s ngc --noEmit down meaningfully.

Codegen is already cheap — fastCompile and the Oxc experiment both show how low it goes. A native type-checker is what would make the other half cheap too. That's the part to watch.

If you enjoyed this post, click the ❤️ so other people will see it. Follow AnalogJS and Brandon Roberts on Bluesky, and subscribe to my YouTube Channel for more content!