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.Field Data — Mobile (Real Users)
Core Web Vitals PoorChrome UX Report — p75 values from real mobile user experiences over the last 28 days
Summary
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.
- 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
- —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
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
Reduce massive JavaScript payload blocking main thread — INP is in the POOR zone
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.Implement React Server Components and granular Suspense boundaries to reduce hydration cost
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.// 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>
);
}// 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 cardConsolidate excessive JS chunks — 40+ small files create scheduling overhead on mobile
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.// next.config.js — default splitting (too granular)
module.exports = {
// No custom webpack config — default splitChunks
};// 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;
},
};Reduce TTFB — field p75 is 1020ms, consuming 40% of LCP budget
/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.// Category page — dynamic render on every request
export default async function BedsPage() {
const products = await fetch('https://api.wayfair.com/products/beds');
// ...
}// 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
}Investigate large field CLS gap — lab shows 0.001 but real users experience 0.08
new PerformanceObserver(list => { list.getEntries().forEach(e => console.log(e)); }).observe({type: 'layout-shift', buffered: true});Reserve explicit dimensions for all product card images in the grid
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.<img src="https://assets.wfcdn.com/im/02698639/resize-h600-w600.../Besan+Bed.jpg" alt="Besan Solid Wood Bed" loading="lazy" />
<!-- 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' }}
/>Preload the LCP product image to eliminate render delay
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.<head> <!-- No preload for LCP image --> </head>
<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>Defer PerimeterX bot detection and other third-party scripts by 3 seconds
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.<script src="https://prx.wayfair.com/px/client.js" async></script> <script> // PerimeterX init runs immediately window._pxAppId = 'PX3Vk96I6i'; </script>
<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>Use scheduler.yield() instead of setTimeout for breaking long tasks
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.// 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));
}
}
}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();
}
}
}Use font-display: optional for body text and preload heading fonts to prevent swap CLS
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.@font-face {
font-family: 'WayfairSans';
src: url('/fonts/wayfair-sans.woff2') format('woff2');
font-display: swap;
}/* 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%;
}