What We Learned Upgrading Angular 19 to 21

Five real-world lessons from migrating a production Angular app through three major versions, including a build-system switch to esbuild/Vite.

We recently took a production Angular application — a frontend app with 50+ components, NGXS state management, and a full Cypress component-testing suite — from Angular 19 to 21, including the switch to the new esbuild/Vite-based build system (use-application-builder).

The upgrade itself wasn’t the hard part. The interesting failures showed up in the gaps: a CLI that quietly installs the wrong version, tests that pass for the wrong reason, and a CI failure that had nothing to do with our code. Here’s what stood out.

1. ng update doesn’t always update what you asked for

Running a version-pinned command like:

ng update @angular/cli@21 @angular/core@21

feels safe. It isn’t always. Under the hood, ng update can spin up its own unpinned “temporary CLI” to perform the migration — and that temporary CLI sometimes writes a newer major version into package.json than the one you requested. We hit this more than once, including once with the use-application-builder migration writing @angular/build@^22 into a project that was otherwise fully on Angular 21.

The fix isn’t a flag — it’s a habit:

git diff package.json

Run it after every migration command, before you run npm install. If something unexpected shows up, fix it before installing, not after.

2. Angular 21’s change detection broke our test patterns — quietly

This was the subtlest issue. In Angular 21, hooks like ngOnInit and ngAfterViewInit can run after a Cypress mount() command resolves rather than during it. Our old pattern —

mount(MyComponent, { providers: [...] }).then(({ component }) => {
  expect(component.someState).to.equal('expected');
});

— started producing two failure modes at once: tests that failed because state wasn’t populated yet, and tests that passed for reasons that had nothing to do with the component actually working. Neither is fun to debug, because the test output looks like a normal assertion failure.

The fix is to treat every state-reading assertion as something that needs to retry:

cy.wrap(null).should(() => {
  expect(component.someState).to.equal("expected");
});

And when a component subscribes to something outside ngOnInit (say, ngAfterViewInit), emitting into that subscription from a test needs to happen inside Angular’s zone, with the view explicitly marked dirty:

fixture.ngZone.run(() => {
  subject.next(value);
  fixture.changeDetectorRef.markForCheck();
  fixture.detectChanges();
});

Skip markForCheck() and you get NG0100: ExpressionChangedAfterItHasBeenCheckedError instead. We learned this one the hard way, twice.

3. ng-container and ng-template are not interchangeable

A small one, but it cost real debugging time. If a component declares:

@ContentChild('header') headerTemplate!: TemplateRef<any>;

the reference variable must sit on an <ng-template>. Put it on an <ng-container> instead — easy to do by accident during a template refactor — and Angular resolves it to an ElementRef, not a TemplateRef. You won’t see a compile error. You’ll see this at runtime, in production-adjacent code:

TypeError: templateRef.createEmbeddedViewImpl is not a function

If you’re touching templates during a control-flow migration (*ngIf@if), it’s worth a quick audit for this pattern before you ship.

4. The new build system leaves your testing tools behind

use-application-builder moves the build to esbuild/Vite (@angular/build) and it’s a genuine speed win. But two things aren’t obvious until they break:

  • Your output path changes. dist/<app>/ becomes dist/<app>/browser/. If your Dockerfile or nginx config hardcodes the old path, your next deploy serves nothing.
  • Cypress component testing still needs the old builder. Per Cypress’s own docs, its component-testing dev server requires @angular-devkit/build-angular to be installed even if your app itself no longer uses it — because it’s still building your Storybook and Cypress setups under webpack. The migration can strip this package from package.json. Don’t let it stay stripped.

5. When CI fails and local doesn’t, check what CI is actually running

Our final and most instructive bug: Cypress component tests passed locally — including a clean npm ci — but failed on Jenkins with:

Error: Could not resolve "@angular-devkit/core/src/index.js".

We chased three wrong hypotheses before finding the real one: Jenkins wasn’t running npm ci at all — it was running npm install --legacy-peer-deps --no-audit --no-fund, a genuinely different install strategy. But even that wasn’t the root cause. The actual problem was that Cypress’s own binary cache on the Jenkins agent was stale, and a security policy on that agent was silently blocking Cypress’s postinstall script from re-downloading the correct binary.

No amount of package.json archaeology would have found this. What found it was reading the actual pipeline script line by line instead of assuming it matched our local reproduction steps.

The short version

  • Diff package.json after every migration command. Don’t trust the CLI’s version resolution.
  • Ship optional migrations (control-flow, build-system changes) in their own PRs, separate from version bumps.
  • After a major Angular upgrade, assume your test suite’s timing assumptions are wrong until proven otherwise.
  • A CI-only failure isn’t automatically a CI problem or a code problem — confirm CI is running the command you think it’s running before you start debugging either.

The upgrade landed clean in the end: zero regressions, full green test suite, noticeably faster local builds. But almost none of the interesting problems were in the Angular changelog — they were in the seams between Angular, our tooling, and our CI environment.

Light