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

Analyze Your Site Free

Audit Results

https://www.wayfair.com/furniture/sb0/beds-c46122.html

Lighthouse Lab Data

Measured in a simulated environment. Values may differ from real user experience.
69
Performance Score
Largest Contentful Paint
1.9 s
Cumulative Layout Shift
0.0015
Interaction to Next Paint
2182 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)
2.5 sNeeds Improvement
74.3%
16.1%
9.6%
Interaction to Next Paint (INP)
1659 msNeeds Improvement
31.9%
25.7%
42.4%
Cumulative Layout Shift (CLS)
0.08Needs Improvement
78.0%
17.7%
Time to First Byte (TTFB)
1020 msNeeds Improvement
59%
34.6%
GoodNeeds ImprovementPoor
~

Summary

Needs improvement

Wayfair's beds category page has a critical INP problem — CrUX field INP is 1659ms (poor zone, >500ms threshold). The root cause is a massive Next.js JavaScript payload with 40+ chunks causing 5456ms TBT on mobile. All other CrUX metrics (LCP 2537ms, CLS 0.08, TTFB 1020ms) are in the needs-improvement zone. There's a significant lab-vs-field CLS gap (0.001 vs 0.08), indicating scroll-triggered layout shifts invisible to Lighthouse.

Must do
  • 1Reduce massive JavaScript payload blocking main thread — INP is in the POOR zone
  • 2Implement React Server Components and granular Suspense boundaries to reduce hydration cost
  • 3Reduce TTFB — field p75 is 1020ms, consuming 40% of LCP budget
  • 4Investigate large field CLS gap — lab shows 0.001 but real users experience 0.08
Can defer
  • Use scheduler.yield() instead of setTimeout for breaking long tasks
  • Use font-display: optional for body text and preload heading fonts to prevent swap CLS
  • Consolidate excessive JS chunks — 40+ small files create scheduling overhead on mobile
Expected outcome

LCP: 2537ms → ~2100ms (into good zone), CLS: 0.08 → ~0.03 (solidly good), INP: 1659ms → ~600-900ms (from poor to needs-improvement/good boundary), TTFB: 1020ms → ~500ms (good zone)

Recommendations

1criticalinpMobile

Reduce massive JavaScript payload blocking main thread — INP is in the POOR zone

CrUX field INP is 1659ms (p75) — firmly in the poor zone (>500ms). Lab TBT confirms the severity at 5456ms with 6306ms total main thread work. The waterfall shows 40+ JavaScript chunks loaded as high priority from assets.wfcdn.com, totaling well over 1MB of JS in the initial load. This is a Next.js application, and every chunk must be parsed, compiled, and executed on mobile CPUs with 4x throttle. The sheer volume of JS is the root cause of the catastrophic INP. The fix requires an architectural review of the Next.js bundle. Use @next/bundle-analyzer to identify what's in the shared app bundle. Common culprits on e-commerce category pages: full product card interactive logic loaded for all cards (only the visible 2-4 need immediate hydration), filter/sort UI logic loaded eagerly, and vendor libraries (lodash, moment, animation libraries) imported at the app level instead of per-route.
Expected impact
Reducing JS payload by 30-40% through tree-shaking, dynamic imports, and eliminating dead code could improve field INP from 1659ms → ~800-1000ms and TBT from 5456ms → ~3000ms. This is the single highest-impact change.
2criticalinpMobile

Implement React Server Components and granular Suspense boundaries to reduce hydration cost

The LCP breakdown shows 1148ms render delay — the gap between resources being ready and actual paint. This is React hydration blocking the main thread. On this category page, most product cards below the first row don't need immediate interactivity, yet the entire component tree hydrates synchronously. With Next.js App Router (which Wayfair appears to use based on the app/ chunk paths), convert product listing components to React Server Components (RSC). Server Components don't ship JS to the client and don't require hydration — they render pure HTML. Only interactive elements ("Add to Cart" buttons, wishlist hearts, filter controls) need to be Client Components. For remaining Client Components, wrap them in granular <Suspense> boundaries. Critical warning: don't wrap large subtrees in a single Suspense. If a user clicks a non-hydrated area inside a large Suspense boundary, React will synchronously force-hydrate the entire subtree — creating an even worse INP spike. Each interactive element (filter button, "Add to Cart") should have its own small Suspense boundary.
Before
// Current: entire ProductCard is a Client Component
'use client';
export function ProductCard({ product }) {
  return (
    <div className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{product.price}</p>
      <button onClick={() => addToCart(product)}>Add to Cart</button>
      <button onClick={() => toggleWishlist(product)}>♡</button>
    </div>
  );
}
After
// Server Component (no 'use client' — zero JS shipped)
export function ProductCard({ product }) {
  return (
    <div className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{product.price}</p>
      <Suspense fallback={<button disabled>Add to Cart</button>}>
        <AddToCartButton productId={product.id} />
      </Suspense>
      <Suspense fallback={<span>♡</span>}>
        <WishlistButton productId={product.id} />
      </Suspense>
    </div>
  );
}

