The first time I opened Chrome DevTools to audit our storefront on a simulated 4G mobile connection, Lighthouse gave it a 41.
The Largest Contentful Paint (LCP) was sitting at nearly 5 seconds. The hero image took an eternity to render, category navigation had a noticeable delay, and scrolling past the first row of products caused layout jumps that made buttons jump right under your finger.
It wasn’t that Laravel was slow or that React 19 was the wrong choice. It was the typical death-by-a-thousand-cuts that happens when an e-commerce project moves fast: unconstrained images uploaded directly from admin cameras, default bundling settings pulling heavy dashboard dependencies into the public customer bundle, and database queries doing redundant joins on every catalog view.
Here is what I actually did to bring the mobile score into the mid-90s, what worked, and a few dead ends I hit along the way.
Where the Seconds Were Actually Leaking
Before changing any code, I ran five consecutive Lighthouse audits on throttled mobile profiles and recorded a performance trace in Chrome. The bottlenecks grouped into three clear problems:
- Bundle contamination on first paint: I had our customer storefront and our back-office admin dashboard in the same monorepo. Because of standard import paths, charting libraries (
recharts) and rich text editors (react-quill) were leaking directly into the initial customer bundle. A visitor who just wanted to buy a shirt was downloading charting code meant for sales reports. - Raw, unscaled product imagery: Sellers were uploading 3MB JPEG product photos directly from phones and DSLRs. When the storefront rendered a 2-column mobile grid, it was loading desktop-resolution files into 180px containers without width descriptors, while missing aspect-ratio locks threw Cumulative Layout Shift (CLS) up to 0.28.
- Repeated database joins on navigation: Every filter click and category page hit the Laravel API for taxonomy definitions, product variant joins, and attribute lists without any memory caching. TTFB hovered around 1.2 seconds under standard load.
1. Separating the Storefront and Bundling Surgically
The setup is a Vite + React 19 single-page app talking to a Laravel 11 REST API backend.
[ Customer on Mobile 4G ]
│
▼
[ Vite + React 19 Storefront ]
├── Zustand: Local UI state & cart drawer
├── TanStack Query: Stale-while-revalidate API caching
├── Surgical manualChunks: Admin deps isolated from visitor path
└── Aspect-ratio locked <Image /> components
│
▼ (REST API)
[ Laravel 11 Backend ]
├── On-demand WebP Resizing (GD + Disk Cache)
├── Taxonomy & Filter In-Memory Cache (Redis)
└── Model lifecycle hooks for cache invalidation
The Bundle Isolation Mistake
My first instinct in vite.config.ts was to split almost every package into its own vendor chunk. I thought more granular chunks would always mean better caching.
I even gave zustand its own chunk:
// What I tried first (do not do this):
if (id.includes("zustand")) return "vendor-zustand";
That was a mistake. Splitting Zustand into its own chunk created an extra network round-trip for a file that was barely 2.4 KB gzipped. On a high-latency mobile connection, that extra handshake delayed script execution instead of speeding it up.
I reverted that and consolidated micro-dependencies into the core React runtime chunk. What actually mattered was isolating the massive libraries that customers never needed:
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import viteCompression from "vite-plugin-compression";
export default defineConfig({
plugins: [
react(),
viteCompression({ algorithm: "gzip", ext: ".gz" }),
],
esbuild: {
// Strip debug logs in production
drop: ["console"],
},
build: {
rollupOptions: {
output: {
manualChunks(id) {
// 1. Core React runtime + essential micro-libs combined
if (
id.includes("/node_modules/react/") ||
id.includes("/node_modules/react-dom/") ||
id.includes("/node_modules/scheduler/") ||
id.includes("use-sync-external-store") ||
id.includes("zustand")
) {
return "vendor-react";
}
// 2. Admin-only heavy tools: kept strictly off the customer path
if (id.includes("recharts")) return "vendor-admin-charts";
if (id.includes("react-quill")) return "vendor-admin-editor";
// 3. Storefront data and icons
if (id.includes("@tanstack")) return "vendor-query";
if (id.includes("lucide-react")) return "vendor-icons";
if (id.includes("react-hook-form") || id.includes("@hookform") || id.includes("/node_modules/zod/")) {
return "vendor-forms";
}
},
},
},
},
});
By pushing recharts and react-quill into isolated admin chunks, the initial JavaScript bundle loaded by visitors dropped by over 180 KB gzipped.
2. Dynamic WebP Resizing and Layout-Shift Defense
Images were responsible for over 70% of total transfer weight on product listing pages. I needed an image strategy that satisfied two rules:
- Don’t make the user download 1800px images on a 390px mobile viewport.
- Don’t let images cause layout shifts while they load.
Building the On-Demand Pipeline in Laravel
I wrote a small controller in Laravel to generate resized WebP images on the fly, but my first implementation had a problem: I didn’t cache the resized results to disk. When I opened a category view with 20 items, the server tried to run PHP’s GD library 20 times concurrently, memory spiked, and response times tanked.
The fix was straightforward: save generated thumbnails directly to storage/app/public/responsive/{width}/{path}. Once a size is generated the first time, all subsequent hits are served directly from disk with long-lived cache headers:
// ResponsiveImageController.php
public function show(Request $request)
{
$path = $request->query('path');
$width = (int) $request->query('width', 800);
// Validate permitted widths to avoid cache-flooding attacks
$allowedWidths = [400, 640, 800, 1200, 1600];
if (!in_array($width, $allowedWidths, true)) {
return response('Invalid width requested', 400);
}
$cachePath = "responsive/{$width}/" . ltrim($path, '/');
if (Storage::disk('public')->exists($cachePath)) {
return response(Storage::disk('public')->get($cachePath), 200, [
'Content-Type' => 'image/webp',
'Cache-Control' => 'public, max-age=31536000, immutable',
]);
}
// Load original, resize with GD, and save to cache
$rawImage = Storage::disk('public')->get($path);
$source = imagecreatefromstring($rawImage);
$origWidth = imagesx($source);
$origHeight = imagesy($source);
$height = (int) round(($origHeight / $origWidth) * $width);
$target = imagecreatetruecolor($width, $height);
imagealphablending($target, false);
imagesavealpha($target, true);
imagecopyresampled($target, $source, 0, 0, 0, 0, $width, $height, $origWidth, $origHeight);
ob_start();
imagewebp($target, null, 82);
$webpData = ob_get_clean();
imagedestroy($source);
imagedestroy($target);
Storage::disk('public')->put($cachePath, $webpData);
return response($webpData, 200, [
'Content-Type' => 'image/webp',
'Cache-Control' => 'public, max-age=31536000, immutable',
]);
}
The Frontend: srcset and Explicit Aspect Ratios
On the React side, I built a wrapper that maps image URLs across those specific widths and enforces an aspect-ratio container:
// storefront/src/components/shared/ProductImage.tsx
interface ProductImageProps {
path: string;
alt: string;
priority?: boolean;
}
const WIDTHS = [400, 640, 800, 1200];
export function ProductImage({ path, alt, priority = false }: ProductImageProps) {
const srcSet = WIDTHS
.map((w) => `/api/v1/media/responsive?path=${encodeURIComponent(path)}&width=${w} ${w}w`)
.join(", ");
return (
<div className="relative aspect-square w-full overflow-hidden bg-slate-800 rounded-lg">
<img
src={`/api/v1/media/responsive?path=${encodeURIComponent(path)}&width=640`}
srcSet={srcSet}
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
alt={alt}
loading={priority ? "eager" : "lazy"}
decoding={priority ? "sync" : "async"}
fetchPriority={priority ? "high" : "auto"}
className="h-full w-full object-cover"
/>
</div>
);
}
Two small things here solved huge score penalties:
- The wrapper’s
aspect-squarereserve holds the exact box height while the image is downloading. That alone dropped our CLS from 0.28 to 0.005. - Setting
priorityon the main hero image gave itfetchPriority="high"anddecoding="sync", telling the browser not to wait behind secondary CSS or font requests.
3. Caching Taxonomy and Event-Driven Invalidation
Every time a user clicked between category filters, our API was running repeated queries joining product tags, categories, and variant prices. These rarely change during normal shopping sessions, yet they added 300–400ms of server overhead per page turn.
I cached the base catalog filter metadata in Redis:
// TaxonomyResolver.php
public function getFilterableAttributes(): array
{
return Cache::remember('shop.filter_meta.base', 3600, function () {
return [
'categories' => Category::select('id', 'slug', 'name')->get(),
'sizes' => AttributeOption::where('attribute_id', 1)->pluck('value'),
'colors' => AttributeOption::where('attribute_id', 2)->pluck('value'),
];
});
}
To prevent stale data when products are updated in the admin panel, I tied the cache bust directly into Eloquent’s model lifecycle:
// Product.php
protected static function booted(): void
{
static::saved(function ($product) {
Cache::forget('shop.filter_meta.base');
Cache::forget("seo.product.{$product->id}");
});
static::deleted(function ($product) {
Cache::forget('shop.filter_meta.base');
Cache::forget("seo.product.{$product->id}");
});
}
On the client, TanStack Query cached API responses in memory with a 5-minute stale time. If a user clicked into a product page and clicked back to the category, the list rendered instantly from local cache without triggering a loading skeleton.
Where the Numbers Settled
Lighthouse scores naturally fluctuate depending on your CPU throttling and network noise. Running single audits and claiming a clean “97” is dishonest, so I ran five consecutive audits before and after, throttling to mobile 4G and 4x CPU slowdown:
| Metric | Before (Median of 5 runs) | After (Median of 5 runs) | Notes |
|---|---|---|---|
| Mobile Score | 41 (range 38–43) | 95 (range 93–97) | Consistent green across repeat runs |
| Largest Contentful Paint | 4.8s | 1.2s | Driven by WebP resizing + fetchpriority |
| First Contentful Paint | 2.8s | 0.7s | Stripped admin dependencies from bundle |
| Cumulative Layout Shift | 0.28 | 0.01 | Aspect-ratio container locks |
A Few Honest Takeaways
If I were starting this project from scratch today, here is what I would do differently:
- I’d skip Zustand for a cart this small. Zustand is a clean library, but for an e-commerce cart that only stores an array of items, quantities, and a boolean for the drawer state, React’s built-in
useReducerwith standard Context would have been plenty. It works fine, but it was an extra dependency to maintain that we didn’t strictly need. - Aspect ratio containers are practically free. You don’t need fancy JavaScript or third-party libraries to fix layout shifts. Putting an
aspect-squareor explicit aspect ratio on the image parent solves 90% of CLS issues before an image even starts downloading. - Inspect your chunk graphs early. If you keep admin views and customer storefronts in the same repository, Rollup will bundle them together by default unless you explicitly carve them apart. Run
npx vite-bundle-visualizerat the start of a project, not when you’re already in production wondering why your mobile bundle is 800 KB.