The product detail page is the most performance-critical page in any ecommerce application. It’s what Google indexes for product searches. It’s where your LCP score is measured. And it carries enormous JavaScript weight — product images, reviews, recommended products, ratings, Q&A sections.

@defer is Angular 21’s answer to progressive loading: load the critical content immediately, defer everything else until the browser is ready.

What @defer Does

@defer wraps a section of template content and defers its JavaScript bundle loading until a specified trigger fires. Until then, it renders a @placeholder. When loading, it optionally renders a @loading block.

This differs from lazy-loaded routes: @defer works inside a template, not at the route level. You can defer individual components within the same page.

All Trigger Conditions

<!-- Triggers when the placeholder enters the viewport -->
@defer (on viewport) { <app-reviews /> }

<!-- Triggers immediately when Angular boots (but after initial render) -->
@defer (on idle) { <app-chat-widget /> }

<!-- Triggers on first interaction (click, keydown, touch) with the placeholder -->
@defer (on interaction) { <app-size-guide /> }

<!-- Triggers when the user hovers over the placeholder -->
@defer (on hover) { <app-quick-view /> }

<!-- Triggers on an explicitly named element -->
@defer (on viewport(productSection)) { <app-spec-table /> }

<!-- Triggers when a signal/observable becomes truthy -->
@defer (when isExpanded()) { <app-product-details /> }

<!-- Immediately (no trigger — just breaks into a separate chunk) -->
@defer { <app-non-critical /> }

The Full Block Structure

@defer (on viewport) {
  <!-- The actual content — loaded and rendered when trigger fires -->
  <app-product-reviews [productId]="product().id" />
}
@loading (minimum 300ms; after 100ms) {
  <!-- Shown while the chunk is downloading -->
  <!-- minimum: always show for at least 300ms (prevents flash) -->
  <!-- after: only show if loading takes longer than 100ms -->
  <div class="review-skeleton" aria-busy="true">
    @for (_ of [1,2,3]; track $index) {
      <div class="skeleton-card"></div>
    }
  </div>
}
@placeholder (minimum 200ms) {
  <!-- Shown before trigger fires — must be lightweight -->
  <!-- minimum: stay as placeholder for at least 200ms -->
  <div class="review-placeholder">
    <p>Customer reviews loading...</p>
  </div>
}
@error {
  <!-- Shown if the chunk fails to download -->
  <p>Reviews unavailable. <button (click)="retryReviews()">Retry</button></p>
}

Product Detail Page — Full Defer Strategy

<!-- product-detail.component.html -->

<!-- ABOVE THE FOLD: Never defer — render immediately (SSR) -->
<section class="product-hero">
  <img [ngSrc]="product().imageUrl" priority width="600" height="600" />
  <h1>{{ product().name }}</h1>
  <p class="price">{{ product().price | currency }}</p>
  <app-stock-indicator [stock]="product().stockQty" />
  <button (click)="addToCart()">Add to Cart</button>
</section>

<!-- PRODUCT SPEC TABLE: Defer on idle (non-critical but useful) -->
@defer (on idle) {
  <app-spec-table [specs]="product().specs" />
} @placeholder {
  <div class="spec-placeholder" style="height: 200px"></div>
}

<!-- RECOMMENDATIONS: Defer on viewport (heavy, below fold) -->
@defer (on viewport; prefetch on idle) {
  <app-recommended-products [productId]="product().id" />
} @loading (minimum 200ms) {
  <div class="product-grid-skeleton">
    @for (_ of [1,2,3,4]; track $index) {
      <div class="skeleton-card" style="height:320px"></div>
    }
  </div>
} @placeholder {
  <div style="height: 350px"></div>  <!-- Reserve space to prevent CLS -->
}

<!-- REVIEWS: Defer on viewport (network call needed) -->
@defer (on viewport) {
  <app-product-reviews [productId]="product().id" />
} @loading (minimum 400ms; after 150ms) {
  <app-review-skeleton />
} @placeholder (minimum 300ms) {
  <div class="review-placeholder"></div>
} @error {
  <p>Unable to load reviews.</p>
}

<!-- Q&A SECTION: Defer on interaction (rarely used) -->
<h2 id="qa-toggle">Questions & Answers</h2>
@defer (on interaction(qaToggle)) {
  <app-product-qa [productId]="product().id" />
} @placeholder {
  <p>Click to load Q&amp;A section</p>
}

Prefetching — Load Before Trigger

@defer supports a separate prefetch trigger — the chunk is downloaded early but not rendered until the main trigger fires.

<!-- Prefetch the chunk on idle, but render only on viewport -->
<!-- This gives the bundle a head start, reducing perceived latency -->
@defer (on viewport; prefetch on idle) {
  <app-recommended-products />
}

TechShop prefetch strategy:

ComponentRender TriggerPrefetch Trigger
Recommendationson viewporton idle
Reviewson viewporton idle
Size Guideon interactionon hover
Q&Aon interactionnone (too large)

SSR + @defer

By default, @defer placeholder content is server-rendered. The main content is deferred to client-side.

This is intentional: the placeholder renders fast (SSR), giving users immediate visual feedback, while the heavy component loads in the background.

<!-- What happens with SSR: -->
<!-- Server:  renders @placeholder content → sent to browser as HTML -->
<!-- Browser: receives HTML with placeholder, Angular hydrates -->
<!-- Trigger fires: loads chunk, replaces placeholder with real component -->
@defer (on viewport) {
  <app-reviews />           <!-- SSR skips this — loads client-side -->
} @placeholder {
  <div class="placeholder" /> <!-- SSR renders this → immediate visible HTML -->
}

To force server-rendering of the deferred content (rare), use withDeferBlockBehavior(DeferBlockBehavior.Playthrough) in SSR config.

Measuring the Impact

Before adding @defer:

  • Product detail initial bundle: 180KB JS
  • LCP: 3.1s (Lighthouse mobile)
  • Reviews component blocking main thread: 280ms

After @defer on reviews + recommendations:

  • Product detail initial bundle: 95KB JS (-47%)
  • LCP: 1.8s ✅
  • Reviews load after visible: 0ms main thread impact
# Measure bundle chunk sizes after build
nx build shell --configuration=production --stats-json
npx webpack-bundle-analyzer dist/apps/shell/stats.json

Look for @defer chunk files in the output — each deferred block becomes its own async chunk.


References


Part of the Angular Tech Lead SeriesBack to main series overview

Export for reading

Comments