Skip to content
performance·29.10.2026·7 min read

531 Requests on a Homepage — Cutting a Headless Storefront Down to 101

A headless Shopware 6 + Nuxt storefront loaded 531 requests on its mobile homepage and scored 56 in Lighthouse. After one batch of changes on production it was 101 requests and 88. How I measured, where the weight came from, what I reverted, and a checklist for your own storefront.

A storefront waterfall chart shrinking from a dense wall of requests to a short, tidy list, with a performance gauge moving from orange to green

The homepage of a headless storefront I work on loaded 531 network requests on a mobile device and scored 56 in Lighthouse. The storefront is Shopware 6 with a Nuxt frontend, serving two brands from one backend. Nothing was broken. Pages rendered, orders came in. It was just slow, and it got a little slower with every feature.

After one batch of changes, deployed to production at the end of October, the same homepage loaded 101 requests and scored 88. This post covers how I measured it, where the 430 extra requests came from, what I tried and reverted, and what to check in your own storefront. Client details are anonymized; the numbers are real.

Measure First, and Measure Where It Counts

Before changing a line, I set up a benchmark I could re-run:

  • Lighthouse mobile, three runs per page, median. Single Lighthouse runs scatter too much to compare.
  • The same seven pages every time: homepage, category pages, product pages of different types, a CMS page with videos.
  • Against stage and production, never locally. On an earlier project, a slow local backend made Lighthouse report an LCP of about 76 seconds while stage measured 3.1–4.1 seconds. A local benchmark measures your laptop's backend, not your shop.
  • One script with the host as a variable, so "before" and "after" run the exact same steps.

That baseline also gave the conversation with the client a different footing. "It feels faster" isn't something you can bill. "Homepage from 56 to 88, requests from 531 to 101" is.

Where 531 Requests Came From

1. Every Component Was Global

The Nuxt config had components: { global: true }. That registers every component globally, so each one becomes its own chunk that the app has to know about up front, whether or not the page uses it.

The fix: only components resolved by a name built at runtime stay global. In a Shopware frontend those are the CMS elements, which are looked up by their CMS type name. Everything else is bundled with the component that imports it:

// nuxt.config.ts
components: { global: false },
hooks: {
  'components:extend'(components) {
    for (const component of components) {
      // CMS elements are resolved by name at runtime and must stay global
      if (/\/components\/(?:cms\/|public\/|errors\/)/.test(component.filePath)) component.global = true
    }
  },
},

2. About 160 Prefetch Hints per Page

The build emitted a speculative <link rel="prefetch"> for every lazy chunk and asset, around 160 hints per page. Prefetching sounds free, but on a mobile connection those downloads compete with the hero image and the CSS the page actually needs. Removing them from the build manifest means lazy chunks load when they're needed:

'build:manifest'(manifest) {
  for (const chunk of Object.values(manifest)) {
    chunk.dynamicImports = []
    chunk.assets = []
  }
},

To keep navigation fast without blanket prefetching, a small client plugin now prefetches the data of a page when the user shows intent: 80 ms hovering over a link, or 50 ms on touch. The prefetched data is cached briefly and capped at 20 entries. It's cleared on login and logout, because prices depend on the customer group.

3. Product Cards Carried the Full Product Page

This was the biggest single payload problem, and it sat in the backend. A product card in a listing shows a name, a price, an image and maybe a subtitle. The API sent the full product for every card: all translated fields, every custom field, category tree, stream IDs, property IDs, tax, manufacturer, every media thumbnail. Multiply that by a listing page and by the server-rendered payload Nuxt embeds in the HTML.

The fix came in three parts:

  • Mark card criteria. Every place that loads products for cards (listings, product boxes, cross-selling, athlete recommendations) tags its criteria as "card".
  • Skip detail-page enrichment for those products, and don't read description or SEO text fields from the database at all.
  • Slim the result to what a card renders: the name, five specific custom fields, and the cover with its URL. The filter aggregations of the listing went the same way: option name, colour code and position, no descriptions or custom fields.

The second batch, a few days later, went further: media thumbnails nobody read in the CMS page payload, all sales-channel domains in the session context, unused fields in the language list. The fallback translation catalogue had been shipped twice, once on its own and once merged, and now ships once.

4. Third-Party Scripts Running Before the Page Was Usable

  • The cookie consent dialog is the LCP element for first-time visitors, and its stylesheet and texts only started loading after its script had run. Preload hints for both, chosen in the page's language, let them load in parallel.
  • Sentry (second batch) now loads when the browser is idle after hydration, and only the parts in use are bundled.
  • The search client (second batch) sat in the entry chunk. Now it loads with the search flyout, the first time someone opens search.

5. Hydration Competing With the First Paint

The app's module scripts were moved out of the critical path. The server-rendered page paints first, and hydration starts after the load event, on the first interaction, or after 1.5 seconds, whichever comes first. Clicks on buttons before hydration are captured and replayed once the app is ready. Links keep working natively the whole time.

That's the riskiest change in the batch. A click that's swallowed and never replayed is worse than a slow page. It shipped with tests for the replay logic and a 15-second fallback that removes the guards if hydration never signals.

What I Reverted

Not everything that speeds up Lighthouse is worth keeping:

  • Skipping the rendering of off-screen product cards on mobile looked like an easy Total Blocking Time win. An interleaved A/B test on the deployed stage build, eight runs each, showed no gain: the category page's mobile median was 92.5 with and without it. Reverted the same day.
  • Lazy hydration for a cross-selling block had been tried earlier. Four cards saved too little JavaScript to justify the extra test complexity, so it went back to normal hydration. Larger blocks on the product page kept it.

Every optimization adds code someone has to understand later. It has to save enough to pay for that.

A Bug Found Along the Way

While reading the components for performance work, I found this:

const smallerThanLG = breakpoints.smaller('lg') // a Ref<boolean>

if (smallerThanLG) {
  // always runs: a ref object is always truthy
}

A Ref is an object, and objects are always truthy. The condition never checked the breakpoint. The fix is smallerThanLG.value. Then I searched the whole codebase for the same pattern: it appeared in exactly two components, and both were fixed together. When you find a bug like that, look for its siblings before you close the ticket.

Results

Production, Lighthouse mobile, median of three runs, measured after the deployment:

PagePerformance beforePerformance afterRequests beforeRequests after
Homepage (brand A)5688531101

Other pages after the deployment: a product page at 99, a category page of the second brand at 96. One product-series page of the second brand is still at 74 with an LCP of 7.0 seconds, and that's the next ticket. The second batch (Sentry and search loading later, sliders that mount on first interaction, the footer skipped while off-screen, resized images requested as WebP so cold CDN misses skip JPEG) shipped after this measurement and isn't in the table.

Checklist for Your Storefront

  1. Count requests on your mobile homepage. Lighthouse or DevTools, with the cache disabled. Several hundred is a finding by itself.
  2. Check components.global in your Nuxt config. If it's true, find out which components really need it.
  3. Look for prefetch hints in your HTML. curl -s https://your-shop.de | grep -c 'rel="prefetch"'
  4. Look at one product card in your API response. Count the fields the card actually renders and compare that to what's sent.
  5. List every third-party script and ask for each one: does it need to run before the page is usable?
  6. Benchmark on stage or production, three runs per page, before and after each change. Not locally.

For more on the individual metrics, see mastering INP and the LCP case study on this site. If your storefront feels like this one did, a performance audit starts with exactly this benchmark.

Enjoyed this?

Get new posts as they land.

Subscribe via RSS

Keep reading