When a WordPress site feels slow, the first impulse is often a hosting upgrade. That is rarely the first useful move. Most bottlenecks live in PHP bootstrap, SQL, autoloaded options, and front-end assets you can measure and fix without changing machines.
This tutorial is a working diagnostic loop for developers and technical editors: measure TTFB, read Query Monitor, trim wp_options autoload, write leaner WP_Query, evaluate Redis object cache, then connect those changes to LCP, INP, and CLS. The official WordPress layer for the same stack is performance optimization.
Hosting versus code
Hosting sets CPU, disk I/O, PHP-FPM workers, and distance to the visitor. Application code sets how much work each HTTP request does before the first byte leaves the origin.
Change hosting when:
- a nearly empty PHP response (page cache bypassed) still shows poor TTFB after obvious plugins and autoload junk are gone
- CPU saturation or disk wait shows up under real concurrency
- the origin is geographically wrong for the primary audience and edge or page cache cannot cover the dynamic templates you care about
Do not change hosting when:
- Query Monitor lists duplicate queries or slow
SELECTwork againstwp_postmeta wp_optionsautoload grew for years of plugin trials- LCP fails on a hero image without dimensions or a modern format
- INP is weak because page-builder scripts and chat widgets own the main thread
On UK and US brochure sites the homepage often looks fine in a lab run near a CDN PoP, while an authenticated account area or a filtered archive with three third-party scripts in the header is what field data actually punishes. Measure the template people use, not only the front page.
Measure TTFB before you change anything
Time to First Byte is the time from the start of the request until the first byte of the response arrives. For WordPress it is the early signal of backend health: DNS, TLS, PHP bootstrap, SQL, and whether page cache hit.
Measure from a terminal with curl so you can separate DNS, connect, TLS, and server work:
curl -o /dev/null -s -w "DNS: %{time_namelookup}s | Connect: %{time_connect}s | TLS: %{time_appconnect}s | TTFB: %{time_starttransfer}s | Total: %{time_total}s\n" https://example.com/How to read the result without invented industry averages:
- low TTFB on cached HTML is expected when page or edge cache hits
- high TTFB on a cache miss or logged-in session points at PHP and MySQL
- high
time_namelookupor TLS time is network or certificate work, not WordPress theme code
Compare three URLs: the homepage, one heavy archive or product template, and wp-login.php (or another authenticated view). The gap between cache hit and miss teaches more than a single lab score.
Chrome DevTools → Network → the document’s Timing panel tells the same story visually. Record TTFB before and after each change. Without a baseline you are tuning blind.
Query Monitor: profile instead of guessing
Query Monitor is the usual free lens into what WordPress does on a request. Keep it on staging or a locked-down copy. On production it can expose SQL, paths, and user meta to logged-in editors.
After installing it on staging, load the slow template and inspect:
- total queries and duplicate queries
- slow queries (wall time and callers)
- HTTP API calls to remote services
- scripts and styles enqueued for that view
- object-cache hit behaviour when a persistent cache is present
Typical findings on real projects:
- a related-posts block that runs a full
WP_QueryplusSQL_CALC_FOUND_ROWSon every single post - a page builder that stores layout JSON in post meta and then issues N+1 meta lookups while rendering modules
- a “security” or “SEO” plugin that fires remote HTTP checks on front-end loads
- three plugins each loading their own copy of a slider or font stack
Fix the largest, repeatable cost first. Removing one duplicate query pattern from a shared template beats installing another optimization plugin that adds its own hooks.
When you need a second opinion without a plugin UI, enable the Query Monitor output only for a capability-checked user, or log slow queries at the database layer on staging. The goal is attribution: which callback owns the cost.
Autoload in wp_options
WordPress loads autoloaded options early on every front-end and admin request via wp_load_alloptions(). That payload sits in memory before your template runs. Leftover settings from removed plugins, oversized theme option blobs, and marketing tools that store configuration as one giant serialized array all inflate it.
Since WordPress 6.6 the autoload column can hold on, off, auto, auto-on, and auto-off in addition to the older yes / no values. Values treated as autoloaded include yes, on, auto-on, and auto. Core also refuses to autoload newly written options whose serialized size exceeds the wp_max_autoloaded_option_size filter (default 150000 bytes). That guard applies at write time. It does not clean decades of leftover rows.
Inspect the heavy rows on staging:
SELECT option_name, LENGTH(option_value) AS bytes, autoload
FROM wp_options
WHERE autoload IN ('yes', 'on', 'auto-on', 'auto')
ORDER BY bytes DESC
LIMIT 30;Or with WP-CLI, knowing that some flags only match a subset of those values:
wp option list --autoload=on --format=total_bytesPractical cleanup rules:
- delete options that belong to uninstalled plugins after you confirm nothing else reads them
- set rarely needed, large options to not autoload (
autoloadoff /no) and fetch them withget_option()only where required - stop storing megabyte-scale JSON in
wp_options; use a custom table, the filesystem, or object cache with an explicit key
Autoload fixes lower TTFB on uncached and authenticated requests because PHP spends less time unserializing dead configuration on every bootstrap.
no_found_rows and leaner WP_Query
By default WP_Query asks MySQL for a found-rows calculation so it can build pagination. That extra work is wasted when you only need a fixed number of posts and will not call max_num_pages.
For secondary loops (related posts, “latest five”, footer teasers), pass:
$related = new WP_Query(
array(
'post_type' => 'post',
'posts_per_page' => 5,
'post_status' => 'publish',
'no_found_rows' => true,
'ignore_sticky_posts' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
)
);Notes that matter in production:
- set
no_found_rowstotrueonly when you do not need pagination math for that query - disable meta and term cache priming when the loop does not print taxonomies or custom fields
- prefer
fields => 'ids'when you only need IDs for a later cached lookup - avoid
meta_queryandtax_querycombinations that cannot use indexes; if you must filter on meta, measure the EXPLAIN plan on staging
Primary loops driven by pre_get_posts for archives still need found rows when the theme paginates. Apply the lean flags to the extras you bolted onto single.php and widget areas.
A small object-cache wrapper around a stable secondary query still helps when the same block renders on many URLs:
function get_cached_teaser_posts() {
$cache_key = 'teaser_posts_v1';
$cached = wp_cache_get( $cache_key );
if ( false !== $cached ) {
return $cached;
}
$query = new WP_Query(
array(
'posts_per_page' => 5,
'post_status' => 'publish',
'no_found_rows' => true,
)
);
wp_cache_set( $cache_key, $query, '', HOUR_IN_SECONDS );
return $query;
}Without a persistent object cache backend, wp_cache_* is request-local. The pattern still documents intent; Redis (next section) makes it pay off across requests.
Redis and the object cache
WordPress ships an object cache API. On a default install it is non-persistent. A Redis (or Memcached) drop-in keeps options, query fragments, and custom keys in memory between requests.
WordPress documentation under performance optimization covers caching layers. Install a maintained object-cache drop-in that matches your host’s Redis connection, verify with wp cache type (WP-CLI) or Query Monitor, and confirm invalidation on publish and stock changes.
When Redis helps:
- high traffic on templates that repeat the same option and query work
- WooCommerce and membership sites with many small lookups per page
- sites where autoload is already sane but duplicate queries remain expensive
When Redis will not save you:
- the HTML is already fully page-cached for anonymous users and you only measure cache hits
- the bottleneck is a single multi-second SQL statement; caching hides it until the key expires
- the host has no Redis service and you cannot run one securely
Do not confuse page cache (full HTML) with object cache (PHP data). You usually want both on different layers. Page cache for anonymous HTML; object cache for the PHP that still runs on misses, carts, and dashboards.
Core Web Vitals in practice
Backend work shows up as TTFB. Visitors feel LCP, INP, and CLS. web.dev documents the thresholds and measurement methods; treat field data (CrUX / Search Console) as the verdict and lab tools as a debugger.
| Metric | What it captures | Common WordPress causes |
|---|---|---|
| LCP | when the largest content element paints | slow TTFB, hero without width/height, render-blocking CSS, oversized image |
| INP | interaction responsiveness | page-builder JS, jQuery plugins, chat widgets, heavy catalog filters |
| CLS | layout shift | images without dimensions, late webfonts, injected banners |
Practical mapping:
- if LCP is image-bound, fix format, size, and priority (
fetchpriorityon the true LCP image) after TTFB is under control - if INP is weak, remove or defer third-party scripts on templates that do not need them; measure with long-task views in DevTools
- if CLS spikes, set dimensions on media and reserve space for cookie banners and ads
Do not chase a single PageSpeed number in isolation. A site can pass a lab Lighthouse run from one geography and still fail field INP for shoppers on mid-range phones. Prefer Search Console Core Web Vitals reports and real-user monitoring when available.
Images, scripts, and plugins without tool religion
After SQL and cache layers are sane, front-end weight still decides LCP and INP.
Images:
- serve modern formats (WebP or AVIF) at the displayed size, not the upload original
- always output width and height (or an aspect-ratio box) so CLS stays stable
- lazy-load below-the-fold media; keep the LCP image eager
Scripts:
- dequeue assets on templates that never use them
- load analytics and chat after interaction or idle where product rules allow
- avoid stacking multiple “optimize JS” plugins that rewrite the same files
Plugins:
- count active plugins less than you count what they enqueue and query
- remove dead security scanners, unused page builders, and duplicate SEO suites
- replace a heavy feature plugin with a small custom snippet when the need is one filter
Output compression belongs at the web server or CDN when possible. A theme-level ob_gzhandler hook is a last resort and can conflict with hosts that already compress:
function start_output_buffering() {
ob_start( 'ob_gzhandler' );
}
add_action( 'init', 'start_output_buffering' );Prefer Brotli or gzip at nginx, Apache, or the edge. Keep PHP focused on application work.
A single diagnostic cycle
Run one pass on staging before buying anything:
- Capture TTFB with
curlon homepage, a heavy template, and an authenticated URL. - Open the heavy template in Query Monitor; list the top queries and HTTP calls.
- Measure autoload size; trim or disable the largest dead options.
- Add
no_found_rows(and cache flags) to secondary loops that do not paginate. - Confirm whether a persistent object cache is available; enable Redis if the host supports it cleanly.
- Re-check LCP, INP, and CLS in lab tools, then watch field data after deploy.
- Only then decide whether hosting CPU, workers, or geography is still the limiter.
Write the before/after numbers in the ticket. The next person should not have to rediscover which template was slow.
Summary
A slow WordPress site is usually an application problem with a hosting ceiling, not the reverse. Measure TTFB, attribute work with Query Monitor, shrink autoload, stop paying for found-rows you never use, add Redis when PHP still repeats the same work, and verify the visitor-facing metrics on web.dev against the official WordPress performance guidance.
Related reading: how to improve WordPress performance. External references: TTFB, LCP, INP, CLS, and WordPress performance optimization.







