From Runtime to Compile Time: How Angular's New Control Flow Actually Works


In Angular 20, the old structural directives—
*ngIf
,*ngFor
, and*ngSwitch
—are officially deprecated. Intent to remove in v22. They’ve been replaced by@if
,@for
, and@switch
.This isn’t just syntax sugar. It’s a core change in how Angular compiles templates and handles rendering.
So if you haven’t tried @if
, @for
, and @switch
yet, now’s a good time to start. Not just because they’re newer—but because they work differently under the hood.
What We'll Cover
In this article, we'll explore:
Why structural directives hit performance limits
How the old system works behind the scenes
What the new control flow brings to the table
Real performance gains you can expect
Why Structural Directives Hit a Limit
Let’s go back to something we’ve all done: building a simple task list. You want two things to show up on the screen:
A list of tasks, when there are tasks.
A message saying “No tasks available,” when the list is empty.
We used
ngIf
andngFor
all the time. They got the job done—but they come with hidden costs—extra memory, unnecessary DOM nodes, and runtime overhead that wasn’t obvious until things got bigger.
@Component({
selector: 'app-task-list',
template:`
<div *ngIf="tasks.length === 0">
No tasks available.
</div>
<div *ngIf="tasks.length > 0">
<ul>
<li *ngFor="let task of tasks">
{{ task.name }} - {{ task.status }}
</li>
</ul>
</div>
`
})
export class TaskListComponent {
tasks = [
{ id: 1, name: 'Write report', status: 'Pending' },
{ id: 2, name: 'Send email', status: 'Done' },
];
}
And yes—it works. But if your task list grows, something strange happens. The app starts to slow down. Transitions feel heavy. DOM updates take longer than they should.
The Performance Difference
Here's how the two approaches stack up:
Phase | Structural Directives (*ngIf /*ngFor ) | Control Flow (@if /@for ) |
Preparation | Runtime directive interpretation | Pre-compiled JavaScript |
Memory | Directive instances + Views | Just DOM nodes |
DOM Updates | Full view recreations | Surgical edits via diffing |
Change Detection | Per-directive checks | Component-level evaluation |
The numbers: For 100 tasks,
*ngFor
can use 100–200 KB just for views, directives, and comment nodes. The new Control Flow syntax eliminates most of this overhead.
Structural Directives at Runtime: Here’s what happens backstage
Here's what happens when your app runs:
When the page loads:
If there are no tasks, NgIf creates the "No tasks available" message
If there are tasks, a different NgIf creates the task list
Angular adds invisible comment nodes like
<!--ngIf-->
to remember where everything goes
When you add a task: This is where things get expensive. Let's say you go from zero tasks to one task:
Angular destroys the "No tasks available" message
Angular creates a brand new task list from scratch
The browser has to repaint the screen
The
*ngFor
problem: Without atrackBy
function,*ngFor
can't tell which tasks are old and which are new. So when you add just one task to a list of 100, Angular might rebuild all 100 items instead of just adding the new one.
┌───────────────────────────────────────┐
│ Structural Directives Runtime Flow │
└───────────────┬───────────────────────┘
│
┌───────────────▼───────────────────────┐
│ *ngIf and *ngFor parsed as templates │
│ and turned into ng-template stubs │
└───────────────┬───────────────────────┘
│
┌───────────────▼───────────────────────┐
│ Angular creates directive instances │
│ (NgIf / NgForOf) │
└───────────────┬───────────────────────┘
│
┌───────────────▼───────────────────────┐
│ Embedded views created/destroyed │
│ dynamically with comment anchors │
└───────────────┬───────────────────────┘
│
┌───────────────▼───────────────────────┐
│ DOM operations triggered (insert, │
│ remove, move nodes) │
└───────────────┬───────────────────────┘
│
┌───────────────▼───────────────────────┐
│ Change detection processes each view │
│ with independent directive logic │
└───────────────────────────────────────┘
Each directive
(like NgIf
or NgForOf
) is like an actor on stage—with memory needs
, update routines
, and change detection hooks
. Multiply that by 100 tasks, and you’ve got 100 tiny actors all doing their own thing. Even worse, You’ll see comment nodes like <!--ngIf-->
in the DOM—Angular uses them to remember where things should be rendered.
What Structural Directives Are Really Doing
When Angular’s compiler parses your template and sees ngIf
or ngFor
, it doesn’t just render them. It rewrites them into <ng-template>
elements behind the scenes. You can think of it as placeholders in the DOM—Angular uses them to mark where content will be rendered when the condition becomes true.
Angular.Dev: Structural directives can be applied directly on an element by prefixing the directive attribute selector with an asterisk (
*
), such as*select
. Angular transforms the asterisk in front of a structural directive into an<ng-template>
that hosts the directive and surrounds the element and its descendants.
NgIf Directive simplified Example
@Directive({ selector: '[ngIf]' })
export class NgIf<T = unknown> {
@Input() set ngIf(condition: T) {
if (condition) {
this._viewContainer.createEmbeddedView(this._template);
} else {
this._viewContainer.clear();
}
}
constructor(
private _viewContainer: ViewContainerRef,
private _template: TemplateRef<NgIfContext<T>>
) {}
}
NgForOf Directive simplified Example
@Directive({ selector: '[ngFor][ngForOf]' })
export class NgForOf<T> {
@Input() set ngForOf(ngForOf: NgIterable<T>) {
this._differ = this._differs.find(ngForOf).create(this.ngForTrackBy);
this._updateViews();
}
private _updateViews() {
const diff = this._differ.diff(this._ngForOf);
if (diff) {
diff.forEachOperation((item, adjustedPreviousIndex, currentIndex) => {
if (item.previousIndex == null) {
this._viewContainer.createEmbeddedView(...);
} else if (currentIndex == null) {
this._viewContainer.remove(adjustedPreviousIndex);
}
});
}
}
constructor(
private _viewContainer: ViewContainerRef,
private _template: TemplateRef<NgForOfContext<T>>,
private _differs: IterableDiffers
) {}
}
ngIf Explanation
The NgIf directive uses ViewContainerRef.createEmbeddedView
to create an embedded view from the TemplateRef
(the content) when the condition is true, and clear to destroy it when false.
ngFor Explanation
Similarly, ngFor
becomes NgForOf
, which manages a view for every item in the list. These directive instances are real JavaScript objects, lifecycle hooks like ngOnChanges
, and logic that runs every time change detection hits.
Change detection
And remember, Angular’s change detection
is constantly scanning for changes. It doesn’t just glance at tasks—it re-evaluates each NgIf
and NgForOf
, and checks each view inside the loop. For 100 tasks, that means managing 100 individual views, each with its own memory, DOM nodes, and reactive checks. The memory adds up fast—*ngFor
alone can use up to 100–200 KB just for its views, directives, and comment nodes.
How Control Flow Solves It
With Angular 17, we can write the same task list using @if
and @for
. The new control flow syntax moves logic into the compiler, so the template gets simpler, and the app runs more efficiently.
// task-list.component.ts
import { Component } from '@angular/core';
@Component({
selector: 'app-task-list',
template: `
@if (tasks.length === 0) {
<div>No tasks available.</div>
} @else {
<ul>
@for (task of tasks; track task.id) {
<li>{{ task.name }} - {{ task.status }}</li>
}
</ul>
}`,
})
export class TaskListComponent {
tasks = [
{ id: 1, name: 'Write report', status: 'Pending' },
{ id: 2, name: 'Send email', status: 'Done' },
];
}
Now the app updates faster and uses less memory. To understand why, let’s walk through how the new control flow works behind the scenes.
Control Flow at Compile Time: Here’s what happens backstage
┌───────────────────────────────────────┐
│ AOT Compilation Phase │
│ Converts to optimized JavaScript │
└───────────────┬───────────────────────┘
│
┌───────────────▼───────────────────────┐
│ Static Analysis + Optimization │
└───────────────┬───────────────────────┘
│
┌───────────────┴───────────────────────┐
│ @if: Condition Met? @for: Diffing │
└───────────────┬───────────────────────┘
│ Yes
┌───────────────▼─────┐ ┌─────────────┐
│ Render DOM Block │───────►│ Render │
└─────────────────────┘ │ DOM Block│
└─────────────┘
│
┌───────────────▼───────────────────────┐
│ @for: Diffing Algorithm with `track` │
│ (Reuses existing DOM nodes) │
└───────────────┬───────────────────────┘
│
┌───────────────▼───────────────────────┐
│ Efficient Change Detection │
│ (Fewer Checks, Integrated Logic) │
└───────────────┬───────────────────────┘
│
┌───────────────▼───────────────────────┐
│ Benefits: │
│ • No directive objects │
│ • Clean DOM (no comment nodes) │
│ • Minimal reflows │
│ • Lower memory usage │
└───────────────────────────────────────┘
How the New Control Flow Works
Before your app runs (compile time): Angular looks at your @if
and @for
statements and converts them into plain JavaScript. No more directive classes, no more complex setup. Just simple if statements and loops.
When your app runs: The code is already optimized and ready to go:
Need to show "No tasks available"? Just insert that div
Need to show tasks? Create the list in one go
No comment nodes cluttering up your DOM
When you add a task: Here's where it gets smart. The @for
block compares your new task list with the old one:
It keeps existing tasks exactly where they are
It only adds the new task
With
track task.id
, it knows which is which
Memory savings: In terms of memory usage, @for
doesn’t create separate directive instances or embedded views. For two tasks, you only get a <ul> and two <li> elements.
Old way: 100 tasks = 100 DOM nodes + 100 directive objects + comment nodes
New way: 100 tasks = just 100 DOM nodes
The Key Differences
Structural Directives
What Happens:
Runtime
directives cast actors and build sets on the fly, dropping comment nodes like stage clutter.Journey:
Parse template → create directives → make views → update DOM → check each directive
.Result: Works but inefficient.
Runtime Cost: Each directive brings memory overhead, and Angular inserts comment nodes (
<!--ngIf-->, <!--ngFor-->
) as anchors in the DOM. Change detection checks each directive separately, adding to processing time as the UI grows.
Built-in Control Flow
What Happens: The template is compiled into plain JavaScript control flow logic. Instead of creating directive instances, Angular uses a compiled instruction set that directly manages the DOM with minimal overhead.
Journey:
Compile template → evaluate conditions and loops → apply DOM changes → run once per component render cycle
.Result: Efficient updates, smaller memory footprint, and a cleaner DOM with no comment anchors or directive wrappers.
Runtime Cost: Eliminates structural directive classes and their memory overhead. DOM nodes are created and updated directly, and diffing logic in
@for
ensures only the necessary changes are applied.
Looking Ahead
The move away from structural directives isn’t just a new syntax—it’s a new model. Templates become easier to understand, the DOM gets cleaner, and performance costs are no longer hidden behind abstractions.
And with @defer
in the same family, we get more control over when things render—keeping the initial load light and shifting work to when it actually matters.
More changes are coming, and I’ll be exploring them in future articles.
Subscribe to my newsletter
Read articles from Claire Cheng directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
