Während klassisches Lazy-Loading immer auf Routenebene oder ganze Komponenten zielte, geht Angular mit @defer einen Schritt weiter: Lazy-Loading direkt im Template – feingranular, deklarativ und mit nativer Unterstützung für Placeholder, Loading-Skeletons und Error-States. Seit Angular 17 stable, seit v19 zusätzlich mit Incremental Hydration für SSR. Das bedeutet: Ganze Sektionen einer Seite werden vom Server gerendert, das passende JavaScript wird aber erst geladen und hydratiert, wenn der Nutzer es tatsächlich braucht. Dieser Artikel zeigt jede Trigger-Variante, das Zusammenspiel aller Sub-Blöcke und reale Use-Cases inklusive Performance-Auswirkung.

Was sind Deferrable Views?

Eine Deferrable View ist ein Template-Block, dessen JavaScript-Code (und damit auch alle darin referenzierten Standalone-Komponenten, Direktiven und Pipes) nicht im initialen Bundle landet, sondern in einen separaten Chunk gesplittet wird. Erst wenn ein definierter Trigger feuert, lädt Angular den Chunk nach und rendert den Inhalt.

Damit unterscheidet sich @defer grundlegend von Route-Level-Lazy-Loading (loadComponent/loadChildren). Letzteres splittet auf Routen-Granularität – eine ganze Seite. @defer splittet auf Template-Granularität – einzelne Sektionen einer bereits gerenderten Seite.

Typische Use-Cases:

  • Below-the-fold-Inhalte wie Footer-Widgets, Kommentar-Sektionen, Related-Posts.
  • Modals und Dialoge, deren JS erst beim Klick gebraucht wird.
  • Marketing-Banner, die zeitversetzt eingeblendet werden.
  • Schwere Visualisierungen wie Charts, Maps, Editoren.

Grundsyntax mit allen Sub-Blöcken

Ein @defer-Block kennt vier Sub-Blöcke. Nur @defer selbst ist verpflichtend, alle anderen sind optional – aber in der Praxis solltest du mindestens @placeholder und @error immer angeben, um Layout-Shifts und stille Fehler zu vermeiden.

HTML dashboard.component.html
<h1>Dashboard</h1>

<!-- Wichtig: Above-the-fold-Content NICHT defern -->
<app-summary-cards />

<!-- Below-the-fold: Heavy Chart wird erst geladen, wenn nötig -->
@defer (on viewport) {
    <app-revenue-chart [data]="chartData()" />
} @placeholder (minimum 500ms) {
    <div class="chart-skeleton" aria-busy="true">
        <div class="bar"></div>
        <div class="bar"></div>
        <div class="bar"></div>
    </div>
} @loading (after 100ms; minimum 1s) {
    <div class="spinner" role="status">
        <span class="visually-hidden">Lade Chart…</span>
    </div>
} @error {
    <p class="error">
        Das Chart konnte nicht geladen werden.
        <button (click)="retry()">Erneut versuchen</button>
    </p>
}

Die Reihenfolge der Anzeige sieht so aus:

  1. @placeholder wird sofort beim ersten Rendern angezeigt.
  2. Sobald der Trigger feuert, beginnt der Download des Chunks.
  3. @loading ersetzt den Placeholder – aber nur, wenn das Laden länger als der after-Wert dauert.
  4. Bei Erfolg rendert der @defer-Block selbst.
  5. Bei Netzwerk- oder Modulfehler springt @error ein.

Die Parameter minimum und after verhindern Flackern: (minimum 500ms) an @placeholder sorgt dafür, dass der Placeholder mindestens eine halbe Sekunde sichtbar bleibt – auch wenn der Chunk in 50ms da ist. (after 100ms) an @loading zeigt den Spinner erst nach 100ms, wenn das Laden also wirklich spürbar dauert.

Trigger-Konditionen im Detail

@defer unterstützt sieben Trigger. Du kannst mehrere kombinieren – sie werden mit Semikolon getrennt und das Laden startet, sobald einer feuert.

