Angular

Angular @defer Explained: How and When to Use It

How Angular's built-in @defer block works, the trigger types available, and practical patterns for deferring non-critical UI.

Tayyab Roy7 min read
Share:

Before @defer, lazy-loading a chunk of template in Angular meant either lazy-loading an entire route or manually wiring up NgComponentOutlet with a dynamic import, then handling loading states and errors yourself. It worked, but almost nobody bothered unless a component was genuinely huge. @defer makes this trivial enough that it’s now worth doing for a lot more of your UI than you’d think — comment sections, charts, below-the-fold widgets, modals that aren’t open on load.

The Basic Syntax

@defer wraps a block of template and splits it (and the components it depends on) into a separate JavaScript chunk that loads based on a trigger condition.

@defer {
  <app-product-reviews [productId]="product.id" />
} @placeholder {
  <div class="reviews-placeholder">Loading reviews…</div>
} @loading (minimum 200ms) {
  <app-spinner />
} @error {
  <p>Couldn't load reviews right now.</p>
}
  • @placeholder renders immediately, before the trigger fires.
  • @loading renders once the trigger has fired and the chunk is being fetched. minimum 200ms prevents a flash of a loading spinner for requests that resolve almost instantly.
  • @error renders if the dynamic import fails (network failure, chunk load error).

By default, with no explicit trigger, the block defers on idle — meaning it loads when the browser is idle after the rest of the view has rendered.

Trigger Types

on viewport — loads when the placeholder scrolls into the viewport. This is the one you’ll use most for below-the-fold content.

@defer (on viewport) {
  <app-comments-section [postId]="post.id" />
} @placeholder (minimum 100ms) {
  <div class="comments-placeholder"></div>
}

on interaction — loads on click or keydown on the placeholder (or a referenced element).

@defer (on interaction) {
  <app-share-panel [url]="currentUrl" />
} @placeholder {
  <button>Share</button>
}

on hover — loads on mouseenter/focusin. Good for preview panels or tooltips with heavy content.

on idle — the default; loads using requestIdleCallback once the browser has spare cycles.

on timer(Xms) — loads after a fixed delay. Useful for things like a “rate this app” prompt or a non-critical banner that shouldn’t compete with initial render.

@defer (on timer(5s)) {
  <app-feedback-prompt />
}

on immediate — loads as soon as possible after the rest of the page renders, essentially the earliest point the deferred chunk can start fetching. Different from on idle in that it doesn’t wait for the browser to be idle — it loads right away but still outside the initial render’s critical path.

You can also combine triggers with a comma, and reference another element as the trigger source using #ref:

<div #trigger>Scroll to load chart</div>

@defer (on viewport(trigger); on timer(10s)) {
  <app-sales-chart [data]="salesData" />
} @placeholder {
  <div class="chart-skeleton"></div>
}

Whichever trigger fires first wins.

Prefetching

You can separate when the chunk is fetched from when it’s rendered, using prefetch:

@defer (on interaction; prefetch on idle) {
  <app-checkout-modal />
} @placeholder {
  <button>Checkout</button>
}

Here, the JS chunk is downloaded during idle time (so it’s ready instantly), but the component isn’t actually instantiated and rendered until the user clicks. This is the pattern to reach for when you want to hide bundle weight from the initial load without making the user wait when they do interact.

A Real Example: Deferring a Heavy Chart

Say you have a dashboard page where a chart library (something like Chart.js or D3-based component) pulls in a non-trivial amount of JS, but the chart sits below the fold.

<section class="dashboard-summary">
  <app-kpi-cards [metrics]="metrics" />
</section>

<section class="dashboard-chart">
  @defer (on viewport) {
    <app-revenue-chart [data]="revenueData" />
  } @placeholder (minimum 150ms) {
    <div class="chart-placeholder" style="height: 320px;">
      <!-- reserve the real height to avoid layout shift -->
    </div>
  } @loading {
    <app-spinner />
  } @error {
    <p class="chart-error">Chart failed to load. <button (click)="retry()">Retry</button></p>
  }
</section>

The KPI cards (likely your LCP element or close to it) render immediately in the main bundle. The chart component and its charting library dependency ship in a separate chunk that only downloads once the user scrolls near it.