// Only these tiny components are Client Components
// AddToCartButton.tsx — 'use client'; ~2KB instead of entire card
Expected impact
RSC eliminates hydration for non-interactive content. On a category page with 48+ product cards, this could reduce client JS by 50-70%, improving INP from 1659ms → ~600-900ms and render delay from 1148ms → ~400ms.
3highinpMobile

Consolidate excessive JS chunks — 40+ small files create scheduling overhead on mobile

The waterfall shows 40+ separate JS chunks in the first batch alone, with many under 10KB (e.g., 55-b6493f… at 4.7KB, 104-f1f2c0… at 5.9KB, 2503-24ddd4… at 5.9KB, 9938-160110… at 5.3KB, 6712-16d0e7… at 5.7KB). While HTTP/2 multiplexing handles parallel downloads, each chunk still incurs per-request overhead: headers, TLS record framing, browser task scheduling for parse/compile/execute. On mobile CPUs with 4x throttle, the scheduling overhead from 40+ micro-tasks compounds significantly. The optimal range is 5-15 chunks per route. Configure Next.js splitChunks to set a minimum chunk size of 20KB and merge related small modules.
Before
// next.config.js — default splitting (too granular)
module.exports = {
  // No custom webpack config — default splitChunks
};
After
// next.config.js — optimized chunk merging
module.exports = {
  webpack: (config, { isServer }) => {
    if (!isServer) {
      config.optimization.splitChunks = {
        ...config.optimization.splitChunks,
        minSize: 20000,        // 20KB minimum chunk
        maxAsyncRequests: 15,  // limit parallel chunks
        cacheGroups: {
          ...config.optimization.splitChunks.cacheGroups,
          commons: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendors',
            chunks: 'all',
            minSize: 20000,
          },
        },
      };
    }
    return config;
  },
};
Expected impact
Merging ~25 small chunks into ~10 larger ones reduces per-chunk scheduling overhead. Expected 200-400ms TBT reduction on mobile, improving field INP from 1659ms → ~1400ms.
4highlcpBoth

Reduce TTFB — field p75 is 1020ms, consuming 40% of LCP budget

CrUX field TTFB is 1020ms (needs-improvement, threshold is 800ms). This eats 40% of the 2500ms LCP budget before any rendering begins. Lab TTFB is 716ms, so real-world conditions add ~300ms — likely from geographic distance to origin, server-side rendering time for the category page with 50,000+ items, or cache miss rates. For a Next.js app serving a category page like /furniture/sb0/beds-c46122.html, this should be a statically generated or ISR (Incremental Static Regeneration) route — not dynamically rendered on every request. The product listing data changes infrequently enough to tolerate a 60-second revalidation window. Important for Next.js 15: if migrating from v14, be aware that the default caching behavior changed from force-cache to no-store. You must explicitly opt in to caching with { cache: 'force-cache' } or next.revalidate on fetch calls, or TTFB will spike as every request hits the origin.
Before
// Category page — dynamic render on every request
export default async function BedsPage() {
  const products = await fetch('https://api.wayfair.com/products/beds');
  // ...
}
After
// Category page — ISR with 60s revalidation
export const revalidate = 60; // seconds

export default async function BedsPage() {
  const products = await fetch('https://api.wayfair.com/products/beds', {
    next: { revalidate: 60 },
  });
  // Served from edge cache for 60s, then revalidated
}
Expected impact
ISR or edge caching could reduce field TTFB from 1020ms → ~400-600ms, which directly improves LCP from 2537ms → ~2100-2200ms — pushing it into the good zone.
5highclsMobile

Investigate large field CLS gap — lab shows 0.001 but real users experience 0.08