TriggerMechanismusTypischer Use-Case
on idlerequestIdleCallbackDefault; lädt während Browser-Leerlauf
on viewportIntersectionObserverBelow-the-fold-Inhalte
on interactionclick / keydownModals, Buttons, Akkordeons
on hovermouseover / focusinTooltip-Vorschau, Mega-Menüs
on timer(2s)setTimeoutMarketing-Banner, Promotions
on immediateSofort nach erstem RenderKonkurrenzlose Hintergrundladung
when exprCustom-BooleanFeature-Flags, Auth-State
HTML trigger-examples.component.html
<!-- Default: on idle (kann weggelassen werden) -->
@defer {
    <app-idle-only />
}

<!-- Viewport: lädt, sobald sichtbar -->
@defer (on viewport) {
    <app-comments />
} @placeholder { <div class="comments-skeleton"></div> }

<!-- Interaction: lädt erst beim Klick -->
@defer (on interaction) {
    <app-emoji-picker />
} @placeholder { <button>Emoji wählen</button> }

<!-- Hover: Tooltip-Vorschau -->
@defer (on hover) {
    <app-rich-tooltip />
} @placeholder { <span class="tooltip-trigger">?</span> }

<!-- Timer: 3 Sekunden nach Render -->
@defer (on timer(3s)) {
    <app-newsletter-modal />
}

<!-- Kombiniert: idle ODER hover, was zuerst kommt -->
@defer (on idle; on hover) {
    <app-related-articles />
} @placeholder { <div class="related-skeleton"></div> }

<!-- Custom-Bedingung -->
@defer (when user.isPremium()) {
    <app-premium-features />
}

Trigger mit Template-Referenz

Manchmal ist das eigentliche Element, an dem der Trigger lauschen soll, nicht im @defer-Block selbst – etwa ein Button, der ein Modal öffnet, dessen Component-Code aber erst geladen werden soll, bevor der Klick passiert.

Dafür akzeptieren viewport, hover und interaction eine Template-Reference-Variable als Argument.

HTML modal-trigger.component.html
<button #openBtn (click)="show.set(true)">
    Hilfe öffnen
</button>

@if (show()) {
    @defer (on interaction(openBtn); prefetch on hover(openBtn)) {
        <app-help-dialog (close)="show.set(false)" />
    } @placeholder {
        <div class="dialog-skeleton" role="dialog" aria-busy="true"></div>
    }
}

Hier wird beim Hovern des Buttons der Chunk schon im Hintergrund prefetcht, beim Klicken dann gerendert. Der Nutzer erlebt eine subjektiv sofortige Reaktion, weil die Datei längst im Cache ist.

viewport(referenceVar) ist außerdem nützlich, wenn du auf ein Sentinel-Element lauschen willst, das selbst billig zu rendern ist, während die schwere Komponente im @defer liegt:

HTML viewport-sentinel.component.html
<article>
    <p>Langer Artikel-Text…</p>
    <div #commentsSentinel style="height: 1px"></div>

    @defer (on viewport(commentsSentinel)) {
        <app-comments [postId]="postId()" />
    } @placeholder {
        <div class="comments-skeleton">Kommentare werden geladen…</div>
    }
</article>

Placeholder, Loading und Timing-Parameter

Die Sub-Blöcke @placeholder und @loading haben zwei Stellschrauben, mit denen du Layout-Shifts und Flicker eliminierst.

ParameterBlockBedeutung
minimum <time>@placeholder, @loadingMindestanzeigedauer – verhindert kurzes Aufblinken
after <time>@loadingVerzögerung, bevor der Loading-State überhaupt erscheint

Praxis-Pattern für Skeleton-Screens, das Flicker auf langsamen wie schnellen Verbindungen vermeidet:

