Audit Results
https://www.wayfair.com/furniture/sb0/beds-c46122.html
MagentoLighthouse 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
Lab vs Field Discrepancies
- Field INP is POOR (1206ms) but lab TBT suggests GOOD. Real user interactions reveal issues not caught in lab. CrUX field data is the ground truth — INP recommendations are critical.
Summary
The page suffers from severe JavaScript-induced interactivity problems on Next.js: 89 chunks totalling ~4.5MB cause 9.8s of blocking time in lab and 1206ms field INP. The LCP image is discovered as the 99th resource because heavy JS loads first, and HTML is not cached at the edge (cf-cache-status: DYNAMIC) despite running through Cloudflare. Fixing bundle architecture, preloading the LCP image, and enabling edge caching for anonymous traffic will move all three Core Web Vitals from needs-improvement into the good zone.
- 1Reduce INP: split Next.js JS bundles and defer non-critical hydration
- 2Preload LCP product image and add fetchpriority=high
- 3Enable HTML edge caching on Cloudflare for category pages
- 4Defer third-party tracking and analytics by 3 seconds
- —Consolidate Next.js chunks — too many small files cause network contention
- —Add font-display: swap to Sofia web fonts
- —Serve product images in AVIF/WebP with correct sizes
- —Reduce DOM size from 5554 elements
- —Investigate CLS gap between lab (0.000) and CrUX (0.08)
- —Add scheduler.yield() to long-running click handlers
LCP: 2548ms → ~1800ms (good), INP: 1206ms → ~450ms (still needs-improvement but usable, target good zone after recommendation #10), CLS: 0.08 → ~0.03 (good), TTFB: 1088ms → ~250ms (good).
Estimates account for detected platform. Actual time may vary based on codebase complexity and developer experience.
Recommendations
Reduce INP: split Next.js JS bundles and defer non-critical hydration
assets.wfcdn.com/webpack/sf-ui-core-funnel/_next/static/chunks/. Lab TBT = 9829ms with 37 long tasks totalling 11679ms — this is React hydration blocking the main thread. Real users click on products and the UI freezes for >1s. @next/bundle-analyzer — many chunks (global-error 118KB, 5967 105KB, bc5c711d 114KB, 2d7c1f16 110KB) load on every page. Move page-specific imports out of _app.tsx/layout.tsx.next/dynamic with ssr: false or loading placeholder.<Suspense> boundaries so React can prioritize the visible Sort/Filter/Add-to-cart buttons first.setTimeout(fn, 0) yield patterns with await scheduler.yield() so user clicks aren't queued behind 3rd-party scripts.Preload LCP product image and add fetchpriority=high
<link rel="preload" as="image" fetchpriority="high" href="https://assets.wfcdn.com/im/10051340/resize-h500-w500%5Ecompr-r85/3655/365590467/Ardin+Solid+Wood+Bed+Frame...jpg"> in the document <head>. On a category page where LCP varies between visits, preload the first 2-3 product images (grid LCP is unstable — different images become LCP on different devices).fetchpriority="high" and loading="eager" on the first 4-6 product image <img> tags (first 2 rows of the grid on mobile).fetchpriority="low" on hero/promo banners that are NOT LCP (e.g., "Dreamy Deals" carousel) to free bandwidth for the product LCP image.loading="lazy" on the first row of product images.<img src="...Ardin+Solid+Wood+Bed...jpg" loading="lazy" />
<!-- in <head> --> <link rel="preload" as="image" fetchpriority="high" href="https://assets.wfcdn.com/im/.../Ardin+Solid+Wood+Bed....jpg" imagesrcset="..." imagesizes="(max-width: 768px) 50vw, 25vw"> <!-- in markup --> <img src="...Ardin+Solid+Wood+Bed....jpg" loading="eager" fetchpriority="high" width="500" height="500" alt="..." />
Enable HTML edge caching on Cloudflare for category pages
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate and cf-cache-status: DYNAMIC — every HTML request hits the origin. TTFB = 654ms lab / 1088ms CrUX p75, contributing ~25% of LCP. Category page content (/beds-c46122.html) changes infrequently (prices, stock) and is identical for anonymous users — it should be cached at the edge. host = www.wayfair.com AND URI Path matches /furniture/sb0/.*\.html AND cookie does not contain session/cart-id → action: Cache Eligibility: Eligible for cache, Edge TTL: 5 minutes, Browser TTL: 0.Cache-Control: public, s-maxage=300, stale-while-revalidate=86400. stale-while-revalidate lets the edge serve a slightly stale page instantly while revalidating in background.103 Early Hints (Speed → Optimization → Early Hints) to ship Link: preload headers before HTML generation completes.Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate cf-cache-status: DYNAMIC
Cache-Control: public, s-maxage=300, stale-while-revalidate=86400 cf-cache-status: HIT Vary: Accept-Encoding
Defer third-party tracking and analytics by 3 seconds
Ping requests and 51 Fetch requests are analytics beacons firing during initial load, competing with critical JS for main thread time and bandwidth. This directly inflates INP because every interaction races against tracking handlers. <!-- Marketing pixels firing immediately on every page load --> <script src="https://pixel.example.com/track.js"></script>
<script>
function loadDeferred() {
var s = document.createElement('script');
s.async = true; s.src = 'https://pixel.example.com/track.js';
document.head.appendChild(s);
}
if ('requestIdleCallback' in window) {
requestIdleCallback(loadDeferred, { timeout: 3000 });
} else {
setTimeout(loadDeferred, 3000);
}
</script>Consolidate Next.js chunks — too many small files cause network contention
9657 7.8KB, 108 5.5KB, 1762 7.2KB, 3977 5.8KB). Aggressive code-splitting past 15 chunks causes HTTP/2 scheduling overhead — headers + TLS record size outweigh the savings, especially on mobile with high RTT. Multiple chunks have TTFB >2000ms because they're queued behind earlier chunks.
1. In next.config.js, tune webpack.optimization.splitChunks to merge tiny chunks: set minSize: 30000 (30KB) and maxAsyncRequests: 15.
2. Use route-based splitting: 1 main chunk + 1 vendor chunk + 1 per-route chunk + a few async chunks for heavy below-fold widgets. Target 8-15 chunks total, not 89.
3. Ensure vendor chunk has a long cache hash so it's reused across navigations.
4. Remove unused dependencies — run npx depcheck and bundle analyzer to find dead imports.// next.config.js (default Next.js splitting) // Result: 89 chunks, many <10KB
// next.config.js
module.exports = {
webpack: (config, { isServer }) => {
if (!isServer) {
config.optimization.splitChunks = {
chunks: 'all',
minSize: 30000,
maxAsyncRequests: 15,
maxInitialRequests: 10,
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
priority: 10,
},
},
};
}
return config;
},
};Add font-display: swap to Sofia web fonts
font-display: sofia-bold-subset.woff2 and sofia-reg-subset.woff2. Browsers default to block behaviour for ~3s — invisible text (FOIT) during the critical 0-3s window. This also delays the moment FCP/LCP can be measured if the LCP element contains text. font-display: optional for body text (zero CLS — if font loads within 100ms it's used, otherwise cached for next navigation).font-display: swap for headings only, combined with <link rel="preload" as="font" crossorigin> + size-adjust/ascent-override descriptors on the fallback to prevent CLS.<head>.@font-face {
font-family: 'Sofia';
src: url('/sofia-reg-subset.woff2') format('woff2');
}<link rel="preload" as="font" type="font/woff2" crossorigin
href="https://assets.wfcdn.com/homebase/sofia/sofia-reg-subset.woff2">
@font-face {
font-family: 'Sofia';
src: url('/sofia-reg-subset.woff2') format('woff2');
font-display: optional;
size-adjust: 100%;
ascent-override: 90%;
}Serve product images in AVIF/WebP with correct sizes
compr-r85 quality 85). AVIF typically saves 40-50% over JPEG at the same visual quality; WebP saves 25-35%. With 70 images, total image weight could drop by ~700KB. assets.wfcdn.com likely supports format negotiation — add Accept header routing or f_auto URL parameter).<picture> with <source type="image/avif"> + <source type="image/webp"> + JPEG fallback. Add Vary: Accept response header on the CDN to prevent wrong format being cached for the wrong browser.resize-h500-w500 for cards that display at ~150x150 on mobile means 11x more pixels than needed. Use srcset with w=200, w=400, w=800 variants and proper sizes attribute.<img src=".../resize-h500-w500%5Ecompr-r85/.../bed.jpg" />
<picture> <source type="image/avif" srcset=".../bed.avif?w=200 200w, .../bed.avif?w=400 400w" sizes="(max-width: 768px) 50vw, 25vw"> <source type="image/webp" srcset=".../bed.webp?w=200 200w, .../bed.webp?w=400 400w" sizes="(max-width: 768px) 50vw, 25vw"> <img src=".../bed.jpg?w=400" width="400" height="400" loading="lazy" alt="..."> </picture>
Reduce DOM size from 5554 elements
Investigate CLS gap between lab (0.000) and CrUX (0.08)
min-height BEFORE they appear, or position them fixed/sticky (fixed elements don't cause CLS).min-height or aspect-ratio set before content arrives.Add scheduler.yield() to long-running click handlers
PerformanceObserver for event entries with duration >200ms in production.scheduler.yield() (or await new Promise(r => setTimeout(r, 0)) as fallback). Update UI feedback (loading state) immediately, defer heavy work to after the yield.useTransition / startTransition in React 18 for non-urgent state updates (filter results, sort) so the input doesn't block.AbortController to cancel in-flight handlers when the user rapidly clicks/types (debounce + cancel).function onAddToCart(item) {
updateCartState(item); // 400ms
trackEvent('add_to_cart', item); // 100ms
showToast('Added!'); // 50ms
}async function onAddToCart(item) {
showToast('Adding...'); // instant feedback
if ('scheduler' in window && 'yield' in scheduler) {
await scheduler.yield();
}
startTransition(() => updateCartState(item));
await scheduler.yield();
trackEvent('add_to_cart', item);
showToast('Added!');
}