DEV Community

Cover image for 9 Mistakes Teams Keep Making When Migrating Legacy Angular Apps (2026 Edition)
Amanulla Khan
Amanulla Khan

Posted on

9 Mistakes Teams Keep Making When Migrating Legacy Angular Apps (2026 Edition)

Angular has changed more in the last three years than in the five before that. Standalone components have been the default for new Angular applications since v19, built-in control flow (@if/@for) has been stable since v17, signals are now a core part of Angular's modern reactivity model, zoneless change detection is the default for new apps in v21+, and as of v22 OnPush is the default change detection strategy. If your app was last touched seriously back in the NgModule-and-*ngIf era — or worse, it's still a hybrid AngularJS app — the migration path is no longer "run ng update and fix whatever breaks."

A note on how to read this: where a claim rests on specific framework behavior I've linked the primary source — an Angular RFC, a filed issue, or the official docs — rather than asking you to take my word for it, because a couple of these contradict advice that's currently circulating.

Below are the mistakes I keep seeing on real migration projects, roughly in the order they bite you.

1. Treating the migration as a single "big bang" project

The most expensive mistake happens before anyone writes code: deciding to freeze feature work for months and rewrite everything at once.

Angular's own tooling assumes the opposite. ng update enforces one major version at a time — you cannot jump from v14 to v22 directly. Each hop (14→15→16→17→18→19→20→21→22) runs its own schematics. This isn't a style preference, it's enforced: Angular's release documentation states you can ng update to any version "provided that... the version you want to update from is within one major version of the version you want to upgrade to" — with a worked example of going 10 → 11 → 12 rather than 10 → 12 directly. A "big bang" plan collides with this immediately: teams try to jump straight to the latest major, ng update refuses or gets --force'd into a broken state, and someone spends a week bisecting which of six simultaneous major upgrades caused the regression.

The fix is the strangler pattern applied to versions, not just code: upgrade one major version, run the automated migrations for that version, ship it, then move to the next. The cost of deferral isn't linear either — breaking changes compound, so a team that skipped five years of upgrades isn't facing five times the work of a team that skipped one.