HTML skeleton-pattern.component.html
@defer (on viewport) {
    <app-product-grid [items]="items()" />
} @placeholder (minimum 300ms) {
    <!-- Wird mind. 300ms gezeigt, auch bei Cache-Hit -->
    <div class="grid-skeleton">
        @for (i of [1,2,3,4,5,6]; track i) {
            <div class="card-skeleton"></div>
        }
    </div>
} @loading (after 200ms; minimum 500ms) {
    <!-- Erst nach 200ms sichtbar, dann mind. 500ms -->
    <div class="loading-overlay" role="status">
        <span>Lade Produkte…</span>
    </div>
}

Render-Trigger und Prefetch-Trigger trennen

Eine der mächtigsten Eigenschaften von @defer ist die strikte Trennung zwischen Wann lade ich den Code? und Wann zeige ich ihn?. Mit prefetch on … definierst du einen Prefetch-Trigger, der den Chunk im Hintergrund zieht, ohne ihn schon zu rendern.

TypeScript checkout-page.component.ts
import { Component, signal } from '@angular/core';

@Component({
    selector: 'app-checkout',
    standalone: true,
    template: `
        <h1>Warenkorb</h1>
        <ul>
            @for (item of cart(); track item.id) {
                <li>{{ item.name }} – {{ item.price | currency }}</li>
            }
        </ul>

        <button #payBtn (click)="startPay.set(true)">
            Zur Kasse
        </button>

        @if (startPay()) {
            @defer (on immediate; prefetch on hover(payBtn)) {
                <app-payment-flow [cart]="cart()" />
            } @placeholder {
                <div class="payment-skeleton"></div>
            } @error {
                <p>Zahlungsmodul nicht verfügbar.</p>
            }
        }
    `
})
export class CheckoutComponent {
    cart = signal<CartItem[]>([]);
    startPay = signal(false);
}

interface CartItem { id: string; name: string; price: number; }

Beim Hover über den Pay-Button beginnt der Browser, das Payment-Bundle zu laden. Klickt der Nutzer eine Sekunde später, ist der Code da – die Komponente erscheint sofort statt erst nach dem Roundtrip.

Incremental Hydration ab Angular 19

Mit Server-Side Rendering hatte @defer ursprünglich ein Problem: Wenn der Server alles rendert und der Client alles hydratiert, gibt es kein „Defer” mehr – das JS lädt sowieso. Seit Angular 19 löst Incremental Hydration das. Du aktivierst es einmalig in der App-Config:

TypeScript app.config.ts
import { ApplicationConfig } from '@angular/core';
import {
    provideClientHydration,
    withIncrementalHydration
} from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
    providers: [
        provideClientHydration(withIncrementalHydration())
        // withEventReplay() ist automatisch aktiv
    ]
};

Anschließend nutzt du hydrate on … statt – oder zusätzlich zu – on …:

HTML ssr-deferred.component.html
<!-- Vom Server gerendert, hydriert erst beim Sichtbar-Werden -->
@defer (hydrate on viewport) {
    <app-comments [postId]="postId()" />
} @placeholder {
    <div>Kommentare</div>
}

<!-- Initial vom Client gelazyloadet, danach hydriert per Klick -->
@defer (on idle; hydrate on interaction) {
    <app-share-widget />
}

<!-- Nie hydrieren – reines HTML aus dem SSR -->
@defer (hydrate never) {
    <app-static-footer />
}

Der Kniff: Das HTML ist sofort da (gut für Time-to-First-Byte und Lighthouse), aber das JS für diese Sektion wird erst geladen und ausgeführt, wenn der Nutzer dort scrollt, klickt oder es auf andere Weise „antippt”. Das senkt First Input Delay massiv.

Was kann (nicht) deferred werden?

@defer funktioniert nur korrekt, wenn alle Abhängigkeiten innerhalb des Blocks standalone sind und außerhalb des Blocks nicht referenziert werden. Sobald irgendwo im Template außerhalb des @defer ein Selektor der gleichen Komponente steht (oder ein @ViewChild darauf zeigt), nennt Angular das eine eager reference – und der Compiler legt die Komponente ins Haupt-Bundle.

TypeScript eager-reference-trap.component.ts
import { Component } from '@angular/core';
import { HeavyChartComponent } from './heavy-chart.component';

