How a multi-store B2B retailer took its product page from a 7-second Speed Index to under one second: database first, then cache, then the frontend.
Client
B2B retailer, €20M+ a year
Platform
Magento 2 · 8 storefronts
Catalogue
20,000+ SKUs
Focus
Product page
Scope
Backend, Varnish, frontend, Hyvä
SQL queries · product page
2,942199
−93%
SQL time · product page
719ms98ms
−87%
LCP · real users, desktop
4.1s1.7s
−59% · Good
Page weight · desktop
4.4MB496KB
−89% · Hyvä, pre-production
The problem
One product page, built like twenty.
A B2B retailer selling more than €20M a year runs eight storefronts on one Magento install. Its product pages are rich: stock per warehouse, accessories, bundles, documents, videos. They had also become slow. Without cache, one product page took almost 10 seconds of server time, ran 2,942 SQL queries and fully loaded about 20 products to show one.
Real-user data agreed. On desktop, the 75th percentile LCP was 4.1 s and time to first byte was 3.5 s. Google Search Console kept flagging slow URLs. At peak traffic, one heavy query caused database deadlocks.
Core Web Vitals, real users, beforeCrUX, desktop, 75th percentile
4.1s
LCP (good ≤ 2.5s)
3.5s
TTFB (good ≤ 0.8s)
0.12
CLS (good ≤ 0.1)
Failed
Core Web Vitals assessment
Chapter 1 · Database & PHP
Fix the server first. Cache comes after.
Cache hides a slow page, but every cache miss, every flush and every new URL still pays for it. We profiled uncached product pages with Blackfire and read the database's own query statistics. Then we fixed each block of the page one at a time.
Stock lookups, one query instead of hundreds.Stock was read with one SELECT per inventory source, per product. With 20 sources and 30 product tiles on a page, that's 600 queries. One joined query now returns every product's stock and tells own warehouse stock from dropship suppliers.
A heavy extension query, cached.A product-attachments extension ran a query so slow that it caused deadlocks at peak traffic. We rewrote the query and moved the result into Redis.
Flat tables for product relations.Four related-product blocks and a bundle block each loaded full product models. Two flat index tables (simple products and bundles) now hold what those blocks show: price, stock, URL, image. A sales-rank table lets them sort and limit in SQL. Reindexing takes under a minute for simples and two for bundles, across 20,000+ products and 8 stores.
No reloading data the page already has.Structured data (rich snippets) loaded full products again to print values already on the page. It now reads the objects already built, or Redis.
-- before: one query per source, per product, per block (20 × 30 = 600)SELECT quantity, status FROM inventory_source_item
WHERE sku = ? AND source_code = ?;
-- after: one query per page (simplified)SELECT si.sku, src.is_dropship, SUM(si.quantity) AS qty
FROM inventory_source_item si
JOIN inventory_source_stock_link l ON l.source_code = si.source_code AND l.stock_id = ?
JOIN inventory_source src ON src.source_code = si.source_code
WHERE si.sku IN (…) AND si.status = 1GROUP BY si.sku, src.is_dropship;
Uncached product page, after as % of beforeBlackfire profiles
BeforeAfter
Wall time
100%
43%
I/O wait
100%
41%
CPU
100%
46%
Peak memory
100%
86%
Network
100%
52%
SQL time
100%
14%
SQL queries
100%
7%
Uncached product pageBlackfire profiles
Metric
Before
After
Change
Wall time
9.76s
4.23s
−56%
I/O wait
5.66s
2.33s
−58%
CPU
4.1s
1.9s
−53%
Peak memory
292MB
250MB
−14%
Network
15.0MB
7.74MB
−48%
SQL time
719ms
98ms
−87%
SQL queries
2,942
199
−93%
Chapter 2 · Varnish & cache warming
Every page cached before the first visitor asks for it.
varnishstat showed that at peak traffic Varnish ran out of memory and evicted cached pages to make room for new ones. We gave it enough memory for the whole catalogue. Then we made sure the cache is always full.
A small Magento module exports every CMS, category and product URL for each storefront. A cache warmer written in Rust requests them all with a fixed limit on concurrent requests, so the servers are never overloaded. Magento keys its cache on the X-Magento-Vary cookie, so the warmer crawls each URL once for each customer group. The cache is flushed every night and warmed again straight away. During the day, the warmer keeps running.
// warmer core: bounded concurrency over the exported URL liststream::iter(urls)
.map(|url| client.get(url).header(COOKIE, &vary_cookie).send())
.buffer_unordered(MAX_IN_FLIGHT) // never more than N requests at once
.for_each(|res| async { record(res) })
.await;
Cache warmerPer storefront
~15 min
Full crawl, empty cache
1–2 min
Same crawl, warm cache
2
Customer groups crawled
8
Storefronts
Chapter 3 · Frontend & Magepack
From 175 requests to 58.
The page made 175 requests and weighed 4.4 MB. Several blocks loaded by AJAX after the page itself, so the page kept changing for seconds. Speed Index was 7 s and CLS 0.65. Too many fonts, too much RequireJS, and an A/B-testing script that faded the page out after it had already loaded.
Blocks loaded by AJAX after page load are now in the cached HTML.This fixed most of the Speed Index and layout shift.
JavaScript bundled with Magepack.We tried Magento's own bundling and another extension first. Magepack gave the best result.
The main image loads first.The gallery's first image is eager, with fetchpriority="high".
CRM forms load on demand.The form script loads only when a visitor opens a form.
Videos show a thumbnail until clicked.Video iframes load only after a click, in descriptions and in the gallery.
Marketing tags (A/B testing, tag manager, consent) belong to another team and are being fixed separately. We measured with and without them so each team could see its own share.
Chapter 4 · Hyvä
RequireJS was the ceiling. Hyvä removed it.
Magepack took desktop as far as RequireJS and Knockout could go. Mobile was still around 50. The product page has now been rebuilt on Hyvä with Alpine.js, and it is in testing.
The full menu is cached in Redis. Its deepest level loads on interaction.
All JavaScript, including the gallery, waits for the first interaction. The LCP image is preloaded in the <head>.
Modals are rendered on interaction, not on load.
Images re-encoded, SVG icons trimmed, and repeated icons served from one SVG sprite.
The web font is subset to Latin: 80 KB down to 30 KB.
Product page, desktop, three stagesWebPageTest · 5/1 Mbps, 28 ms
TTFB
183ms−24%
Start render
0.32s−81%
FCP
0.52s−73%
LCP
0.67s−82%
Speed Index
0.68s−90%
CLS
0.00−100%
TBT
48ms−82%
Fully loaded
1.02s−93%
Requests
35−80%
Page weight
496KB−89%
Render-blocking
1−97%
PageSpeed Insights performance score, product pageLab data · Lighthouse
Desktop · beforeNeeds work
Desktop · MagepackNeeds work
No marketing tagsGood
Desktop · HyväGood
Mobile · MagepackNeeds work
Mobile · HyväNeeds work
Before and Magepack figures are production runs. Hyvä figures come from the pre-production build without third-party tags, and will be replaced with production runs after launch.
Real users, today
Lab numbers are a promise. Field data is the proof.
Google ranks on what real visitors experience, measured by Chrome over 28 days. On desktop, LCP, FCP and INP now pass, and time to first byte is three times faster than before.
Core Web Vitals, real users, before → nowCrUX, desktop, 75th percentile
LCP
4.1s1.7s
−59% · Good
FCP
3.8s1.5s
−61% · Good
INP
119ms100ms
−16% · Good
TTFB
3.5s1.1s
−69%
CLS
0.120.11
Needs work · see below
CLS is the one metric still above the line. A marketing script added after the optimisation work shifts the layout, and it is being fixed in the Hyvä release. It shows why performance needs monitoring: one new script can undo months of work.
How it unfolded
Four phases, each one measured.
Phase 1Profiling with Blackfire and RDS query insights. Stock, attachments, flat tables, structured data.
Phase 2Varnish memory sized for peak traffic. URL export and cache warmer in production.