---
title: Load JavaScript on user interaction
description: >-
  Use dynamic `import()` within event listeners to load JavaScript modules only
  when users interact with components, keeping the initial bundle small and
  improving INP and page load performance.
source_url:
  html: >-
    https://shopify.dev/docs/storefronts/themes/best-practices/performance/load-javascript-on-user-interaction
  md: >-
    https://shopify.dev/docs/storefronts/themes/best-practices/performance/load-javascript-on-user-interaction.md
api_name: liquid
---

# Load Java​Script on user interaction

Use dynamic [`import()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import) within event listeners to load JavaScript modules only when users interact with components.

***

## Why

Loading JavaScript for unused features upfront increases bundle size, delays the initial page render, blocks the main thread during parsing and execution, and affects [INP](https://web.dev/inp/) and [TBT](https://web.dev/tbt/) metrics. Loading code only when the user shows intent to use a feature keeps the initial bundle small, enables faster page load, improves INP scores, and makes features feel instant, because the code loads in parallel with the user action.

***

## How

Use dynamic [`import()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import) within event listeners to load modules only when needed. Common triggers include:

* **Click interaction**: The most common trigger.
* **Hover or focus**: An intent signal.
* **Visibility or scroll**: When the element enters the viewport.
* **Idle time**: Use `requestIdleCallback` or `setTimeout`.

For a better user experience, preload the module on hover, before the click, so that features feel instant when clicked.

***

## Examples

Click interaction:

```html
<button id="chat-widget-btn">Chat with us</button>


<script>
  const chatBtn = document.getElementById("chat-widget-btn");


  chatBtn.addEventListener(
    "click",
    (e) => {
      e.preventDefault();
      import("{{ 'chat-widget.js' | asset_url }}")
        .then((module) => module.default)
        .then((ChatWidget) => ChatWidget.init())
        .catch((err) => console.error("Failed to load chat:", err));
    },
    { once: true }
  );
</script>
```

Hover or focus, as an intent signal:

```javascript
// `once` is per listener, so share one promise to init only once.
let loaded;


function loadFeature() {
  loaded ??= import("./feature.js").then((m) => m.init());
}


button.addEventListener("mouseenter", loadFeature, { once: true });
button.addEventListener("focus", loadFeature, { once: true });
```

Visibility through scroll, using `IntersectionObserver`:

```javascript
const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      import("./feature.js").then((m) => m.init());
      observer.disconnect();
    }
  });
});


observer.observe(document.querySelector("#lazy-component"));
```

Idle time:

```javascript
if ("requestIdleCallback" in window) {
  requestIdleCallback(() => {
    import("./analytics.js").then((m) => m.init());
  });
} else {
  setTimeout(() => {
    import("./analytics.js").then((m) => m.init());
  }, 2000);
}
```

Real-world: chat widget.

```liquid
<div id="chat-container"></div>
<button id="open-chat">Chat with us</button>


<script>
  document.getElementById('open-chat').addEventListener('click', async () => {
    const { initChat } = await import("{{ 'chat.js' | asset_url }}");
    initChat('#chat-container');
  }, { once: true });
</script>
```

Real-world: video player.

```liquid
<div class="video-container" data-video-id="abc123">
  <img src="{{ 'video-thumbnail.jpg' | asset_url }}" alt="Video thumbnail">
  <button class="play-btn">Play Video</button>
</div>


<script>
  document.querySelector('.play-btn').addEventListener('click', async function() {
    const container = this.closest('.video-container');
    const videoId = container.dataset.videoId;


    const { VideoPlayer } = await import("{{ 'video-player.js' | asset_url }}");
    container.innerHTML = '';
    const player = new VideoPlayer(container, videoId);
    player.play();
  }, { once: true });
</script>
```

Real-world: defer Liquid content in dialogs.

For dialogs that contain expensive Liquid operations, such as collection queries or variant iteration, defer the server-side rendering until the dialog opens:

```liquid
{%- comment -%}
  Render the dialog shell only. The expensive query lives in a separate section,
  `search-results`, which this page never renders on the initial request.
{%- endcomment -%}
<button id="search-toggle">Search</button>


<dialog id="search-dialog">
  <div id="search-results"></div>
</dialog>


<script>
  const searchToggle = document.querySelector('#search-toggle');
  const searchDialog = document.querySelector('#search-dialog');
  let contentLoaded = false;


  searchToggle.addEventListener('click', async () => {
    if (!contentLoaded) {
      {%- comment -%}
        Fetch a different section, not this one. Re-requesting this section would
        return the same empty shell.
      {%- endcomment -%}
      const response = await fetch('?sections=search-results');
      const data = await response.json();
      const doc = new DOMParser().parseFromString(data['search-results'], 'text/html');


      document.querySelector('#search-results').innerHTML =
        doc.querySelector('#search-results').innerHTML;
      contentLoaded = true;
    }


    searchDialog.showModal();
  });
</script>
```

See [Defer dialog content loading](https://shopify.dev/docs/storefronts/themes/best-practices/performance/defer-modal-content-loading) for detailed patterns on guarding expensive Liquid operations.

Preload on intent for an instant feel:

```javascript
let modulePromise = null;


button.addEventListener(
  "mouseenter",
  () => {
    if (!modulePromise) {
      modulePromise = import("./feature.js");
    }
  },
  { once: true }
);


button.addEventListener(
  "click",
  async () => {
    const { Feature } = await (modulePromise || import("./feature.js"));
    Feature.init();
  },
  { once: true }
);
```

***

## Testing

* [Chrome DevTools Performance panel](https://developer.chrome.com/docs/devtools/performance): Record a page load and check **Scripting** time in the summary. Use the **Bottom-Up** tab to see cost per script.
* [Chrome DevTools Coverage](https://developer.chrome.com/docs/devtools/coverage): **Drawer** > **Coverage**. Shows unused JavaScript code execution.
* [Network panel](https://developer.chrome.com/docs/devtools/network): Verify that modules load only on interaction.
* User testing: Confirm that features still work after interaction.

***

## References

* [`asset_url`](https://shopify.dev/docs/api/liquid/filters/asset_url) filter
* [Import on Interaction pattern - Addy Osmani](https://addyosmani.com/blog/import-on-interaction/)
* [Load non-critical resources on interaction](https://shopify.dev/docs/apps/build/performance/general-best-practices#load-non-critical-resources-on-interaction)
* [AssetSizeJavaScript Theme Check](https://shopify.dev/docs/storefronts/themes/tools/theme-check/checks/asset-size-javascript)
* [MDN: Dynamic imports](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import)
* [Defer non-critical scripts](https://shopify.dev/docs/storefronts/themes/best-practices/performance/defer-scripts)
* [Defer dialog content loading](https://shopify.dev/docs/storefronts/themes/best-practices/performance/defer-modal-content-loading)
* [Use import maps for module resolution](https://shopify.dev/docs/storefronts/themes/best-practices/performance/use-import-maps-for-modules)

***
