This is a demo report. Want one for your site?

Analyze Your Site Free

Audit Results

https://www.gymshark.com/collections/leggings

Lighthouse Lab Data

Measured in a simulated environment. Values may differ from real user experience.
34
Performance Score
Largest Contentful Paint
12.4 s
Cumulative Layout Shift
0.1139
Interaction to Next Paint
1774 ms
Good (90–100)Needs Improvement (50–89)Poor (0–49)

Field Data — Mobile (Real Users)

Core Web Vitals Poor

Chrome UX Report — p75 values from real mobile user experiences over the last 28 days

Largest Contentful Paint (LCP)
3.6 sNeeds Improvement
56.1%
22.4%
21.5%
Interaction to Next Paint (INP)
548 msNeeds Improvement
36.1%
36.2%
27.7%
Cumulative Layout Shift (CLS)
0.09Needs Improvement
76.6%
18.6%
Time to First Byte (TTFB)
1761 msNeeds Improvement
29.0%
47.2%
23.8%
GoodNeeds ImprovementPoor
~

Summary

Needs improvement

All five CrUX field metrics are in the needs-improvement zone, with INP at 548ms effectively crossing into poor territory (>500ms threshold). The root causes are clear: a 1.1MB _app.js Next.js shared bundle that blocks rendering and interactivity, a bloated GTM container with 239 paused tags and 288 unused variables accumulated from years of campaigns, and an uncached SSR response with 1761ms field TTFB. The GTM container alone has 341 tags (70% paused) with duplicate Facebook, Snapchat, TikTok, and dead Universal Analytics trackers.

Must do
  • 1Split the 1.1MB _app.js Next.js bundle
  • 2Delete 239 paused GTM tags, 288 unused variables, and 298 orphaned triggers
  • 3Edge-cache SSR HTML on Cloudflare to reduce 1761ms field TTFB
  • 4Consolidate duplicate Facebook Pixel: remove 5 GTM Custom HTML tags + inline script, keep 1 native GTM tag
  • 5Defer advertising pixels by 3 seconds via GTM Timer triggers
Can defer
  • Lazy-load 20+ country flag SVGs that consume bandwidth during initial load
  • Preload the icon font to eliminate 5.4-second load delay
  • Consolidate duplicate Snapchat and TikTok pixel tags
Expected outcome

LCP: 3638ms → ~2500ms (good zone), CLS: 0.09 → ~0.03 (good zone), INP: 548ms → ~250-300ms (needs-improvement zone, out of poor), TTFB: 1761ms → ~500ms (good zone)

Recommendations

1criticalinpBoth

Split the 1.1MB _app.js Next.js bundle

The file _app-b08118dbafd793aa.js is 1.1MB (compressed) and takes 2885ms to download on mobile. Every import placed in _app.js (or layout.tsx in App Router) ends up in the shared bundle loaded on every page. This single file causes a 1581ms render delay (blocking LCP paint until JS executes) and is the primary driver of 4436ms TBT in lab — which maps directly to the 548ms field INP (poor zone, >500ms threshold). On a 4x CPU-throttled mobile device, parsing and executing 1.1MB of JavaScript monopolizes the main thread for several seconds, making the page completely unresponsive to taps.
1Run npx @next/bundle-analyzer to identify what's inside _app.js — look for heavy libraries (moment.js, lodash, full CSS frameworks, analytics SDKs, feature flags).
2Move page-specific imports out of _app.js into individual page components using next/dynamic with { ssr: false } for non-critical components.
3Replace heavy libraries: momentdate-fns, lodashlodash-es (tree-shakeable), full icon libraries → individual icon imports.
4Use next/dynamic for below-the-fold components (footer, modals, country selector, chat widgets).
5Verify with next build output — the shared First Load JS should be under 200KB.
Before
// _app.js or layout.tsx
import HeavyAnalytics from '@/lib/analytics'
import CountrySelector from '@/components/CountrySelector'
import ChatWidget from '@/components/ChatWidget'
import { FullLibrary } from 'lodash'
import FeatureFlags from '@/lib/feature-flags'
After
// _app.js or layout.tsx — keep only essentials
import '@/styles/globals.css'

