Skip to main content

Load JavaScript on user interaction

Use dynamic import() within event listeners to load JavaScript modules only when users interact with components.


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 and 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.


Use dynamic 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.


Click interaction:

<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:

// `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:

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:

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.

<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.

<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:

{%- 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 for detailed patterns on guarding expensive Liquid operations.

Preload on intent for an instant feel:

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 }
);

  • Chrome DevTools Performance panel: Record a page load and check Scripting time in the summary. Use the Bottom-Up tab to see cost per script.
  • Chrome DevTools Coverage: Drawer > Coverage. Shows unused JavaScript code execution.
  • Network panel: Verify that modules load only on interaction.
  • User testing: Confirm that features still work after interaction.


Was this page helpful?