@Component({
    selector: 'app-dashboard',
    standalone: true,
    imports: [HeavyChartComponent],
    template: `
        <!-- ❌ Diese Referenz ist eager – HeavyChart landet im Haupt-Bundle! -->
        <app-heavy-chart [data]="summary()" />

        @defer (on viewport) {
            <!-- @defer ist hier wirkungslos, weil oben schon eager referenziert -->
            <app-heavy-chart [data]="details()" />
        }
    `
})
export class DashboardComponent { /* … */ }

Ebenso ein Problem: NgModule-basierte Komponenten im @defer. Sie werden trotzdem eager geladen, weil das Modul-System keine feingranularen Chunks erlaubt. Faustregel: Im @defer-Block ausschließlich standalone: true-Komponenten verwenden.

PatternFunktioniert mit @defer?
Standalone-Component nur im @defer referenziertJa – idealer Fall
Standalone-Component zusätzlich außerhalb referenziertNein – wird eager geladen
NgModule-basierte Component im @deferNein – wird eager geladen
Pipe oder Direktive aus Standalone-ImportJa – wird mit gesplittet
@ViewChild/@ContentChild auf deferred ComponentNein – Referenz ist eager

Was Deferrable Views besonders macht

Compiler-Magie statt manuelle Imports

Klassisches Lazy-Loading erfordert import(’./heavy’) und Promise-Handling. Mit @defer erkennt der Angular-Compiler die Abhängigkeiten, die ausschließlich im Block referenziert sind, und baut den passenden esbuild-Chunk inklusive Lade-Logik vollautomatisch. Du schreibst null Boilerplate-JS für Code-Splitting.

@placeholder muss statisch sein

Im Placeholder darfst du keine schweren Standalone-Komponenten oder teure Pipes referenzieren – sonst wandern deren Abhängigkeiten ins Haupt-Bundle und der ganze Sinn von @defer ist dahin. Halte den Placeholder bewusst leichtgewichtig: HTML, CSS-Klassen, einfache Interpolationen.

Eager References sabotieren stillschweigend

Wird eine Komponente einmal außerhalb des @defer-Blocks im selben Template referenziert (oder per @ViewChild abgefragt), zieht der Compiler sie eager ins Hauptbundle. Der @defer-Block läuft technisch weiter, der Effekt ist aber null. Aktiviere im Build den Defer-Diagnose-Output, um solche Fälle zu finden.

Incremental Hydration löst SSR + @defer

Vor v19 war Hydration ein Alles-oder-Nichts-Schritt: Sobald der Browser hydratisierte, lud sich auch jeder @defer-Block. Mit withIncrementalHydration() bleibt das HTML aus dem SSR stehen, das JS für die Sektion kommt erst beim Trigger – riesiger FID-Vorteil bei Marketing-Seiten und Blogs.

Trigger lassen sich frei kombinieren

Du kannst beliebige Trigger mit Semikolon stapeln: @defer (on idle; on hover(btn); on timer(5s)) lädt, sobald der erste feuert. Der separate prefetch on …-Trigger ist davon unabhängig und lässt dich Code im Hintergrund vorab ziehen, ohne ihn schon zu rendern.

viewport(reference) entkoppelt Sichtbarkeit von Inhalt

Wenn der eigentliche @defer-Inhalt initial gar nicht im DOM ist – etwa hinter einem @if –, funktioniert on viewport nicht ohne Argument. Mit einer Template-Variable auf einem leichten Sentinel-Element (z. B. ein 1px hohes div) machst du jede beliebige Stelle der Seite zum Trigger.

@defer ergänzt Route-Lazy-Loading, ersetzt es nicht

Lazy-Loaded Routen splitten ganze Seiten und sind weiterhin der erste Hebel für Bundle-Größe. @defer wirkt feingranular innerhalb einer schon geladenen Route. Die Kombination – lazy Route plus mehrere @defer-Sektionen darin – bringt in Praxis-Apps oft 40–60 % kleinere initial geladene Bytes.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Templates & Control Flow

Zur Übersicht