← All guides

Menu and LCP: how navigation blocks your largest contentful paint

Render-blocking menu scripts: how to defer JavaScript without breaking navigation

Async vs defer vs type=module—strategies to load menu JS without blocking LCP, with Shopify-specific examples.

You have a solid menu. Customers like it. It works. But every time you test your store with PageSpeed Insights, Google flags one specific file: your menu script. The label says “render-blocking JavaScript,” the file is 120KB, and it is adding 600 milliseconds to your mobile LCP. You know you need to fix it, but the last time someone suggested using defer, the menu broke. Half the dropdowns stopped working, the mobile toggle did nothing, and you rolled it back within an hour.

This is a common problem. Render-blocking scripts are one of the most impactful performance bottlenecks on Shopify stores, and the menu is a frequent offender. But deferring JavaScript is not just about adding an attribute to a script tag. If you do it wrong, the menu will not work. If you do it right, you can shave half a second off your LCP without touching a single line of menu logic.

This article explains what render-blocking actually means, why async and defer behave differently, and how to load your menu script without breaking functionality.

Quick read
  • Render-blocking scripts halt all page rendering until they download, parse, and execute.
  • defer downloads scripts in parallel but delays execution until HTML parsing completes.
  • async downloads and executes immediately, which can break menus that depend on DOM order.
  • type="module" defers by default and supports modern JavaScript features.
  • Most menu breakage after deferring is caused by initialization timing, not the defer attribute itself.

What Render-Blocking Actually Means

When a browser encounters a <script> tag in the HTML without any loading attributes, it stops everything. It pauses HTML parsing, downloads the script, parses it, executes it, and only then continues building the page. This is called synchronous or render-blocking behavior.

The logic behind this behavior is old but reasonable: JavaScript can modify the DOM using document.write(), so the browser assumes every script might change the structure of the page and therefore must run before proceeding.

Here is the problem. Your menu script does not use document.write(). It does not need to run before the rest of the page is parsed. But the browser does not know that, so it blocks anyway.

According to Chrome’s own documentation on optimizing Largest Contentful Paint, eliminating render-blocking JavaScript is one of the highest-impact improvements you can make. When a script in the document head blocks for 400 milliseconds, every element below it in the DOM — your hero image, your headline, your product grid — waits 400 milliseconds longer to paint.

The Three Loading Strategies: Default, Defer, and Async

There are three ways a browser can load a script.

Default (Synchronous)

<script src="menu.js"></script>

The browser stops parsing HTML, downloads menu.js, parses it, executes it, then resumes. This blocks rendering for the entire duration.

Defer

<script src="menu.js" defer></script>

The browser downloads menu.js in parallel with HTML parsing but does not execute it until the entire HTML document has been parsed. Multiple deferred scripts execute in the order they appear in the HTML.

Async

<script src="menu.js" async></script>

The browser downloads menu.js in parallel with HTML parsing and executes it as soon as the download completes, even if HTML parsing is not finished. Execution order is unpredictable if multiple scripts use async.

Here is a table summarizing the behavior:

Attribute Download Execution Blocks HTML Parsing Execution Order
None (default) Blocks parsing Immediate Yes Sequential
defer Parallel After HTML parse No Sequential
async Parallel As soon as ready Yes (during execution) Unpredictable

For most navigation menus, defer is the right choice. It keeps scripts in order, which matters when one script depends on another (for example, a menu script that relies on a utility library). And it guarantees the DOM is ready before the script runs, which is critical for any code that queries elements.

Why Defer Breaks Some Menus (And How to Fix It)

You add defer to your menu script tag, reload the page, and the menu does not work. The dropdowns do not open. The mobile toggle does nothing. You remove defer, and everything works again. What happened?

The issue is almost always initialization timing. Here are the three most common causes.

Problem 1: Script Runs Before Theme JavaScript Is Ready

Many Shopify themes have a global JavaScript object or initialization function that other scripts depend on. If your menu script runs before that object is created, it fails.

Example:

// theme.js (loaded first)
window.Theme = { utils: { ... } };

// menu.js (loaded second, expects Theme to exist)
Theme.utils.initDropdown();

If menu.js is deferred but theme.js is not, menu.js might run before theme.js finishes executing, and Theme will be undefined.

Fix: Defer both scripts, or make sure menu.js checks for dependencies before initializing.

if (typeof Theme !== 'undefined') {
  Theme.utils.initDropdown();
}

Problem 2: Inline Scripts Run Before Deferred Scripts

Inline scripts (JavaScript written directly in the HTML, not loaded from an external file) execute in document order as they are encountered. Deferred scripts execute after the entire HTML is parsed, which means after all inline scripts.

If you have an inline script that tries to initialize the menu, and the menu library is deferred, the initialization will fail.

Fix: Move the initialization logic into the deferred script, or wrap inline initialization in a DOMContentLoaded listener.

document.addEventListener('DOMContentLoaded', function() {
  if (typeof MenuApp !== 'undefined') {
    MenuApp.init();
  }
});