// Move everything else to page-level or lazy-load
import dynamic from 'next/dynamic'

const CountrySelector = dynamic(() => import('@/components/CountrySelector'), {
  ssr: false,
  loading: () => null
})

const ChatWidget = dynamic(() => import('@/components/ChatWidget'), {
  ssr: false,
  loading: () => null
})

// Replace lodash with tree-shakeable imports
// import { debounce } from 'lodash-es'
Expected impact
Reducing shared bundle from 1.1MB to ~200KB would cut render delay from 1581ms to ~400ms, improving CrUX p75 LCP from 3638ms to ~2800ms and CrUX p75 INP from 548ms to ~300ms (moving from poor to needs-improvement zone)
2criticalinpBoth

Delete 239 paused GTM tags, 288 unused variables, and 298 orphaned triggers

GTM container GTM-WQXVFFG has 341 total tags — only 102 are enabled, while 239 are paused. Paused tags are still included in the container JavaScript bundle, parsed by the browser, and evaluated during execution. This is a textbook case of accumulated GTM debt from old campaigns and previous agencies. Additionally, 288 out of 460 variables are unused (not referenced by any tag or trigger), and 298 out of 339 triggers are orphaned. All of this inflates the GTM container size and increases main thread parsing time, directly worsening INP. Examples of paused tags to delete: - 68 paused GA4 Event tags (pdp #1–#68, plp #1–#16, header #1–#8, homepage #1–#11, etc.) — these are granular event tracking tags that were likely replaced by newer versions - 4 paused Snapchat Pixel tags, 5 paused TikTok Pixel tags — old campaign pixels - 29 unidentifiable paused Custom HTML tags — old vendor scripts with stripped code - 6 paused Google Tag tags — decommissioned tracking configs Steps:
1Open GTM container GTM-WQXVFFG → Tags → filter Status: Paused.
2Delete all 239 paused tags — they serve no purpose but still add to bundle weight.
3Go to Variables → delete the 288 unused variables (examples: Event #1, OptanonActiveGroups, totalOrders, paymentType, deliveryType, totalRevenue, products.0.dimension21, products.0.dimension33).
4Go to Triggers → delete the 298 orphaned triggers not attached to any active tag.
5Publish a new container version. The container JS size should drop significantly.
Google Tag Manager · GTM-WQXVFFG15 tags
GA4 Event — pdp #1
paused· GTM: pdp
DELETE
GA4 Event — plp #1
paused· GTM: plp
DELETE
GA4 Event — homepage #1
paused· GTM: homepage
DELETE
GA4 Event — header #1
paused· GTM: header
DELETE
GA4 Event — minicart #1
paused· GTM: minicart
DELETE
GA4 Event — search #1
paused· GTM: search
DELETE
GA4 Event — landing_page #1
paused· GTM: landing_page
DELETE
29 × Custom HTML (unidentifiable)
paused
DELETE
6 × Google Tag (unidentifiable)
paused
DELETE
5 × Custom Tag (__baut) (unidentifiable)
paused
DELETE
2 × Floodlight Counter (unidentifiable)
paused
DELETE
Cookie Script
paused
DELETE
Custom Tag (__pntr)
paused
DELETE
Custom Tag (__pntr) — pagevisit #1
paused· GTM: pagevisit
DELETE
Custom Tag (__pntr) — addtocart
paused· GTM: addtocart
DELETE
Expected impact
Removing 239 paused tags + 288 unused variables should reduce GTM container parse time by 200–400ms, contributing to CrUX p75 INP improvement from 548ms toward ~400ms
3highinpBoth

Consolidate duplicate Facebook Pixel: remove 5 GTM Custom HTML tags + inline script, keep 1 native GTM tag

Facebook Pixel is loaded 6 times — 5 Custom HTML tags in GTM (Facebook Pixel SDK #1, Facebook Pixel SDK #2, Facebook Pixel — ViewContent, Facebook Pixel — AddToCart, Facebook Pixel — Init) PLUS an inline script loading connect.facebook.net/en_US/fbevents.js (93.8KB, 1147ms load time). This wastes ~250ms of main thread time from duplicate SDK initialization and event processing. Facebook Pixel via Custom HTML runs synchronously in the main thread. A native GTM Facebook Pixel template is sandboxed, loads asynchronously, and is ~80-120ms faster because GTM optimizes its execution. Steps:
1In GTM GTM-WQXVFFG → Tags → search for "Facebook". Delete the 4 paused tags and replace the 1 enabled tag (Facebook Pixel — Init) with the native Facebook Pixel tag template from GTM's Community Template Gallery.
2Configure the native tag with your Pixel ID and the events you need (PageView, ViewContent, AddToCart, Purchase).
3Remove the inline Facebook Pixel snippet from your Next.js _document.js or <Head> component — search for connect.facebook.net/en_US/fbevents.js.
4Set the native GTM FB Pixel to fire on a Timer trigger (3000ms delay) to keep it out of the critical rendering path.
Google Tag Manager · GTM-WQXVFFG5 tags
Facebook Pixel SDK #1
paused· facebook.com/tr
DELETE
Facebook Pixel SDK #2
paused· facebook.com/tr
DELETE
Facebook Pixel — ViewContent
paused
DELETE
Facebook Pixel — AddToCart
paused
DELETE
Facebook Pixel — Init
html
MODIFY
Expected impact
Eliminating 5 duplicate FB Pixel instances + moving to native template saves ~250ms main thread time, contributing to CrUX p75 INP reduction from 548ms toward ~450ms
4highlcpBoth

Edge-cache SSR HTML on Cloudflare to reduce 1761ms field TTFB

CrUX field TTFB is 1761ms (needs-improvement, very close to the 1800ms poor threshold). Lab TTFB is 961ms — the 800ms gap indicates real users are geographically distant from the origin server or experience variable SSR render times. The server runs Kestrel (a .NET/Node server) behind Cloudflare Bot Management, but the HTML response has no cache-related headers — every request hits the origin for full SSR. For a product listing page, content changes at most every few minutes. Caching the HTML at Cloudflare's edge for even 60 seconds would serve most users from a nearby PoP instead of the origin.
1Add a Cloudflare Page Rule or Cache Rule for /collections/* paths: - Cache Level: Cache Everything - Edge TTL: 60 seconds - Browser TTL: 0 (respect origin)
2Set the Cache-Control header from your Next.js server for collection pages.
3Add stale-while-revalidate so Cloudflare serves stale content while fetching fresh in the background — users never wait for SSR.
4Verify: curl -sI https://www.gymshark.com/collections/leggings | grep -i cf-cache-status — should show HIT on second request.
Before
// next.config.js or API route — no cache headers
// Response header: (none related to caching)
After
// In Next.js getServerSideProps or middleware for /collections/* pages:
export async function getServerSideProps(context) {
  context.res.setHeader(
    'Cache-Control',
    'public, s-maxage=60, stale-while-revalidate=300'
  );
  // ... fetch collection data
  return { props: { /* ... */ } };
}
Expected impact
Edge-caching HTML would reduce CrUX p75 TTFB from 1761ms to ~400-600ms, improving CrUX p75 LCP from 3638ms to ~2800ms and FCP from 2939ms to ~2000ms
5highlcpBoth

