JOURNAL

@defer Deferrable Views — The Ecommerce Performance Secret

Post F — A deep dive into Angular 21's @defer deferrable views. All trigger conditions, loading/placeholder/error blocks, combining with SSR, and measuring the real impact on LCP, INP, and bundle size.

angular techlead defer views

Read with AI

Choose content to copy and paste into your AI assistant. Nothing is sent automatically. CMS content is converted to Markdown; original Markdown is used when available.

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:

Component Render Trigger Prefetch Trigger
Recommendations on viewport on idle
Reviews on viewport on idle
Size Guide on interaction on hover
Q&A on interaction none (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 Series — Back to main series overview

Discussion

Comments are reviewed before publication. Your email is kept private.

Add a comment

Name and email are required. Do not include confidential information.

Privacy & data

Loading anti-spam verification…

← Back to allĐọc tiếng Việt