Prefetch on demand

The Prefetch on Demand component enables intelligent, user-driven page prefetching to dramatically improve navigation performance in your Progressive Web App. Instead of relying solely on static <link rel="prefetch"> tags, this component allows you to dynamically prefetch pages based on user interactions like hovering over links, scrolling into view, or any custom trigger. Pages are cached by the service worker via Workbox, making subsequent navigation instantaneous.

This component is particularly useful for:

  • Speeding up navigation in content-heavy websites (blogs, news sites, documentation)

  • Preloading likely next pages in multi-step forms or checkout flows

  • Improving perceived performance in single-page applications

  • Caching related articles, author pages, or recommended content

  • Prefetching resources when users hover over navigation menus

  • Loading content as users scroll down infinite-scroll pages

  • Preparing next chapters in e-learning platforms

  • Optimizing user experience in catalog browsing and e-commerce

Browser Support

The Prefetch on Demand component relies on service workers and the Cache API, which are widely supported across modern browsers.

Support level: Excellent - Works on all modern browsers that support service workers (Chrome, Firefox, Safari, Edge).

This component requires an active service worker with Workbox configured. Ensure your PWA Bundle service worker is properly registered before using this component.

Traditional Prefetching vs. On-Demand

Static Prefetching

Traditional HTML-based prefetching uses link tags:

<link rel="prefetch" href="/article-2">
<link rel="prefetch" href="/article-3">
<link rel="prefetch" href="/author-18">

Limitations:

  • Prefetching starts immediately on page load

  • No control over timing or conditions

  • Browser may ignore hints based on connection type or battery

  • Cannot respond to user behavior

Dynamic On-Demand Prefetching

The Prefetch on Demand component offers more flexibility:

  • Trigger prefetching based on user interactions (hover, scroll, click)

  • Programmatically control when and what to prefetch

  • Cache resources through service worker for reliable offline access

  • Respond to user behavior and intent

Usage

Basic Hover-Based Prefetching

<section {{ stimulus_controller('@pwa/prefetch-on-demand') }}>
    <main>
        Lorem ipsum dolor sit amet, consectetur adipiscing elit.
        Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
    </main>
    <aside {{ stimulus_action('@pwa/prefetch-on-demand', 'prefetch', 'mouseover', {urls: ['/author-18', '/article-2', '/article-3']}) }}>
        Author: <a href="/author-18">John Doe</a>
        Other articles: <a href="/article-2">How to Foo</a>
        Other articles: <a href="/article-3">How to Bar</a>
    </aside>
</section>

When users hover over the aside section, the specified URLs are prefetched and cached.

<div {{ stimulus_controller('@pwa/prefetch-on-demand') }}>
    <h2>Related Articles</h2>
    <ul>
        <li>
            <a href="/articles/getting-started"
               {{ stimulus_action('@pwa/prefetch-on-demand', 'prefetch', 'mouseenter', {
                   urls: ['/articles/getting-started']
               }) }}>
                Getting Started Guide
            </a>
        </li>
        <li>
            <a href="/articles/advanced-topics"
               {{ stimulus_action('@pwa/prefetch-on-demand', 'prefetch', 'mouseenter', {
                   urls: ['/articles/advanced-topics']
               }) }}>
                Advanced Topics
            </a>
        </li>
    </ul>
</div>

Intersection Observer - Prefetch on Scroll


</div>

### Programmatic Prefetching

<div data-gb-custom-block data-tag="code" data-lineNumbers='true'>

