How We Cut a Storefront's Mobile Load Time From 6s to Under 2s
One of our portfolio projects was an e-commerce storefront with a large product catalogue, client-rendered, taking around 6 seconds to become usable on mobile. That's not a minor inconvenience — Google's own research puts bounce probability significantly higher past a 3-second load, and this site was measurably losing customers before the page finished loading. Here's specifically what we changed to get it under 2 seconds.
Diagnosing the actual problem, not the symptom
The site was a client-rendered single-page application: the server sent a near-empty HTML shell, and the browser downloaded a large JavaScript bundle, parsed it, executed it, fetched product data from an API, and only then rendered anything a user could see. On a fast desktop connection this is barely noticeable. On a mid-range mobile device over real-world mobile networks, every one of those steps compounds.
The specific bottlenecks, in order of impact:
- A large, unsplit JavaScript bundle that had to fully download and parse before any rendering could start.
- Client-side data fetching after mount — the product catalogue API call only fired once the JS bundle had already executed, adding a full network round-trip on the critical path.
- Unoptimised images — full-resolution product photos served without responsive sizing or modern formats.
- No caching layer between the application and the database for catalogue data that changes infrequently relative to how often it's read.
The fix: move rendering to the server
We migrated the storefront to Next.js with server-side rendering for product and category pages. This is the single highest-impact change: the server now renders the actual HTML — including product data — before it ever reaches the browser, so there's meaningful content on screen without waiting for JavaScript to download, parse, and execute first.
// Server Component — this runs on the server; the browser receives
// rendered HTML with the product data already in it, not a loading spinner
export default async function ProductPage({ params }: { params: { slug: string } }) {
const product = await getProduct(params.slug); // resolved before the page ever reaches the client
return <ProductDetail product={product} />;
}
Client-side JavaScript is still there for interactivity (add to cart, image galleries), but it's no longer blocking the initial render of content — it hydrates on top of HTML that's already visible.
Caching the catalogue at the edge
Product and category data doesn't change every second, but it was being fetched fresh from the database on every request. We introduced Redis caching in front of the catalogue queries, combined with Next.js's edge caching for rendered pages, so repeat requests for the same product or category page are served without round-tripping to the origin database at all.
// Cache catalogue reads — invalidated on product updates, not on every request
async function getProduct(slug: string) {
const cached = await redis.get(`product:${slug}`);
if (cached) return JSON.parse(cached);
const product = await db.product.findUnique({ where: { slug } });
await redis.set(`product:${slug}`, JSON.stringify(product), "EX", 3600);
return product;
}
This matters more than it sounds like on paper — for a large product catalogue, database query time under real concurrent traffic was a meaningful chunk of the original load time, not just the frontend bundle.
Images: the most overlooked cost
Product photography was being served at full resolution regardless of the device or viewport requesting it. We moved image delivery to next/image, which handles responsive sizing, modern formats (WebP/AVIF), and lazy loading for below-the-fold images automatically — a mobile device now downloads an appropriately sized image instead of the same file a desktop user gets.
Cutting the JavaScript that ships to the browser
Server-rendering content doesn't help if the browser still has to download a massive JavaScript bundle before the page becomes interactive. We audited the bundle and:
- Removed client-side data-fetching libraries that were now redundant with server-rendered data.
- Split rarely-used features (certain checkout steps, account management) into separately loaded chunks instead of the main bundle.
- Replaced a couple of heavy third-party UI libraries with lighter, purpose-built components.
The result
Mobile load time went from around 6 seconds to under 2 — enough to meet Core Web Vitals "good" thresholds across the key page types (product, category, and checkout entry). That's not a cosmetic win: for a storefront, page speed is directly tied to bounce rate and conversion, not just a Lighthouse score to feel good about.
The pattern, generalised
This isn't specific to this one project — it's the same architecture we default to for web development work generally: render on the server first, cache what doesn't need to be fresh on every request, and treat every byte shipped to the client as a cost that has to justify itself. Performance isn't a phase-three optimisation pass; it's a property of the architecture from the start.
If your site is slow and you want a specific, honest diagnosis of why — not a generic checklist — get in touch.
Talk to us about web development
Tell us what you're building and we'll give you a clear, honest assessment.