Preload LCP image and set fetchpriority on all above-fold product images

The LCP element is a product image from cdn.shopify.com (VitalSeamless2_0LeggingsBlackMarl…_384x.jpg). On this collection page grid, 4-6 product images are visible in the viewport simultaneously on mobile (2 columns × 2-3 rows). The LCP element is unstable across visits — different images may become LCP depending on network conditions and load order. All above-fold product images must be optimized, not just one. Currently, product images are loaded at Low priority (waterfall rows 39-40). The browser discovers them late because they're rendered by React after JS execution.
1Add a <link rel="preload"> in <Head> for the first product image on the collection page.
2Set fetchpriority="high" on the first product image only (to avoid bandwidth contention).
3Set loading="eager" on the first 4-6 product images (the ones visible in the 2-column mobile grid).
4Ensure all remaining product images below the fold use loading="lazy".
Before
<Image
  src={product.image}
  alt={product.name}
  width={384}
  height={480}
/>
After
<!-- In _document.js or Head for collection pages -->
<link
  rel="preload"
  as="image"
  href={firstProduct.image.replace('_384x', '_384x')}
  fetchpriority="high"
/>

<!-- First product card image -->
<Image
  src={product.image}
  alt={product.name}
  width={384}
  height={480}
  priority={index === 0}
  loading={index < 6 ? 'eager' : 'lazy'}
  fetchPriority={index === 0 ? 'high' : 'auto'}
