Short answer
Core Web Vitals are the symptom; the query budget is usually the disease. Front-end optimisation on top of an unprofiled backend produces numbers that look good in the lab and regress the moment real traffic and real data volumes arrive. Profile the data layer first, fix rendering strategy second, and treat asset delivery as the last step rather than the first.
Most performance work starts at the wrong end. Someone runs Lighthouse, sees a poor LCP, and starts compressing images. The images were 8% of the problem. The template was issuing 340 database queries and the server took 1.8 seconds to send the first byte.
This is the order I work in, and why each step comes where it does.
The metrics, and what each one is actually measuring
| Metric | Target | Dominated by |
|---|---|---|
| LCP | < 2.5s | Time to first byte, then the loading path of the largest element |
| INP | < 200ms | Long JavaScript tasks blocking the main thread during interaction |
| CLS | < 0.1 | Elements without reserved space: images, ads, late-loading fonts |
| TTFB | < 800ms | Server work – queries, uncached API calls, template rendering |
TTFB is not one of the Core Web Vitals, which is why it gets ignored. It is also a floor under LCP: if the server takes 1.5 seconds to respond, no amount of front-end work will produce a 2.5-second LCP. Start here.
1. The query budget
Set a number and enforce it. Something like: no template renders more than 30 database queries, and no single query exceeds 50ms. The specific numbers matter less than having them written down and checked automatically, because query counts do not grow through one bad decision – they grow through fifty reasonable ones.
The patterns that produce runaway counts are consistent:
- N+1 in loops. A listing that fetches 20 items and then makes a separate call per item for an author, a term or a meta value. Prime the caches in one query before the loop.
- Counting rows nobody displays. Pagination queries that calculate a total for a page that never shows one. In WordPress,
'no_found_rows' => trueremoves an expensiveSQL_CALC_FOUND_ROWSpass. - Unindexed meta lookups. Querying by a meta value across a large table without an index is a full scan. It is fast on a development database with 200 rows and catastrophic at 200,000.
- Uncached HTTP calls in the render path. A third-party API call inside a template makes your page as slow as their worst day. Cache the response and serve stale on failure.
Profile with real data volumes. A query plan on a development dataset tells you almost nothing about the plan the database will choose in production.
2. Caching layers, and knowing which one you are in
Each layer serves a different miss:
- Object cache (Redis or Memcached) – avoids repeating the same query within and across requests. The highest-leverage layer for a dynamic application, and the one most often missing.
- Page cache – avoids running the application at all for anonymous traffic.
- Edge / CDN – avoids crossing the network to your origin.
The question worth asking about each is not “is it enabled” but “what is the hit rate, and what happens on a miss?” A page cache with a 40% hit rate and a two-second miss is a slow site for nearly half its visitors. Measure the miss path, because that is the path real users hit after every deploy and every cache purge.
WordPress VIP’s platform makes most of this mandatory rather than optional, which is a large part of why building against those constraints improves a codebase – I have written about that in Engineering for WPVIP: High-Concurrency Standards & VIP Go Compliance.
3. Rendering strategy in Next.js
Choosing a rendering mode is choosing where you pay the cost:
| Strategy | Right when | Cost |
|---|---|---|
| Static (SSG) | Content changes rarely and is the same for everyone | Build time grows with page count |
| Incremental (ISR) | Content changes on a predictable cadence | Some users see stale content |
| Server-rendered | Content is personalised or must be current | Every request pays for server work |
| Client-fetched | Below the fold, or genuinely interactive | Adds a round trip after hydration |
The common mistake is rendering a whole page on the server because one component needs live data. Split it: static or incremental shell, with the dynamic fragment streamed in. Server components make that boundary explicit rather than implicit.
4. JavaScript is what INP is made of
INP measures how long the main thread is busy when a user interacts. Reducing it is mostly about doing less work, not about doing the same work faster:
- Audit the bundle before optimising it. A date library imported for one format call, a full icon set for six icons, an analytics SDK loaded eagerly – these are the usual findings, and they are removals rather than optimisations.
- Break up long tasks. Anything over 50ms blocks input. Yield between chunks of work rather than processing a large array synchronously.
- Defer third-party scripts. Tag managers, chat widgets and session recorders are frequently the largest contributors to INP, and they are the easiest to move off the critical path.
- Hydrate selectively. A static marketing section does not need to become interactive.
5. Images, fonts and layout stability
Last, and genuinely worth doing once the above is handled:
- Serve modern formats, size to the layout rather than to the source file, and always set explicit dimensions so the browser reserves space before the image loads.
- The LCP image must not be lazy-loaded. This is the most common self-inflicted LCP regression – a blanket lazy-loading rule applied to the hero image.
- Self-host fonts or preconnect early, use
font-display: swap, and match fallback metrics so the swap does not shift the layout. - Reserve space for anything that arrives late: embeds, ads, banners injected by scripts.
Measure in the field, not only in the lab
Lighthouse runs on your machine with your network and no third-party scripts blocked by an ad blocker. Real users are on worse networks and older devices, and their distribution is what Google measures. Collect real-user metrics, look at the 75th percentile rather than the median, and segment by device – a p75 that looks fine in aggregate frequently hides a mobile experience that is twice as slow.
Questions people actually ask
- My Lighthouse score is 95 but users say the site is slow. Why?
- Lighthouse is a lab test on a simulated network from one location, with none of the conditions real users have – cold caches, slower devices, third-party scripts, and distance from your origin. Check field data at the 75th percentile segmented by device type. The gap is usually mobile, and usually TTFB or main-thread blocking rather than anything Lighthouse’s synthetic run exercises.
- What is a reasonable query budget?
- Thirty queries per template render, with no single query over 50ms, is a defensible starting point for a content-driven application. The number matters less than enforcing it automatically in CI, because query counts grow gradually through individually reasonable additions and nobody notices the aggregate until it is a problem.
- Should I use SSR or static generation?
- Static or incremental wherever the content is the same for everyone, server-rendered only where it is personalised or must be current. The useful move is usually splitting the page rather than choosing one mode for all of it: a static shell with the dynamic fragment streamed in gives you both.
- Does a CDN fix a slow site?
- It fixes network distance for cacheable assets, which is real but narrow. It does nothing for a slow origin on a cache miss, and nothing at all for INP, which is entirely determined by JavaScript on the user’s device. If TTFB is slow on a miss, a CDN moves the problem rather than solving it.
- How do I stop performance regressing after it is fixed?
- Enforce it in CI. Budgets on bundle size and query count that fail the build, plus field monitoring with an alert on the 75th percentile. Performance achieved once and not defended degrades within a couple of release cycles – not through one bad change, but through many small ones.