For a genuinely legacy (AngularJS 1.x) app, the equivalent mistake is trying to rewrite the whole SPA before shipping anything. The usual advice is a hybrid app via UpgradeModule, migrating route-by-route behind a shared shell. That still works — but go in knowing something the guides rarely mention: ngUpgrade and zoneless don't currently mix. A team that tried it on Angular 19 + AngularJS 1.8 found change detection simply didn't fire for downgraded components, and an Angular team member investigating replied that "the upgrade module appears to depend on ZoneJS," pointing at specific code, while adding that enabling zoneless support "likely carries some amount of risk since there's so little usage left of the upgrade adapter that it's effectively untested" (angular#61640).

I'd read that as "unsupported and untested," not "architecturally impossible" — nobody has declared it permanently off the table. But for planning purposes the distinction doesn't help you much: an untested path through a low-usage compatibility layer is not where you want your migration's critical path. The practical takeaway is that the hybrid phase and zoneless are sequential, not concurrent, and that second quote is the real signal — ngUpgrade is in maintenance territory. The bridge is one you need to actually finish crossing, not a steady state to park in for three years.

2. Running the standalone/control-flow schematics and assuming they're correct

ng generate @angular/core:standalone and ng generate @angular/core:control-flow are genuinely good tools — but they're pattern-matching schematics, not compilers with full semantic understanding of your templates. Mishandled ng-template removal is a whole genre of filed bug, not a one-off: #52513, #53288, #53362, #53383, #59919, and #64741 are all variations on the schematic deleting or mangling a template that was still needed.

Most of those specific reports are now fixed, and that's the point rather than a caveat: the same failure mode kept resurfacing in new shapes across several years of releases, because "is this template still referenced?" is a genuinely hard question to answer from pattern matching. Your codebase's particular shape is not guaranteed to be one of the ones somebody already filed.

The nastiest variant is when the template is referenced from TypeScript rather than another template, because nothing in the converted markup looks wrong:

// Before migration
template: `
  <div *ngIf="true; else someTpl"></div>
  <ng-template #someTpl />
`
export class CountComponent {
  someTpl = viewChild('someTpl', { read: ViewContainerRef });
}
Enter fullscreen mode Exit fullscreen mode

The schematic converts the *ngIf to @if/@else and removes the <ng-template #someTpl /> — leaving the viewChild query pointing at a template reference that no longer exists (#59919). The template compiles; the query silently returns nothing at runtime; whatever dynamic rendering depended on that ViewContainerRef quietly stops working.

What to actually do: the schematic's own README says to verify the application between each step, and it accepts a path option specifically so large apps can migrate a subdirectory at a time. Use both. Diff every changed template by hand, grep for viewChild/ViewChild/ngTemplateOutlet references to any template the migration touched, and don't merge until you've visually verified each converted view — not just that it compiles. Treat the automated migration as a first draft, not a merge-ready PR.

3. Flipping to zoneless without auditing implicit change-detection triggers

Angular 21 makes zoneless the default for new apps, and it's increasingly the target for migrated ones too, because it removes an entire class of Zone.js monkey-patching overhead and makes change detection explicit and predictable.

The useful way to think about the risk is not "async code stops working" — that's the version of this warning you'll see repeated everywhere, and it's too broad to act on. Zoneless doesn't care whether your code is asynchronous. It cares whether Angular gets notified. Angular's docs list seven conditions that schedule change detection (including edge cases like attaching a dirty view, removing a view, and registering a render hook); these are the ones that matter day to day:

  • a signal that's read in a template is updated
  • ChangeDetectorRef.markForCheck() is called
  • ComponentRef.setInput() is used
  • a template-bound event listener fires — your (click), (input), (submit) handlers are all fine
  • AsyncPipe receives a new value That last point is the whole model in miniature. Angular needs an explicit notification that a view may need updating. A signal write can provide that notification when the signal is read in the template; AsyncPipe calls markForCheck() for you when its observable emits. Other Angular APIs, such as ComponentRef.setInput() and bound template listeners, can also schedule change detection. A hand-rolled subscription that simply assigns a new value to a plain property does none of those things.

That distinction is what makes the risk auditable. What breaks is state that changes with no notification attached — a bare setTimeout or setInterval callback, a Promise.then, a manually-registered addEventListener outside the template, a WebSocket onmessage, or a third-party SDK callback, each assigning to a plain property:

// This pattern is everywhere in legacy Angular code
setTimeout(() => {
  this.status = 'ready'; // Zone.js used to notice this; nothing does now
}, 300);
Enter fullscreen mode Exit fullscreen mode

That assignment updates a plain property, and nothing tells Angular a check is needed — the UI silently goes stale. The fix isn't a shim, it's a real architectural change: back that state with a signal() (or push updates through ChangeDetectorRef.markForCheck() at minimum) so the framework has an explicit reason to update the view.

status = signal<'loading' | 'ready'>('loading');

setTimeout(() => this.status.set('ready'), 300);
Enter fullscreen mode Exit fullscreen mode

Note the direction of travel here: an existing app doesn't become zoneless because you upgraded. It keeps its Zone.js behavior until somebody deliberately turns Zone.js off — which means this is a migration you opt into on your own schedule, and therefore one that's easy to defer indefinitely and easy to do carelessly in a quiet sprint by whoever picks up the "remove zone.js" ticket. Before that ticket gets merged, grep your codebase for setTimeout, setInterval, third-party callbacks, and WebSocket handlers that mutate component state — every one of them is a candidate for silent breakage. Also budget time for your UI kit: Material and PrimeNG components historically had zoneless gaps, so pin to versions explicitly tested against the Angular version you're targeting rather than assuming "latest" works.

4. Letting the OnPush migration quietly make your debt permanent

First, a correction to something you'll read on a lot of migration blogs: zoneless does not require OnPush. Angular's own zoneless guide is explicit — "The OnPush change detection strategy is not required, but it is a recommended step towards zoneless compatibility." Default-strategy components are still checked in a zoneless app, provided something notifies Angular that a check is needed. If your app breaks after going zoneless, the cause is a missing notification (mistake #3), not a missing OnPush.

The real trap is on the other side, and it already landed. As of v22, per the release's own breaking-changes note, "Component with undefined changeDetection property are now OnPush by default" — the old default is now spelled ChangeDetectionStrategy.Eager (RFC #66779; v22.0.0 release notes). And — exactly as with the standalone migration — the Angular team ships an automatic migration that preserves your behavior. The v22 release describes it as a migration to "add ChangeDetectionStrategy.Eager where applicable," and it runs as part of ng update — so components that were relying on the old implicit default come out the other side carrying an explicit Eager annotation. ("Where applicable" is the release's own wording, so don't assume the annotation count will exactly match your component count; read the diff.)

That migration is the correct engineering decision by the framework team, and it is also the moment your technical debt becomes invisible. Before it runs, "this component is on the default strategy" is an absence — something you can grep for and gradually fix. After it runs, every one of those components carries an explicit, intentional-looking Eager annotation that no future reviewer will question, because it looks like somebody chose it.

So don't just accept the migration diff and move on. Run it, then treat the resulting Eager annotations as a tracked backlog rather than settled code — they're a precise, machine-generated inventory of every component that was never designed for OnPush. Work through them leaf-components-first; the conversion usually surfaces genuine mutation bugs (arrays and objects mutated in place instead of replaced) that were already latent.

5. Carrying over unmanaged RxJS subscriptions

This one predates zoneless, but the migration is the right moment to deal with it: legacy services and components that call .subscribe() in ngOnInit with no takeUntil, no takeUntilDestroyed(), and no async pipe accumulate leaked subscriptions. Repeated destruction and recreation of the component can leave another live subscription behind, holding a reference to the destroyed instance — a memory leak, plus duplicated side effects (double-fired analytics, double-submitted requests) that get harder to trace the longer the session runs. The distinction that matters is whether the source completes: a one-shot HttpClient call tears its own subscription down, so it's the long-lived streams that bite you — a BehaviorSubject in a service, router events, fromEvent, a WebSocket feed.

// Legacy pattern — currentUser$ never completes, so this
// subscription outlives the component on every re-navigation
ngOnInit() {
  this.userService.currentUser$.subscribe(u => this.user = u);
}

// Modern pattern
private destroyRef = inject(DestroyRef);

ngOnInit() {
  this.userService.currentUser$
    .pipe(takeUntilDestroyed(this.destroyRef))
    .subscribe(u => this.user.set(u));
}
Enter fullscreen mode Exit fullscreen mode

If you're migrating anyway, this is the moment to convert hand-rolled subscription management to takeUntilDestroyed(), the async pipe, or toSignal() — not just to modernize syntax, but because the old pattern is an active liability once the CD model changes underneath it.

6. Skipping the DI audit when partially migrating to standalone

Scope first, because this one gets overstated: if your service is providedIn: 'root', none of this applies to you. It resolves against the root environment injector no matter who injects it, and standalone conversion doesn't change that. Angular has recommended providedIn as "the best practice for providing services" since v6, so in a reasonably modern codebase most services are already safe.

The exposure is services registered the old way — listed in an @NgModule.providers array, typically behind a CoreModule or a SomeModule.forRoot(). That pattern already had a well-known duplication hazard in the NgModule world: provide a service in a module that gets imported by both an eagerly-loaded and a lazy-loaded module, and the lazy module's injector instantiates its own copy. forRoot() exists specifically as a convention to avoid it, and the classic CoreModule guard — inject the module into itself with @Optional() @SkipSelf() and throw if it's already there — exists to catch it when the convention is broken.

Standalone conversion adds a new way to trip the same wire, because it introduces new injector boundaries. If a provider-carrying NgModule is moved into a narrower injector scope during migration — most commonly by landing it in a standalone component's own imports array to satisfy that component's dependencies — a service that was previously resolved as one shared instance may now be instantiated more than once. The exact behavior depends on how the module and its providers are wired, but the risk is real: changing the injector hierarchy changes the lifetime and scope of services.

This isn't hypothetical, and it isn't a bug queued for a fix. The Angular team reviewed a report of exactly this — services from an NgModule's providers duplicating across standalone components that imported it — and closed it as working as designed (#53120).

The failure is nasty because it doesn't look like a DI problem. Two copies of CartService means the header reads one instance and checkout reads another; the symptom is "the cart shows 2 items up top and 0 at checkout," which gets triaged as a state-sync bug and debugged as one, sometimes for days, because nobody suspects the injector topology moved.

The fix is to hoist, not to sprinkle. Provider-carrying modules belong at application level, not in component imports:

// Risky during migration: pulling a provider-carrying NgModule
// into a component introduces a narrower provider scope.
@Component({ imports: [CoreModule], /* ... */ })

// Migration approach: hoist NgModule providers to the
// application/environment injector instead.
bootstrapApplication(AppComponent, {
  providers: [importProvidersFrom(CoreModule)],
});
Enter fullscreen mode Exit fullscreen mode

importProvidersFrom() is the bridge API for exactly this, and its type signature makes the intended scope explicit. It returns EnvironmentProviders, and Angular's docs state that providers extracted this way "are only usable in an application injector or another environment injector (such as a route injector). They should not be used in component providers."

It's a fine migration step, but treat it as a stepping stone rather than a destination — it only carries providers, so anything else the module was doing (initialization logic, guards) needs somewhere to live. The end state most teams want is a plain provideCore() function returning a Provider[], with module-constructor side effects moved to an initializer. Before you convert a single component, grep for @NgModule.providers arrays and forRoot( — that list is your risk register.

7. No regression safety net before refactoring

Teams that skip continuous testing during migration aren't cutting a corner, they're removing the only thing that would tell them the migration is wrong. This shows up constantly on real projects: TestBed configurations built for NgModule-declared components don't automatically work for standalone ones (imports vs declarations semantics differ), so the test suite either silently stops covering what it used to, or fails to compile and gets .skip()'d "temporarily" — which becomes permanent.

Before touching template syntax or component architecture, get characterization tests (even coarse E2E ones with Cypress/Playwright) around the critical user flows. They don't need to be elegant; they need to fail loudly when the migration breaks behavior that unit tests, written against the old structure, won't catch because they get rewritten alongside the code they're testing.

8. Letting CI/CD quietly block the upgrade

Angular's version requirements move fast, and the minimum-patch precision catches people out. Per Angular's own version compatibility reference, v20 and v21 both require Node ^20.19.0 || ^22.12.0 || ^24.0.0 — note that's 20.19, so a runner pinned to Node 20.18 fails even though it's "on Node 20." v22 raises the floor again to ^22.22.3 || ^24.15.0 || ^26.0.0, dropping Node 20 entirely. Each major typically bumps the minimum TypeScript version too. The mistake is discovering this in CI after the code changes are already merged to a branch, because the pipeline's Node image was pinned two years ago and nobody owns updating it. This isn't a framework problem, it's an infrastructure-ownership gap — but it blocks the migration just as effectively as a code bug, and it's invisible until someone actually tries to run ng update.

Check your CI runner's Node/TypeScript versions against the target Angular version's requirements before scheduling the migration work, not after the PR is open.

9. Rewriting state management ad hoc, component by component

Legacy Angular apps accumulate state handling organically: some data lives in BehaviorSubjects in services, some in @Input()/@Output() chains, some in route resolvers, some in whatever global singleton someone added under deadline pressure. The mistake during migration is "fixing" this piecemeal — converting whichever service you happen to be touching to signals, NgRx SignalStore, or a plain signal-based store, with no shared decision about which pattern owns which category of state.

The result, eighteen months later, is a codebase with three competing state paradigms instead of one, which is strictly worse than the single legacy pattern it replaced because now every new developer has to learn which parts of the app use which model. Decide the target state architecture (signals for local/component state, a signal store or NgRx for shared/cross-cutting state, resolvers or route data for navigation-scoped state) before the migration starts, and treat inconsistency with that decision as a review blocker, not a style preference.


The pattern underneath all nine

Almost every mistake here comes from the same root cause: treating the migration as a mechanical version bump instead of an architectural decision with a safety net. The tooling (ng update, the standalone schematic, the control-flow migration) is good, but it's good at syntax transformation, not at verifying behavior or catching the assumptions your codebase quietly baked in around Zone.js, $scope, or singleton services. Budget the audit and testing time as part of the migration, not as cleanup afterward — it's cheaper every time.

Quick pre-migration checklist:

  • Confirm CI's Node/TypeScript versions meet the target Angular version's exact minimums (patch versions matter)
  • Grep for setTimeout/setInterval/raw callbacks mutating component state before turning Zone.js off
  • Inventory services in NgModule providers arrays before any standalone conversion — that's where duplicate-instance risk lives
  • After the v22 Eager migration runs, track the generated annotations as a backlog instead of treating them as settled code
  • Grep for viewChild/ViewChild/ngTemplateOutlet references to any ng-template the control-flow migration touched
  • Get coarse E2E coverage on critical flows before refactoring TestBed configs
  • Pick one target state-management pattern and write it down before the first PR
  • Upgrade one major version at a time — it's enforced, not advisory
  • If you're on an ngUpgrade hybrid, sequence it: treat zoneless as a step after AngularJS is gone, not alongside it

Top comments (0)