/>
Expected impact
Preloading the LCP image and eagerly loading the first 6 images would reduce LCP Resource Load Delay by 300-500ms, improving CrUX p75 LCP from 3638ms toward ~3100ms
6highinpBoth

Defer advertising pixels by 3 seconds via GTM Timer triggers

Multiple advertising pixels fire eagerly on page load, competing for main thread time during the critical interaction window. Each pixel SDK loads 100-200KB of JavaScript and initializes synchronously. Active ad pixel tags include: - Snapchat Pixel #3 and #5 (sc-static.net/scevent.min.js) - Script: js.adsrvr.org #1 and #4 (TradeDesk) - Script: redditstatic.com #3 (Reddit) - Script: c.amazon-adsystem.com #3 (Amazon) - Script: insight.adsrvr.org (TradeDesk insight) Users who leave before 3 seconds never convert — losing their tracking data is acceptable. Deferring these pixels removes them from the critical rendering path.
1In GTM GTM-WQXVFFG, create a new Timer trigger: interval 3000ms, limit 1, fires on All Pages.
2Change the trigger for each advertising pixel tag listed above from the current trigger to the new Timer trigger.
3This ensures these pixels load 3 seconds after page load, after LCP has painted and the user can interact.
Google Tag Manager · GTM-WQXVFFG7 tags
Snapchat Pixel #3
html· sc-static.net/scevent.min.js
MODIFY
Snapchat Pixel #5
html· sc-static.net/scevent.min.js
MODIFY
Script: js.adsrvr.org #1
html· js.adsrvr.org/up_loader.3.0.0.js
MODIFY
Script: js.adsrvr.org #4
html· js.adsrvr.org/up_loader.3.0.0.js
MODIFY
Script: redditstatic.com #3
html· redditstatic.com/pixel.js
MODIFY
Script: c.amazon-adsystem.com #3
html· c.amazon-adsystem.com/amzn.js
MODIFY
Script: insight.adsrvr.org
html· insight.adsrvr.org/pxl
MODIFY
Expected impact
Deferring 7 ad pixel tags by 3 seconds saves ~400-600ms of main thread blocking during the first 3 seconds, improving CrUX p75 INP from 548ms toward ~400ms
7highclsMobile

Fix USP revolving banner and MainContent layout shifts (CLS 0.09)

CrUX field CLS is 0.09 (borderline needs-improvement). Lab CLS is 0.1139 with 10 detected shifts. The two largest contributors: 1. `main#MainContent` — shift score 0.046, occurs when the product grid renders after JS hydration. The main content area resizes when React populates the product cards. 2. `div.usp-revolving_usp-revolving__SjiW5 > div.slick-slider` — shift score 0.024, the revolving USP banner ("15% Student Discount") uses a Slick slider that causes a shift when it initializes. 3. `div.product-card_tag-container__u_4lQ` — shift score 0.046, product card "NEW" badges shift when dynamically injected. Fixes: 1. For the USP revolving banner: set a fixed min-height on div.usp-revolving_usp-revolving__SjiW5 matching the rendered height (~40px on mobile) to reserve space before Slick initializes. 2. For MainContent: ensure the SSR output includes the product grid skeleton with correct dimensions, so hydration doesn't cause a reflow. 3. For product card tags: reserve space for the "NEW" badge via CSS min-height on div.product-card_tag-container__u_4lQ or use position: absolute so badges don't shift surrounding content.
Before
.usp-revolving_usp-revolving__SjiW5 {
  /* No reserved height */
}
.product-card_tag-container__u_4lQ {
  /* Dynamically sized */
}
After
.usp-revolving_usp-revolving__SjiW5 {
  min-height: 40px;
  contain: layout style;
}
.product-card_tag-container__u_4lQ {
  position: absolute;
  bottom: 12px;
  left: 12px;
  z-index: 1;
}
Expected impact
Reserving space for the USP banner and product badges would reduce CrUX p75 CLS from 0.09 to ~0.03, moving it firmly into the good zone (<0.1)
8highinpBoth