Lab CLS is 0.0015 (excellent) but CrUX field CLS is 0.08 (borderline needs-improvement, threshold 0.1). This 53x gap means real users experience layout shifts that Lighthouse cannot detect. Lighthouse only measures CLS during initial page load — it doesn't scroll. On this category page, likely causes of scroll-triggered CLS:
1Lazy-loaded product images below the fold without reserved dimensions — as users scroll, images load and push content
2Dynamic promo banners (the purple "UP TO 60% OFF" banner may inject/resize after initial render)
3"Add to Cart" / "Quickview" buttons or price badges rendering after JS hydration
4Sticky header behavior on scroll — if the header changes height (e.g., search bar collapses), everything below shifts
5Infinite scroll / pagination loading additional product rows without pre-reserved space To diagnose: open Chrome DevTools → Performance panel → check "Screenshots" → record while scrolling the entire page. Look for blue "Layout Shift" bars. The Layout Instability API can also log shifts in production: new PerformanceObserver(list => { list.getEntries().forEach(e => console.log(e)); }).observe({type: 'layout-shift', buffered: true});
Expected impact
Fixing scroll-triggered CLS sources could reduce field CLS from 0.08 → ~0.02-0.04, solidly within the good zone. Focus on the product grid — it's the largest content area with dynamic elements.
6highclsMobile

Reserve explicit dimensions for all product card images in the grid

The product grid images (e.g., resize-h600-w600 from wfcdn.com) are loaded lazily as users scroll. If these <img> elements don't have explicit width and height attributes (or CSS aspect-ratio), each image load causes a layout shift. With ~48 product images on a category page, even small per-image shifts compound into the 0.08 CLS seen in CrUX. Since this is a Next.js app, use the next/image component with explicit dimensions. Critical: do NOT use loading="lazy" on the first 2-4 product images visible in the initial viewport — they contribute to LCP. For the LCP image specifically, add the priority prop which disables lazy-load and adds a preload hint.
Before
<img
  src="https://assets.wfcdn.com/im/02698639/resize-h600-w600.../Besan+Bed.jpg"
  alt="Besan Solid Wood Bed"
  loading="lazy"
/>
After
<!-- LCP product image (first visible in grid) -->
<Image
  src="https://assets.wfcdn.com/im/02698639/resize-h600-w600.../Besan+Bed.jpg"
  alt="Besan Solid Wood Bed"
  width={600}
  height={600}
  priority
/>

<!-- Below-fold product images -->
<Image
  src="https://assets.wfcdn.com/im/.../Other+Bed.jpg"
  alt="Other Bed"
  width={600}
  height={600}
  loading="lazy"
  style={{ aspectRatio: '1/1' }}
/>
Expected impact
Adding explicit dimensions to all product images prevents per-image layout shifts during scroll. Expected CLS reduction from 0.08 → ~0.03, particularly on slower mobile connections where images load incrementally.
7highlcpMobile

Preload the LCP product image to eliminate render delay

The LCP element is a product image at assets.wfcdn.com/im/02698639/resize-h600-w600%5Ecompr-r85/4066/406676443/Besan+Solid+Wood+...jpg. While the LCP breakdown shows 0ms resource load delay (the image URL is in the HTML), the 1148ms render delay indicates JS execution blocks the paint. A preload hint tells the browser to start fetching the image at the highest priority immediately during HTML parsing — before any JS executes — so the image is ready the instant hydration completes. Since this is Next.js, use priority on the next/image component (which automatically adds a <link rel="preload">). If using raw <img>, add the preload manually. Only apply fetchpriority="high" to this one image — applying it to multiple resources causes bandwidth contention and actually slows the LCP image.
Before
<head>
  <!-- No preload for LCP image -->
</head>
After
<head>
  <link
    rel="preload"
    as="image"
    href="https://assets.wfcdn.com/im/02698639/resize-h600-w600%5Ecompr-r85/4066/406676443/Besan+Solid+Wood+Boucle+Upholstered+Headboard+Bed+Frame.jpg"
    fetchpriority="high"
  />
</head>
Expected impact
Preloading ensures the LCP image is cached by the time hydration unblocks rendering. Expected 100-200ms LCP improvement in field, helping push LCP from 2537ms → ~2300ms.
8highinpMobile

Defer PerimeterX bot detection and other third-party scripts by 3 seconds

The waterfall shows PerimeterX (prx.wayfair.com/px/xhr/api/v1/collector/noScript.gif?appId=PX3Vk96I6i) loading at high priority at 842ms — competing with critical CSS and JS for bandwidth during the most important rendering window. PerimeterX's JS client typically adds 200-400ms of main thread blocking for fingerprinting and behavioral analysis. Bot detection does not need to run before the first screen renders. Users who bounce before the page loads are irrelevant for bot scoring. Defer PerimeterX and any analytics scripts by 3 seconds using setTimeout. This removes them from the critical rendering path while preserving 95%+ measurement accuracy for meaningful sessions. Do NOT add `defer` to the GTM container script itself — this breaks dataLayer.push() events from inline scripts. Instead, load GTM normally but configure marketing/analytics tags inside GTM to fire on a Timer trigger (3-5 seconds) or Window Loaded + 2s.
Before
<script src="https://prx.wayfair.com/px/client.js" async></script>
<script>
  // PerimeterX init runs immediately
  window._pxAppId = 'PX3Vk96I6i';