Problem 3: Race Conditions with Multiple Deferred Scripts

If your menu depends on a utility library, and both are deferred, they should execute in the order they appear in the HTML. But if the utility library is loaded asynchronously (using async instead of defer), execution order becomes unpredictable.

Fix: Use defer for all interdependent scripts, not async. If you must use async, add explicit dependency checks or use dynamic imports.

Type=”module”: The Modern Alternative

Modern JavaScript supports ES6 modules, which have built-in deferred loading.

<script type="module" src="menu.js"></script>

Scripts with type="module" automatically defer. They download in parallel and execute after HTML parsing completes, just like defer. They also support import and export, which makes dependency management explicit.

If your menu script is written as a module, you can import dependencies directly:

import { initDropdown } from './dropdown.js';
initDropdown();

This eliminates the timing issues that cause breakage with traditional defer, because the browser knows exactly which scripts depend on which.

The downside: older browsers (Internet Explorer, very old versions of Safari) do not support modules. But according to Can I Use, over 95% of global browser traffic supports ES6 modules as of 2025. For most Shopify stores, this is safe.

Shopify-Specific Considerations

Shopify themes load scripts in different ways depending on the theme architecture. Here is how deferring works in the most common patterns.

Theme.liquid Script Tags

If your menu script is loaded in theme.liquid like this:

{{ 'menu.js' | asset_url | script_tag }}

The script_tag filter does not add defer by default. You need to replace it with a manual <script> tag:

<script src="{{ 'menu.js' | asset_url }}" defer></script>

App Embed Blocks

If your menu is installed as an app embed (common for third-party menu apps), the app controls how its script is loaded. You cannot add defer yourself. You need to check with the app developer or switch to a menu app that already loads scripts asynchronously.

Tools like Navi+ load their JavaScript with defer by default, which is one reason they perform better in LCP tests than legacy menu apps.

Section Rendering and Deferred Scripts

Shopify 2.0 themes load sections dynamically. If a section includes a deferred script, and the section is added to the page after the initial load (for example, via AJAX for quick view or infinite scroll), the script might not execute as expected.

Fix: Use DOMContentLoaded only for initial page load. For dynamically added sections, trigger initialization manually using a custom event or mutation observer.

How to Test If Defer Works

Before deploying deferred script loading to your live store, test it thoroughly. Here is a checklist.

  1. Desktop Chrome: Open your store, open DevTools, go to the Network tab, reload, and verify the menu script downloads in parallel with other resources and does not block the document.
  2. Mobile simulation: In DevTools, switch to mobile emulation (iPhone or Android), throttle the network to “Fast 3G,” and reload. The menu should still initialize correctly.
  3. Cross-browser: Test in Safari, Firefox, and Edge. Module scripts behave slightly differently in Safari.
  4. Real device: Open the store on an actual phone over a real mobile connection. Emulation is useful, but real-world latency exposes timing bugs that desktop testing misses.
  5. Functionality check: Open every dropdown, test the mobile menu toggle, click every link, and verify nothing breaks.

Before you pushRun a PageSpeed Insights test on the same page before and after enabling defer. You should see a measurable drop in LCP and a reduction in render-blocking resources. If LCP does not improve, the script might not have been the bottleneck, or there may be other blocking resources to address.

When Async Makes Sense (And When It Does Not)

async is appropriate for scripts that are fully self-contained and do not depend on DOM order or other scripts. Examples include analytics trackers, chat widgets, and some ad scripts.

For navigation menus, async is risky. The menu needs the DOM to be ready, it often depends on theme utilities, and it usually needs to run before the customer interacts with the page. If the script executes while the HTML is still being parsed, it might try to query elements that do not exist yet.

If you are considering async for a menu script, the script must:

  • Not depend on any other scripts
  • Not query DOM elements (or wrap all queries in a DOMContentLoaded check)
  • Be small enough that its execution time does not block parsing

Most menu scripts do not meet these criteria. Stick with defer or type="module" unless you have a specific reason to use async.

What Good Menu Script Loading Looks Like

A properly optimized menu script on a Shopify store looks like this:

<script src="{{ 'menu.js' | asset_url }}" defer></script>

Or, if using modules:

<script type="module" src="{{ 'menu.js' | asset_url }}"></script>

The script downloads in parallel with the rest of the page, executes after the DOM is ready, and does not block the hero image or any other LCP element from painting.

In Chrome DevTools Performance timeline, you should see the hero image paint before the menu script executes. If the menu script runs first, it is still blocking.

Most stores can cut 300 to 600 milliseconds off mobile LCP just by switching from synchronous to deferred script loading. The menu still works. The customer does not notice a difference. But Google does, and more importantly, the browser does. The page becomes visible faster, which is the whole point.

This article is part of the larger guide on Menu and LCP: how navigation blocks your largest contentful paint.

Share Facebook X

Get started with Navi+ AI Menu Builder

Pick your platform — free to install, live in minutes.