Delete 6 dead Universal Analytics tags — UA was sunset July 2023

Google Universal Analytics (UA) was permanently shut down in July 2023. The GTM container still has 6 Universal Analytics tags — 5 paused and 1 enabled. The enabled tag (Universal Analytics — intercom: open) sends data to a dead endpoint — Google no longer processes UA hits. These tags are pure waste: they load the analytics.js library, parse it, and attempt network requests to a non-functional service. Open GTM GTM-WQXVFFG → Tags → filter by type Universal Analytics. Delete all 6 tags:
Google Tag Manager · GTM-WQXVFFG6 tags
Universal Analytics — intercom: open
ua· GTM: intercom: open
DELETE
Universal Analytics — support
paused· GTM: support
DELETE
Universal Analytics — support: contact
paused· GTM: support: contact
DELETE
Universal Analytics — account: addresses #1
paused· GTM: account: addresses
DELETE
Universal Analytics — account: addresses #2
paused· GTM: account: addresses
DELETE
Universal Analytics — footer: my account
paused· GTM: footer: my account
DELETE
Expected impact
Removing dead UA tags saves ~100-150ms of main thread blocking from library loading and execution attempts, contributing to INP improvement
9mediumlcpMobile

Lazy-load 20+ country flag SVGs that consume bandwidth during initial load

Requests #19–#38 in the waterfall show 20 country flag SVG images (us.svg, au.svg, bh.svg, ca.svg, dk.svg, de.svg, eu.svg, fr.svg, fi.svg, kw.svg, nl.svg, no.svg, om.svg, qa.svg, row.svg, sa.svg, ch.svg, se.svg, ae.svg, gb.svg) loading immediately at 2336ms with Low priority. These are for the country/locale selector that is hidden behind a click/tap interaction — users never see them on initial load. Each request takes 2200–3000ms to complete, consuming network bandwidth that should go to product images and critical resources.
1Load flag SVGs only when the country selector is opened (on click/tap interaction).
2Alternatively, use a single SVG sprite file containing all flags, loaded lazily.
Before
// Country selector component — all flags loaded eagerly
import usFlag from '@/static/media/us.svg'
import auFlag from '@/static/media/au.svg'
import bhFlag from '@/static/media/bh.svg'
// ... 17 more imports
After
// Country selector component — flags loaded on interaction
import dynamic from 'next/dynamic'

const CountryFlags = dynamic(
  () => import('@/components/CountryFlags'),
  { ssr: false, loading: () => <div className="flag-placeholder" /> }
)

// Only render when selector is open
{isSelectorOpen && <CountryFlags />}
Expected impact
Removing 20 concurrent requests from initial load frees network bandwidth for product images, reducing LCP by ~100-200ms on mobile
10mediumlcpBoth

Preload the icon font to eliminate 5.4-second load delay

The icon font gymshark-icons.70b7ab93.woff (23.3KB) starts loading at 2212ms but doesn't complete until 7668ms — a 5456ms duration. This is because the font is discovered only after CSS is parsed (CSS references the font via @font-face), and on a throttled mobile connection it's queued behind the massive JS bundles competing for bandwidth. The browser's preload scanner cannot discover fonts referenced in CSS. Adding a <link rel="preload"> in the <head> lets the browser start fetching the font immediately alongside other critical resources.
Before
<!-- No preload for icon font -->
After
<link
  rel="preload"
  as="font"
  type="font/woff"
  href="/_next/static/media/gymshark-icons.70b7ab93.woff"
  crossorigin="anonymous"
/>
Expected impact
Preloading the icon font would start its download ~1.5s earlier, reducing total font load time from 5456ms to ~2000ms and preventing icon display delays that may indirectly affect CLS

This is a demo report. Want one for your site?

Analyze Your Site Free
Gymshark — CWV Audit Report | FixMyCWV