</script>
After
<script>
  setTimeout(function() {
    var s = document.createElement('script');
    s.src = 'https://prx.wayfair.com/px/client.js';
    s.async = true;
    document.head.appendChild(s);
    window._pxAppId = 'PX3Vk96I6i';
  }, 3000);
</script>
Expected impact
Deferring PerimeterX and analytics by 3s frees 200-400ms of main thread time during the critical window. Expected TBT reduction of 300-500ms, improving field INP from 1659ms → ~1300-1400ms.
9mediuminpMobile

Use scheduler.yield() instead of setTimeout for breaking long tasks

With 5456ms TBT, long tasks must be broken up. The standard advice is setTimeout(fn, 0) to yield to the main thread — but this places the continuation at the end of the browser's macro-task queue. If PerimeterX, analytics, or other third-party scripts have already queued tasks, your continuation waits behind all of them, making the UI feel frozen. scheduler.yield() (Prioritized Task Scheduling API) is the correct replacement. It pauses your function, yields to the main thread for user input processing, then places your continuation at the front of the priority queue — ahead of third-party tasks. This ensures user interactions (filter clicks, Add to Cart) are processed immediately. Additionally, if processing large product arrays (filtering/sorting 50,000+ items client-side), do not use forEach/map/reduce — these are synchronous and cannot be interrupted. Use for loops with time-based yielding: check performance.now() every iteration and yield when execution exceeds 50ms.
Before
// Breaking long task with setTimeout — loses priority
async function processProducts(products) {
  for (const product of products) {
    renderProduct(product);
    if (needsYield()) {
      await new Promise(r => setTimeout(r, 0));
    }
  }
}
After
async function processProducts(products) {
  let lastYield = performance.now();
  for (const product of products) {
    renderProduct(product);
    if (performance.now() - lastYield > 50) {
      if ('scheduler' in window && 'yield' in scheduler) {
        await scheduler.yield();
      } else {
        await new Promise(r => setTimeout(r, 0));
      }
      lastYield = performance.now();
    }
  }
}
Expected impact
Proper yielding ensures user interactions aren't queued behind background tasks. Expected 100-200ms improvement in worst-case INP interactions (filter/sort clicks), contributing to field INP reduction from 1659ms → ~1400-1500ms.
10mediumclsBoth

Use font-display: optional for body text and preload heading fonts to prevent swap CLS

The 53x CLS gap between lab (0.0015) and field (0.08) may be partially caused by web font swaps. If Wayfair's body fonts use font-display: swap (the Google Fonts default since 2019), the fallback-to-custom font swap causes layout shifts as text reflows — especially in product titles, prices, and descriptions across dozens of product cards. The fix is to differentiate by text role: - Body/paragraph text (product descriptions, prices): use font-display: optional. The browser tries to load the font for ~100ms; if it fails, it renders the system font and never swaps — guaranteeing zero CLS. The custom font caches silently for the next navigation. - Heading/brand text (category title "Beds", product names): font-display: swap is acceptable for 1-2 line elements, but pair it with size-adjust, ascent-override, and descent-override on the fallback @font-face to match metrics and minimize shift. Self-hosting the fonts from assets.wfcdn.com (instead of Google Fonts) eliminates the two-domain sequential request chain (fonts.googleapis.com → fonts.gstatic.com) which adds 200-500ms.
Before
@font-face {
  font-family: 'WayfairSans';
  src: url('/fonts/wayfair-sans.woff2') format('woff2');
  font-display: swap;
}
After
/* Body text — zero CLS guaranteed */
@font-face {
  font-family: 'WayfairSans';
  src: url('/fonts/wayfair-sans-regular.woff2') format('woff2');
  font-weight: 400;
  font-display: optional;
}

/* Headings — swap OK with metric matching */
@font-face {
  font-family: 'WayfairSans';
  src: url('/fonts/wayfair-sans-bold.woff2') format('woff2');
  font-weight: 700;
  font-display: swap;
}

/* Fallback with matched metrics */
@font-face {
  font-family: 'WayfairSans-Fallback';
  src: local('Arial');
  size-adjust: 97.2%;
  ascent-override: 92%;
  descent-override: 24%;
  line-gap-override: 0%;
}
Expected impact
Eliminating font swap CLS for body text across 48+ product cards could reduce field CLS by 0.01-0.03, contributing to moving CLS from 0.08 → ~0.05.

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

Analyze Your Site Free
Wayfair — CWV Audit Report | FixMyCWV