In short and in plain language
Many shops have a big menu. It shows all areas and all sub-areas. This menu is part of every page. That is true even if you do not open it.
This makes every page heavier. The browser has to load more and do more work. The page then reacts more slowly to a click.
A small menu is better. It shows only the top levels. The page fetches the deep levels only when you open the menu.
Google should still find all areas. So every area page shows its sub-areas as links.
We measure your menu. Then we rebuild it. After that, we check if the page is faster.
In many online shops, the heaviest part of a page is not the content but what sits above it: the mega menu. Every category, every subcategory and often a third level are written into the HTML as links, on the home page just as on every product page. Frequently the same tree appears a second time in the document because the mobile drawer gets its own copy. Visitors see none of this until they open the menu. The browser still has to download it, parse it, build it into the DOM and check it on every style change. The median mobile page has 594 HTML elements (Web Almanac 2024); a menu with a few hundred categories reaches the same order of magnitude on its own, with one list item and one link per entry. This article shows how to measure the node count of your navigation, which levels belong in the HTML and how deeper levels can arrive on demand without search engines losing the path to your categories. It also covers a single markup for desktop menu and drawer, opening via CSS with hover intent, and the navigation as a cache fragment of its own.
Key takeaways
- The median mobile page has 594 HTML elements (Web Almanac 2024). A mega menu with a few hundred categories reaches the same order of magnitude on its own, with one list item and one link per entry, and it does so on every page of the shop.
- Measure first: elements inside the
navversus elements in thebody, the number of links and the bytes of the menu markup. If the tree appears twice in the document for desktop and drawer, it counts twice. - The top two levels stay in the HTML as real links, the third arrives as a fragment on first open. Because Google Search does not interact with the page (Google Search Central), every category page links to its subcategories.
- One markup for desktop and phone: CSS rearranges the same list, a button with
aria-expandedopens the levels. Submenus close with a delay; the W3C example uses one second for this (W3C WAI). - Hovering does not count toward INP (web.dev), but the work behind it can delay the next click. On mobile, only 69 percent of secondary pages reach a good INP score, compared with 80 percent of home pages (Web Almanac 2025).
- The navigation is identical on every page and belongs in its own cache fragment. Shopware has loaded header and footer via ESI since version 6.7 (Shopware); personal parts such as the cart counter are delivered separately.
Why the navigation makes every page heavier
Navigation is one of the few components that sit on every single page. A product description appears once, a category teaser on a few pages, but the menu shows up on the home page, listings, product pages, search results and the customer account. Whatever is too much in there, every page view pays for. In a shop with a deep catalog, the main navigation quietly grows with the assortment: every new subcategory automatically becomes another link in the header of every page, without anyone touching the template. If you only measure the home page, you often miss this, because images and sliders dominate the diagnosis there.
The cost arises in three places. First in the HTML: every list item and link travels with the document, is transferred compressed and unpacked again in the browser. For context: the median desktop home page loads 18 KB of HTML (Web Almanac 2024), so a menu with several hundred category links is no minor item next to that. Second in the DOM: the median mobile page has 594 elements, and at the 90th percentile it is 1,716 (Web Almanac 2024). Third at runtime: the larger the tree, the more expensive every style calculation and every layout becomes, even if most nodes stay invisible.
The old thresholds of the Lighthouse audit were a warning at about 800 and an error at about 1,400 nodes in the body, and since Lighthouse 13 the audit has moved into the "Optimize DOM size" insight (Chrome for Developers), which we cover in detail in the article Reduce DOM size.
For a mega menu, a different statement from the same documentation is more revealing: a style recalculation counts as large when it affects more than 300 elements (Chrome for Developers). A menu reaches exactly that order of magnitude when opening it sets a class on the body or html element to dim the page behind it or to stop scrolling. The browser may then have to re-evaluate large parts of the page, including all hidden menu entries. Why complex selectors make this work even more expensive is explained in the article on style recalculation.
Responsiveness shows the difference between entry pages and deeper pages. On mobile, 80 percent of home pages reach a good INP score, but only 69 percent of secondary pages (Web Almanac 2025). Navigation is not the only cause, since listings with filters and product pages with galleries contribute their share. But it is a component that runs on every one of those secondary pages, which makes it a lever that works everywhere at once. Why work on templates pays off more than work on the home page alone is covered in Template performance beyond the home page.
Measuring the node count of your navigation
Before anything is rebuilt, the navigation needs to be measured, on a typical category or product page rather than only on the home page. Four values are enough to start: the number of elements in the whole body, the number of elements inside each navigation, the number of links in it and the size of the menu markup in bytes. The following snippet returns all four in the console of the developer tools. Adjust the selector to your theme; many shops have more than one nav element, for desktop, drawer, breadcrumbs and footer.
// Run in the console of a typical category or product page
const total = document.body.querySelectorAll('*').length;
const navs = document.querySelectorAll('nav, [role="navigation"]');
for (const nav of navs) {
const elements = nav.querySelectorAll('*').length;
console.log(nav.getAttribute('aria-label') || nav.className || nav.tagName, {
elements,
links: nav.querySelectorAll('a[href]').length,
hidden: [...nav.querySelectorAll('a[href]')].filter((a) => !a.checkVisibility({ visibilityProperty: true })).length,
bytes: new Blob([nav.outerHTML]).size,
share: (elements / total).toFixed(2),
});
}
console.log('Elements in body:', total);The snippet counts elements, not nodes. Nodes also include text, whitespace and comments; Lighthouse states its thresholds in nodes, but the old audit actually counted elements ("Total DOM Elements"), and the web.dev guide deliberately works with elements. For the comparison this does not matter, as long as you count the same way before and after the rebuild. outerHTML measures the markup uncompressed, in the form the browser has to process after unpacking. The hidden column shows how many links are currently not visible; with the menu closed, this is a rough measure of the ballast every page carries.
Four findings come up particularly often during measurement:
- Two trees: if the snippet finds two navigations with almost the same number of links, the menu is in the document twice, once for desktop and once for the drawer.
- Hidden levels: many invisible links with the menu closed show that third and fourth levels are shipped although they only appear on request.
- Images in the menu: teaser images in mega menus load with every page unless they carry
loading="lazy". How to deliver such images at the right width is covered in the article on the sizes attribute. - Inline data: some themes additionally write the category tree as JSON into a
scriptelement. The page then pays for the tree once more, this time as script data.
In the network panel you can also see the size of the HTML document itself, transferred and unpacked. How to read the first row in the waterfall and assess the share of the document in the load time is described in Reading the network waterfall. For an interaction in the lab, look at the "Recalculate Style" entries in the Performance panel: there Chrome shows how many elements a style recalculation affected (web.dev). Open the menu with a throttled CPU in the phone profile, because that is where every unnecessary recalculation weighs most.
Limiting depth: what belongs in the HTML
The most important decision is editorial rather than technical: how many levels does a visitor need to find their way? For many assortments, two levels in the delivered HTML are enough. The top level names the main areas, the second the categories below them. Everything beyond that is fine selection, which visitors make on the category page anyway, with filters, sorting and product images. A menu that lists every subcategory takes that job away from the category page and at the same time makes every other page heavier.
In Shopware, the depth of the main menu is set in the sales channel under "Navigation levels". According to the field definition in the source code, the value determines how many levels of subcategories the storefront category menu shows. It is worth reviewing after every major expansion of the assortment. Custom themes and extensions can bypass the setting when they load the tree themselves; then the template needs checking, not just the setting. In our Shopware performance work, this look at the templates is a fixed part of the process.
Which categories belong in the second level is decided by access data, not by the org chart. Categories that hardly anyone opens from the menu move to the category page of their area. A "View all" link at the foot of each column leads to the parent category page and replaces a long list that hardly anyone reads to the end anyway. Visitors lose nothing: the category page shows the subcategories with image, product count and filters, while the menu shows only their names.
Promotions and seasonal topics are a special case. They tend to end up as an extra column with an image in the mega menu and stay there long after the promotion has ended. A fixed slot with exactly one teaser that is swapped editorially keeps the navigation stable. The same applies to brand lists: a column with every brand in the assortment belongs on a brand page of its own, not in the header of every product page.
Loading deeper levels on demand
What does not belong in the HTML does not have to disappear. The third level can arrive as an HTML fragment as soon as the visitor shows interest in a main item. web.dev describes exactly this approach: omit parts of the page that are not visible at startup and add them when the user interacts with that part of the page (web.dev). The benefit works twice. The initial document is lighter and renders faster, and interactions during loading compete with less work for the main thread.
The fragment is ideally served from its own statically cacheable address per main area, such as /navigation/tools.html. It is loaded when the pointer reaches the main item, when the item receives focus or when the level is opened on a phone. A second request is served from the browser cache or from the script memory. Visible feedback matters in case the fragment takes longer: in-flight network requests do not count toward INP but do increase perceived latency, which is why web.dev recommends a loading indicator (web.dev).
// Load the third level on first interest, each level only once
const pending = new WeakMap();
function loadLevel(item) {
if (pending.has(item)) return pending.get(item);
const panel = item.querySelector('.nav-panel');
panel.setAttribute('aria-busy', 'true');
const job = fetch(item.dataset.fragment)
.then((res) => (res.ok ? res.text() : Promise.reject(res.status)))
.then((html) => panel.insertAdjacentHTML('beforeend', html))
.catch(() => pending.delete(item)) // try again on the next interest
.finally(() => panel.removeAttribute('aria-busy'));
pending.set(item, job);
return job;
}
for (const item of document.querySelectorAll('.nav-item[data-fragment]')) {
for (const type of ['pointerenter', 'focusin', 'click']) {
item.addEventListener(type, () => loadLevel(item));
}
}The top and second levels remain fully present as links in the HTML. The script only adds, it replaces nothing. If it fails or the network is slow, every category stays reachable through its main item and the category page. The fragment itself contains only markup, without script and without inline styles, so it slots into the existing panel without further work.
Shopware uses this pattern itself: the storefront mobile drawer fetches its levels via a dedicated route when tapped instead of writing the whole tree into the document. Since version 6.7.3.0, Shopware also caches the default category levels of the main navigation so they are not loaded and hydrated again on every request (Shopware). For custom themes this means: check first whether they use these paths or pull the full tree back into the template after all.
What search engines still need to find
The most common objection to lean menus is the concern about discoverability: if the third level is no longer in the HTML of every page, will Google still find it? The answer depends on whether there is a second path besides the menu. Google can generally only follow a link if it is an <a> element with an href attribute (Google Search Central). And content that is only loaded after an action such as clicking or scrolling is not picked up by Google Search this way, because it does not interact with the page (Google Search Central).
A fragment that arrives on hover therefore stays invisible to the crawler. That is not a problem as long as every subcategory is linked as a normal link on its parent category page and every page carries breadcrumbs up to the home page. Google puts the rule simply: every page you care about should have a link from at least one other page on your site (Google Search Central). Breadcrumbs show the position of a page in the hierarchy, and marked up as BreadcrumbList they become readable for Search (Google Search Central).
This makes the navigation one of several paths to the categories instead of the only one. The same applies to accessibility: the W3C advises repeating the entries of a submenu on the page of the parent menu item, because people with reduced dexterity often have trouble operating fly-out menus (W3C WAI). A category page that shows its subcategories as tiles or a list solves both tasks at once. This is no promise about rankings; it only ensures that no destination depends on the menu alone.
A second point concerns size. For Google Search, Googlebot reads the first 2 MB of a supported file (Google Search Central), and this limit applies to the uncompressed data (Google Search Central). Shop pages usually stay far below it. But because the navigation sits at the beginning of the document, a bloated menu uses up this budget first, before product text and reviews further down. If you already ship large pages, such as long listings with many variants, measure the sum of menu and content uncompressed once.
One markup for desktop and drawer
Many themes build the navigation twice: as a mega menu for wide screens and as a list in the mobile drawer. Both copies sit in every document, and one of them is hidden at any given time. On a phone, visitors pay for a desktop menu they do not get to see there, and on desktop for a drawer that stays closed there. If you measure the navigation, you recognize this case by two trees with almost the same number of links.
The alternative is a single list that CSS arranges differently depending on width: on desktop as a horizontal bar with panels, on a phone as a vertical list with expandable groups. The structure stays the same nested list of ul, li and a; only presentation and control differ. The link of a main item leads to the category page, a separate button next to it opens the panel.
<nav class="main-nav" aria-label="Main navigation">
<button type="button" class="nav-toggle" aria-expanded="false" aria-controls="nav-list">
Menu
</button>
<ul id="nav-list" class="nav-list">
<li class="nav-item" data-fragment="/navigation/tools.html">
<a href="/tools/">Tools</a>
<button type="button" class="nav-open" aria-expanded="false" aria-controls="panel-tools">
<span class="visually-hidden">Tools submenu</span>
</button>
<div class="nav-panel" id="panel-tools">
<ul>
<li><a href="/tools/power-tools/">Power tools</a></li>
<li><a href="/tools/hand-tools/">Hand tools</a></li>
<li><a href="/tools/">All tools</a></li>
</ul>
</div>
</li>
</ul>
</nav>If you want to manage without any custom script in the drawer, you can also mark up the groups as details and summary. The element has been widely available since July 15, 2022 (Web Features Explorer). Several details elements with the same name attribute are mutually exclusive, so only one group is open at a time; this has worked in all major browsers since September 3, 2024 (Web Features Explorer). On desktop, details is a weaker fit because it does not open on hover. For a shared markup, the variant with link and button is therefore usually the more robust one.
Important for keyboard use: submenus should not open as soon as someone tabs through the main items, otherwise they have to step through every subitem to reach the next main item (W3C WAI). A button with aria-expanded next to the link solves this: the link leads to the category page, the button opens the panel. How dialogs and popovers can be controlled via command and commandfor without custom script is shown in Dialog and popover directly in HTML.
Opening via CSS and recognizing hover intent
Many mega menus depend on a library that evaluates mouse movements, positions panels, sets timers and toggles classes. Opening on hover usually needs none of that. CSS can show a panel as long as the pointer rests on the main item or the panel, and it can delay opening and closing. That models hover intent: someone moving the mouse across the bar to the search field should not open every panel along the way, and someone moving diagonally from the main item into the panel should not lose it on the way.
/* Desktop with mouse: open after a short delay, close noticeably later */
@media (min-width: 64em) and (hover: hover) {
.nav-item { position: relative; }
.nav-panel {
position: absolute;
top: 100%;
visibility: hidden;
opacity: 0;
transition: opacity 0.15s ease 0.25s, visibility 0s linear 0.4s;
}
.nav-item:hover > .nav-panel,
.nav-item.is-open > .nav-panel {
visibility: visible;
opacity: 1;
transition-delay: 0.12s;
}
}The W3C tutorial on fly-out menus delays closing with a timer so that imprecise mouse movements do not snap the menu shut immediately; the example uses one second (W3C WAI). In CSS, the delayed transitions on opacity and visibility take over this job: the panel only starts to fade after a quarter of a second and stays usable until then. The values in the example above are starting points for your own tests, not a rule. The (hover: hover) condition makes sure touch devices do not get this behavior at all; there the button opens the panel. Only opacity is animated, not height or position, so no layout has to be recalculated.
Content that appears on hover has to meet three conditions under success criterion 1.4.13 of WCAG 2.2: it can be dismissed without moving pointer or focus, the pointer can move over the new content without it disappearing, and it remains visible until the trigger is removed (W3C). A panel that attaches directly to the main item and closes on Escape meets this. A gap of a few pixels between bar and panel, on the other hand, makes it close while the pointer moves across. A few lines of script on the nav element are enough for Escape and the button state; that is no comparison to a menu library.
The interestfor attribute now offers a built-in way to detect interest via hover, focus or long press. So far, however, it only works in Chrome and Edge from version 142, not in Firefox and Safari (Web Features Explorer). For a main menu it is therefore an addition for later, not a foundation.
INP has a special feature here: hovering, zooming and scrolling do not count as interactions; clicks, taps and key presses are measured (web.dev). A mega menu that starts heavy work on hover therefore does not show up in INP directly. It can, however, delay the click that comes right after if the main thread is still busy at that point. On a phone the case is clear: a tap opens the menu, and exactly that tap is measured.
That is why it pays to keep the state on the menu rather than on the body. A class on the list item usually affects only its subtree, while a class on the body can make selectors across the whole page re-evaluate. If you want to dim the page behind an open menu, place a separate element over the content instead of toggling a rule on the body. And if you need to lock scrolling behind the drawer, set the lock once rather than again on every movement.
Proving the effect on INP and HTML size
The rebuild is only finished once it has been measured, with the same values as before. For the HTML, that means the bytes of the document and the navigation's share of them; for the DOM, the number of elements on a typical secondary page. For responsiveness, INP counts: a value up to 200 milliseconds is good, a value above 500 milliseconds is poor (web.dev). Because INP comes from field data, the assessment takes time; in the lab, the "Recalculate Style" entries add how many elements opening the menu affected.
- Document size: bytes of the HTML document on a category page and a product page, transferred and unpacked.
- Elements: count in the
bodyand count in each navigation, measured with the same snippet. - Opening in the lab: duration of the style recalculation and number of affected elements when opening the menu, in the phone profile with a throttled CPU.
- INP in the field: value for secondary pages separately from the home page, observed over several weeks.
- Reachability: every third-level category is linked from its parent category page, checked with a crawl that does not execute scripts.
How to break down INP values in the field and find long tasks is described in Optimizing INP. With every rebuild we also check how the values fit into the other Core Web Vitals, so that a lighter menu does not create a layout shift elsewhere, for instance when the fragment changes the height of a panel after the fact.
A panel that is already in the DOM but rarely opened can additionally be excluded from rendering. When content-visibility helps and when it interferes with panels that have their own positioning is covered in the article on content-visibility. For a mega menu this is the second choice: nodes that are not in the document at all cost neither transfer nor memory.
The navigation as a cache fragment of its own
The navigation is identical on every page of a shop, at least for all visitors of one language and one sales channel. Yet many systems recompute it on every page build: loading categories, checking visibility, generating links, writing markup. That costs server time before the first byte and ties the navigation to the lifetime of every single page. When a price changes, the menu expires along with the product page even though it has not changed.
A fragment with its own lifetime is the better approach. Shopware has taken this route since version 6.7: header and footer are loaded via ESI (Shopware), which allows them to be cached separately from the rest of the page (Shopware). When a product price changes, the navigation does not have to be rebuilt; when a category changes, the fragment is invalidated without every page having to be rebuilt just because of the menu. In other systems you achieve the same with a server-side include that caches the menu part separately, or with a dedicated cache entry that is purged when a category is saved.
For third-level fragments, the situation is even simpler. They are the same for all visitors and can be served as static files with a long cache lifetime, as long as their address carries a version that changes with the category tree. A new tree gets a new address, and old copies simply expire. The server then no longer has to run a single database query for an opened menu.
It gets tricky when the navigation contains personal parts: the name in the account menu, the number in the cart, a customer group with its own categories. If these sit in the same fragment, the cache breaks into many small variants. Such parts are delivered separately or added in the browser. Why a single cookie can wreck the hit rate is explained in Why one cookie wrecks your cache hit rate; which cache layers interact in a shop is covered on our page on caching strategies.
The rebuild in six steps
- Measure: record elements, links and bytes of the navigation on a typical category and product page, plus the INP of secondary pages as the baseline.
- Set the depth: use access data to decide which categories belong in the second level; everything below moves to the category pages.
- Secure a second path: every category page links to its subcategories as normal links, and breadcrumbs lead up to the home page.
- Build one list: bring desktop menu and drawer back to the same markup and remove the duplicate copy.
- Load and open: third level as a versioned fragment, opening via CSS with a delay, a button with
aria-expandedfor keyboard and touch. - Cache and re-measure: cache the navigation as a fragment of its own, separate personal parts and measure the same values again.
Most of these steps are template work, not new technology. The effort lies in the reconciliation: which category needs which path, which extension writes its own links into the menu, which part of the theme reads the tree a second time. In our front-end optimization we go through this list for your shop, carry out the rebuild and measure document size, element count and INP before and after the change. If you first want to know where your own shop stands, start with a technical analysis.
Sources and documentation
<details>, Mutually exclusive <details>, Interest invokers); Shopware (upgrade notes 6.7.0.0 and 6.7.3.0, storefront source code).nav element of a secondary page. If you find two navigations with almost the same number of links, one of which is hidden depending on screen width, the tree is in the document twice. The snippet from the measurement section shows this with one line per navigation.aria-expanded opens them via keyboard and touch. A few lines of script remain for Escape, for the button state and for loading the third level.Related Articles
Oversized Images Despite srcset: Getting sizes Right
Browser still loading the big file despite srcset? Usually sizes is missing or no longer fits the grid. How browsers pick files, how to derive and check sizes.
Reading the network waterfall: what each row reveals
Learn to read the network waterfall: how it is ordered, what the phases of a bar show and which six patterns across rows reveal typical slowdowns.
WordPress Slow Despite Caching: WP-Cron and admin-ajax
Page cache on, WordPress still sluggish? WP-Cron, Heartbeat and admin-ajax bypass the cache and tie up PHP workers. How to measure that load and take it off.