There are two separate problems here, one on the server and one in the browser. Both show up in the code and the page description, but neither is measured yet. I'd measure first and confirm which one dominates LCP.
What to measure first
-
Network waterfall in DevTools, throttled to mobile. Look at three numbers:
- TTFB and download time for
/api/products. - The JSON payload size.
- The total image bytes, and when the LCP element's request starts and finishes.
This tells you whether LCP is gated by the API, the images, or both. The API call has to finish before React can render any
<img>, so the delays add up in sequence. - TTFB and download time for
-
The LCP element in the Lighthouse or Performance trace. Check which image it is, and look at the breakdown into TTFB, resource load delay, resource load duration and render delay.
-
Postgres query count and timing for one request. Turn on
log_min_duration_statement = 0temporarily, or usepg_stat_statements. Also runEXPLAIN ANALYZEon the individual queries. With 1,200 products, I expect about 2,401 queries per request.
Likely bottlenecks
Backend: N+1 queries. The endpoint runs 1 query for products, then 1 for the category and 1 for the reviews of each product, one after another with await. Even at about 0.4 ms each, that is around a second of sequential round trips. This matches the p95 of 1.2 s. The endpoint also has these problems:
SELECT *with no pagination returns all 1,200 products in one response.- It sends every individual review rating, when the list page probably only needs an average and a count.
- The queries are built by string concatenation. The values are integer IDs from your own database, so this is probably not exploitable today, but it is a bad habit and should use parameters.
- Check that
reviews.product_idhas an index. It may already, and the plan will tell you.
Frontend: images and DOM size. 1,200 full 2400px JPEGs is probably several to tens of MB on a phone. That is most likely the main reason LCP is 4.8 s. Rendering 1,200 cards also costs main-thread time, and it will hurt INP.
What I'd change
Change one thing at a time and re-measure after each, as in the sequence below.
API (do first, it's cheap and the cause is clear):
app.get('/api/products', async (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 24, 100);
const offset = Math.max(parseInt(req.query.offset) || 0, 0);
const { rows } = await db.query(`
SELECT p.id, p.name, p.price, p.image_url,
c.name AS category,
COALESCE(r.avg_rating, 0) AS avg_rating,
COALESCE(r.review_count, 0) AS review_count
FROM products p
LEFT JOIN categories c ON c.id = p.category_id
LEFT JOIN LATERAL (
SELECT AVG(rating)::float AS avg_rating, COUNT(*)::int AS review_count
FROM reviews WHERE product_id = p.id
) r ON true
ORDER BY p.id
LIMIT $1 OFFSET $2`, [limit, offset]);
res.json(rows);
});
This turns about 2,400 queries into 1, selects only the columns the list needs, adds pagination with a stable order, and parameterizes the inputs. Make sure reviews(product_id) is indexed. If the aggregate is still slow, store avg_rating and review_count on products or in a materialized view. Keyset pagination is better than OFFSET for deep pages. Add HTTP caching headers or a short TTL cache only if the data is read far more often than it changes, and key the cache on the query parameters.
Front end (probably the biggest LCP win):
- Show about 24 products per page with "load more" or infinite scroll, or virtualize the list. This is the largest single reduction in DOM size, bytes and requests.
- Serve resized images: generate 320, 640 and 960px variants in AVIF or WebP at build time or through an image CDN. Use
srcsetandsizesso a phone downloads a 15–60 KB image instead of a multi-hundred-KB one. - Give every
<img>awidthandheightto avoid layout shift. Useloading="lazy"anddecoding="async"on everything below the fold. Give the actual LCP imagefetchpriority="high"and don't lazy-load it. - Consider server-rendering or prefetching the first page, so the LCP image doesn't have to wait for the JS bundle and then the API call.
Verifying
Re-measure the same way as the baseline (same throttling, same data, repeated runs). Compare against run-to-run noise, and revert anything that doesn't clearly move the numbers. Then add a Lighthouse CI budget and field monitoring of LCP p75 with web-vitals.
My expectation, which the measurements need to confirm, is that the query fix takes the p95 from 1.2 s to a few tens of milliseconds. The image and pagination work should bring LCP into the "good" range (≤ 2.5 s). If the waterfall shows something different, such as a slow TTFB from somewhere other than the queries, follow that instead.
Test run on a fictional slow product page. The model said what to measure first, found the N+1 queries and the oversized images, and gave a rewritten endpoint. Its expected speed-up is a prediction, not a measurement, and the code was not run.
它做什麼
堅持先量測再最佳化。它先定下 Core Web Vitals 的目標(LCP、INP、CLS),然後走一個循環:量測、找出真正的瓶頸、只修這一件事、驗證並加以守護。它為 LCP 慢(伺服器端、資源載入、渲染延遲)、INP 差與版面位移提供決策樹,並涵蓋圖片(尺寸、現代格式、優先順序與延遲載入)、套件大小與拆分、渲染與重新渲染控制、長清單、快取,以及後端:N+1 查詢、缺少的索引、無上限的清單與分頁。它還列出常見反模式(沒有基線就最佳化、沒修好查詢就先加快取),並要求一次只改一件事、在 CI 裡保留效能預算。
輸出
先量什麼、依序排列的可能瓶頸、附程式碼的修正方案,以及如何驗證。
適合什麼場景
頁面慢、介面慢、Lighthouse 分數不達標,或在上線前規劃效能工作。套件內附有 Skill 引用的效能檢查清單。
低風險:純指令檔,沒有腳本。它建議的是程式碼修改與量測工具(Lighthouse、DevTools、查詢日誌),由你自己執行。已用一個虛構的慢商品頁試用一次:它先說明要量什麼,找出 N+1 查詢與過大的圖片,並給出重寫後的介面。它預估的加速是預測而不是量測,程式碼也沒有執行。共用的檢查清單已一起打包並修正路徑。作者 Addy Osmani,MIT 授權。