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

Claire ChengClaire Cheng
8 min read

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 and ngFor 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:

PhaseStructural Directives (*ngIf/*ngFor)Control Flow (@if/@for)
PreparationRuntime directive interpretationPre-compiled JavaScript
MemoryDirective instances + ViewsJust DOM nodes
DOM UpdatesFull view recreationsSurgical edits via diffing
Change DetectionPer-directive checksComponent-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:

  1. Angular destroys the "No tasks available" message

  2. Angular creates a brand new task list from scratch

  3. The browser has to repaint the screen

The *ngFor problem: Without a trackBy 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

Relevant Source

@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

Relevant Source

@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.

0
Subscribe to my newsletter

Read articles from Claire Cheng directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Claire Cheng
Claire Cheng