Why This Matters for Core Web Vitals

The initial JS bundle is smaller because deferred components (and anything they import) are split into separate chunks that the browser only fetches when the trigger condition is met. That generally helps Largest Contentful Paint, since there’s less JS competing for the main thread and network during the initial render, and it can help Interaction to Next Paint by keeping the main thread less busy early on. I won’t put a number on “how much” — it depends entirely on how heavy the deferred component and its dependencies are relative to the rest of your bundle, and on the device/network conditions you’re testing against. The honest framing is: @defer removes non-critical work from the critical rendering path, which is directionally good for LCP and INP, not a guaranteed fixed improvement.

For more general bundle and rendering-path strategies beyond @defer itself, see Angular Performance Optimization and the broader, framework-agnostic Frontend Performance Optimization Techniques.

Common Mistakes

Deferring above-the-fold or LCP-candidate content. If the deferred block contains your hero image, headline, or primary CTA, you’ve just delayed your LCP element and made things worse. @defer is for content the user doesn’t need immediately, not for everything indiscriminately.

Omitting @placeholder and causing layout shift. If you don’t reserve space with a placeholder sized close to the real content’s dimensions, the deferred component popping in will shift everything below it — directly hurting Cumulative Layout Shift. Always give your placeholder an explicit height or a skeleton that approximates the real layout.

Using on viewport for content that’s already visible on load. If the block is already in the viewport on initial render, on viewport effectively behaves like on immediate — you haven’t actually deferred anything meaningful, you’ve just added a loading state for no benefit.

Forgetting that deferred components must be standalone. @defer requires the component (and anything else in the block) to be a standalone component, directive, or pipe — it can’t lazily load something that’s only declared in an NgModule. If you’re still working through the standalone migration, Angular Standalone Components is worth reading alongside this.

Over-fragmenting with too many small @defer blocks. Each deferred block is a separate chunk request. If you wrap a dozen tiny components each in their own @defer, you can end up with more network overhead (many small requests) than the savings are worth. Group related non-critical UI into fewer, more meaningful deferred boundaries.

When NOT to Use It

Skip @defer for anything critical to the first meaningful paint, anything the user interacts with immediately (primary navigation, main form fields), and very small components where the overhead of a separate chunk and loading states isn’t worth it. It’s a tool for genuinely non-critical, often below-the-fold or interaction-gated UI — not a blanket wrapper for every component in your app.

Nested @defer Blocks

Deferred blocks can be nested, and Angular will only start evaluating an inner block’s trigger once the outer block has actually rendered. This is useful for a widget that itself contains a secondary, even-less-critical piece of UI:

@defer (on viewport) {
  <app-product-detail [id]="productId" />

  @defer (on interaction) {
    <app-full-specs-table [id]="productId" />
  } @placeholder {
    <button>View full specifications</button>
  }
} @placeholder {
  <div class="product-detail-skeleton"></div>
}

The specs table’s on interaction trigger doesn’t even get attached until the outer app-product-detail block has loaded and rendered, which keeps the dependency graph predictable instead of trying to wire up every trigger eagerly on page load.

Testing Deferred Content

In component tests, Angular’s testing utilities let you force a deferred block into a particular state instead of waiting on real triggers. With TestBed and the fixture.componentRef, you can use the DeferBlockBehavior.Manual configuration (or, in newer testing APIs, helper methods to render a specific deferred block state) to assert on placeholder, loading, and error states deterministically rather than trying to simulate a real viewport intersection or timer in a unit test. If you’re introducing @defer into a codebase with existing component tests, it’s worth checking early whether any tests assert on the deferred content being present immediately after fixture.detectChanges() — those will need to be updated to either trigger the defer condition or assert against the placeholder first.

#Angular#Performance#Lazy Loading#@defer

Written by

Tayyab Roy

Senior Frontend / Full-Stack Engineer

Senior Frontend & Full-Stack Engineer specializing in Angular, React, Next.js and enterprise eCommerce (Shopify, SAP Commerce / Hybris, Spartacus). I write about practical, production-tested frontend engineering.