```twig
<div {{ stimulus_controller('@pwa/prefetch-on-demand') }}>
    <button id="load-next-chapter">Next Chapter</button>
    <div id="chapter-content"></div>
</div>

<script type="module">
  const host = document.querySelector('[data-controller="pwa__prefetch-on-demand"]');
  let currentChapter = 1;

  document.getElementById('load-next-chapter').addEventListener('click', async () => {
    // Prefetch next two chapters when user loads one
    const nextChapter = currentChapter + 1;
    const chapterAfterNext = currentChapter + 2;

    await host.controller.prefetch({
      params: {
        urls: [
          `/chapters/${nextChapter}`,
          `/chapters/${chapterAfterNext}`
        ]
      }
    });

    // Load current chapter
    loadChapter(currentChapter++);
  });

  // Listen for prefetch confirmation
  host.addEventListener('prefetch-on-demand:prefetched', (e) => {
    console.log('Prefetched URLs:', e.detail.params.urls);
  });

  host.addEventListener('prefetch-on-demand:error', (e) => {
    console.error('Prefetch failed:', e.detail);
  });
</script>
<nav {{ stimulus_controller('@pwa/prefetch-on-demand') }}>
    <ul class="main-menu">
        <li {{ stimulus_action('@pwa/prefetch-on-demand', 'prefetch', 'mouseenter', {
            urls: ['/products', '/products/featured', '/products/new-arrivals']
        }) }}>
            <a href="/products">Products</a>
            <ul class="submenu hidden">
                <li><a href="/products/featured">Featured</a></li>
                <li><a href="/products/new-arrivals">New Arrivals</a></li>
            </ul>
        </li>
        <li {{ stimulus_action('@pwa/prefetch-on-demand', 'prefetch', 'mouseenter', {
            urls: ['/about', '/about/team', '/about/contact']
        }) }}>
            <a href="/about">About</a>
            <ul class="submenu hidden">
                <li><a href="/about/team">Our Team</a></li>
                <li><a href="/about/contact">Contact Us</a></li>
            </ul>
        </li>
    </ul>
</nav>

Best Practices

Be Selective with Prefetching

{# Good: Prefetch likely next pages #}
<div {{ stimulus_action('@pwa/prefetch-on-demand', 'prefetch', 'mouseenter', {
    urls: ['/checkout/shipping']
}) }}>
    Continue to Shipping →
</div>

{# Bad: Prefetching unrelated pages wastes resources #}
<div {{ stimulus_action('@pwa/prefetch-on-demand', 'prefetch', 'mouseenter', {
    urls: ['/about', '/contact', '/terms', '/privacy', '/faq', '/careers']
}) }}>
    Continue
</div>

Use Hover Intent, Not Immediate Hover

// Add intelligent hover detection
let hoverTimeout;
element.addEventListener('mouseenter', () => {
  hoverTimeout = setTimeout(() => {
    // User has been hovering for 200ms - likely interested
    host.controller.prefetch({ params: { urls: ['/target-page'] } });
  }, 200);
});

element.addEventListener('mouseleave', () => {
  clearTimeout(hoverTimeout);
});

Prioritize High-Value Pages

Prefetch pages that provide the most value when loaded instantly:

  • Next steps in user workflows

  • High-traffic destination pages

  • Content directly related to current page

  • Pages that require authentication or API calls

Monitor Cache Size

Respect User Preferences

// Check for data saver mode before prefetching
const connection = navigator.connection;
if (connection && connection.saveData) {
  console.log('Data saver enabled - skipping prefetch');
} else {
  // Safe to prefetch
  host.controller.prefetch({ params: { urls: ['/next-page'] } });
}

Common Use Cases

1. Multi-Step Form/Checkout Flow

{# Step 1: Cart #}
<div {{ stimulus_controller('@pwa/prefetch-on-demand') }}>
    <h2>Your Cart</h2>
    <div class="cart-items">
        {# Cart contents #}
    </div>

    <button {{ stimulus_action('@pwa/prefetch-on-demand', 'prefetch', 'mouseenter', {
        urls: ['/checkout/shipping', '/checkout/payment']
    }) }}>
        Proceed to Checkout
    </button>
</div>

2. Blog Article Navigation

<footer>
    <div class="author-box"
         {{ stimulus_action('@pwa/prefetch-on-demand', 'prefetch', 'mouseenter', {
             urls: ['/authors/' ~ article.author.slug]
         }) }}>
        <img src="{{ article.author.avatar }}" alt="{{ article.author.name }}">
        <span>By {{ article.author.name }}</span>
    </div>

    <div class="related-articles">
        <h3>You Might Also Like</h3>
        {% for related in article.relatedArticles %}
            <a href="{{ path('article_show', {slug: related.slug}) }}"
               {{ stimulus_action('@pwa/prefetch-on-demand', 'prefetch', 'mouseenter', {
                   urls: [path('article_show', {slug: related.slug})]
               }) }}>
                {{ related.title }}
            </a>
        {% endfor %}
    </div>
</footer>

3. E-Commerce Product Listing

4. Documentation Navigation

<main class="docs-content">
    {{ currentPageContent }}

    {# Prefetch previous/next pages #}
    <div class="page-navigation">
        {% if previousPage %}
            <a href="{{ path('docs_page', {slug: previousPage.slug}) }}"
               {{ stimulus_action('@pwa/prefetch-on-demand', 'prefetch', 'mouseenter', {
                   urls: [path('docs_page', {slug: previousPage.slug})]
               }) }}>
                ← {{ previousPage.title }}
            </a>
        {% endif %}

        {% if nextPage %}
            <a href="{{ path('docs_page', {slug: nextPage.slug}) }}"
               {{ stimulus_action('@pwa/prefetch-on-demand', 'prefetch', 'mouseenter', {
                   urls: [path('docs_page', {slug: nextPage.slug})]
               }) }}>
                {{ nextPage.title }} →
            </a>
        {% endif %}
    </div>
</main>

5. Infinite Scroll Feed

<div id="load-more-trigger"></div>

Example with custom event:

host.controller.prefetch({
  params: {
    urls: ['/page-1', '/page-2']
  }
});

Targets

None

Events

prefetched

Fired when the service worker successfully receives and processes the prefetch request. Note that this indicates the command was received, not necessarily that all URLs are fully cached.

Event detail:

  • params.urls (array): The URLs that were requested to be prefetched

host.addEventListener('prefetch-on-demand:prefetched', (e) => {
  console.log('Prefetch command processed:', e.detail.params.urls);
});

error

Fired when the prefetch operation fails (e.g., service worker not available, Workbox not configured).

Event detail:

  • params.urls (array): The URLs that failed to prefetch

host.addEventListener('prefetch-on-demand:error', (e) => {
  console.error('Prefetch failed for:', e.detail.params.urls);
  // Optionally fall back to native prefetch
  e.detail.params.urls.forEach(url => {
    const link = document.createElement('link');
    link.rel = 'prefetch';
    link.href = url;
    document.head.appendChild(link);
  });
});

How It Works

  1. User Interaction: User triggers an event (hover, scroll, click)

  2. Controller Action: The prefetch action is called with an array of URLs

  3. Service Worker Message: Controller sends a CACHE_URLS message to the service worker via Workbox

  4. Caching: Service worker fetches and caches the specified URLs

  5. Event Dispatch: Component emits prefetched or error event

  6. Navigation: When user navigates to prefetched URL, it loads instantly from cache

Configuration

Ensure your service worker is configured to handle the CACHE_URLS message. The PWA Bundle automatically configures this when Workbox is enabled.

If you need custom cache configuration:

config/packages/pwa.yaml
pwa:
    serviceworker:
        enabled: true
        workbox:
            enabled: true
            # Configure resource caching for prefetched pages
            resource_caching:
                -
                    match: '/.*'
                    strategy: 'NetworkFirst'
                    cache_name: 'prefetched-pages'
                    options:
                        networkTimeoutSeconds: 3
                        expiration:
                            maxEntries: 50
                            maxAgeSeconds: 86400  # 1 day

Resources

Last updated

Was this helpful?