Performance work starts with measurement, not guesses. A slow WordPress page is usually slow for one or two specific reasons: a heavy query, a remote HTTP call, a plugin doing work on every request, or a huge block of autoloaded options. This lesson shows how to find the cause and fix the common ones.
Page and object caching hide slowness from visitors. Profile with caching off, or logged in, so you see the real cost of generating a page.
Measure first
Pick one slow URL and write down a baseline before changing anything: total generation time, number of queries, total query time and peak memory. Change one thing, then measure again. Without a baseline you cannot tell whether a change helped.
Profile on a copy of production data where possible. Many problems only appear with real volumes: a meta query that is instant on 50 posts can be slow on 500,000.
Query Monitor
Query Monitor is a free developer plugin from the WordPress.org directory, and it is the standard tool for this job. Once active, it adds a panel to the admin bar for administrators showing, for the current request:
- Overview: generation time, peak memory, query count and query time.
- Queries: every SQL query with its time and the function and component (plugin, theme or core) that ran it. You can sort by time and filter by component.
- Duplicate queries: the same SQL run more than once, a sign of missing caching or a query inside a loop.
- Queries by component: which plugin or theme is responsible for most of the database time.
- HTTP API calls: remote requests, how long they took and whether they failed.
- Hooks and actions: which callbacks are attached to each hook that fired.
- PHP errors, scripts, styles and the template used.
It also profiles admin-ajax and REST requests, shown in the browser's developer tools or the panel itself.
Start with "Queries by component" and "HTTP API calls". They usually point straight at the plugin or function to investigate.
Install it on development and staging. If you must use it on production to see a real problem, remove it afterwards.
SAVEQUERIES and timing your own code
For environments where you cannot use a plugin, or to log data from a command, WordPress can record every query itself. In wp-config.php:
define( 'SAVEQUERIES', true );
$wpdb->queries then holds each query, its duration and the call stack. This must-use plugin logs the slowest five at the end of each request. Save it as wp-content/mu-plugins/myplugin-query-log.php on a development site only:
<?php
/**
* Plugin Name: My Plugin Query Log (development only)
*/
defined( 'ABSPATH' ) || exit;
add_action( 'shutdown', 'myplugin_log_slow_queries' );
function myplugin_log_slow_queries(): void {
global $wpdb;
if ( ! defined( 'SAVEQUERIES' ) || ! SAVEQUERIES || empty( $wpdb->queries ) ) {
return;
}
$queries = $wpdb->queries;
// Each entry starts with: SQL, time in seconds, caller stack.
usort(
$queries,
static fn( array $a, array $b ): int => $b[1] <=> $a[1]
);
foreach ( array_slice( $queries, 0, 5 ) as $query ) {
error_log( sprintf( '%.4fs %s | %s', $query[1], $query[0], $query[2] ) );
}
}
SAVEQUERIES stores every query in memory and slows each request a little. Never leave it on in production.
To time a section of your own code, hrtime() gives a precise clock:
$start = hrtime( true );
$report = myplugin_build_report();
error_log( sprintf( 'Report built in %.1f ms', ( hrtime( true ) - $start ) / 1e6 ) );
Profiling from the command line
The WP-CLI profile command is a separate package. Install it once:
wp package install wp-cli/profile-command:@stable
It times the stages of a request (bootstrap, main query, template) and the callbacks on a hook:
wp profile stage
wp profile stage bootstrap
wp profile hook init
A slow bootstrap stage points to something running on plugins_loaded or init on every request, which is a common place for plugins to do too much.
For deeper PHP-level profiling, a sampling or tracing profiler such as Xdebug's profiler shows time per function. That belongs to the PHP performance course; the WordPress tools above are usually enough to find the culprit.
Autoloaded options
Every request loads all options marked for autoload in one query, through wp_load_alloptions(), and keeps them in memory. That is efficient for small settings. It becomes a problem when plugins store large values, such as cached API responses, logs or leftovers from plugins removed long ago, with autoload enabled.
Finding large autoloaded options
Current versions of WordPress store autoload as on, off, auto, auto-on or auto-off. Older sites may still have yes and no. This query finds the largest options that are loaded on every request:
SELECT option_name, LENGTH(option_value) AS bytes
FROM wp_options
WHERE autoload IN ('yes', 'on', 'auto-on', 'auto')
ORDER BY bytes DESC
LIMIT 20;
Replace wp_ with your table prefix. You can run it with wp db query. WP-CLI can also give the total size:
wp option list --autoload=on --format=total_bytes
Site Health in current versions also warns when autoloaded options are unusually large.
Fixing them
For each large option, find its owner from the name. Then:
- Option from a removed plugin: back up the database, then delete it with
wp option delete option_name. - Option that is needed but rarely: turn off autoload. In code, use
wp_set_option_autoload():
// Run once, for example in your plugin's upgrade routine.
wp_set_option_autoload( 'myplugin_import_log', false );
- Your own plugin's data: pass
falseas the autoload argument when you store it, and move growing data such as logs into a custom table or post meta.
update_option( 'myplugin_import_log', $log, false );
Common bottlenecks and fixes
Unbounded and over-fetching queries
'posts_per_page' => -1 loads every matching post, its meta and terms. Set a limit and paginate. When you only need IDs, ask for IDs, and skip work you do not need:
$ids = get_posts(
array(
'post_type' => 'myplugin_event',
'posts_per_page' => 100,
'fields' => 'ids',
'no_found_rows' => true, // No pagination count query.
'update_post_meta_cache' => false, // Do not preload meta.
'update_post_term_cache' => false, // Do not preload terms.
)
);
get_posts() already sets no_found_rows; with WP_Query you must add it yourself when you do not paginate.
Slow meta queries
The meta_value column is not usefully indexed, so filtering or sorting by it scans many rows. Meta queries that combine several clauses, use LIKE, or orderby a meta value get slower as content grows. Options:
- Store values you filter by as taxonomy terms, which are indexed for this purpose.
- Use
EXISTSon a meta key rather than comparing values, where the logic allows. - Cache the resulting IDs, as shown in the object cache lesson.
- Precompute: store a flag or sort key when a post is saved instead of calculating it on every query.
Queries inside loops
Calling a function that runs its own query for every post in a loop multiplies queries by the number of posts. Query Monitor's duplicate queries panel shows it. Core avoids this for post meta and terms by priming caches for the whole result set; do the same for your own data by loading everything for all IDs in one query before the loop.
Expensive ordering and exclusions
'orderby' => 'rand' forces the database to sort the whole result set. post__not_in with many IDs prevents efficient queries. Fetch a larger ordered set and pick or filter in PHP, or cache the result.
Remote HTTP calls during page loads
A call to an external API in a template makes your page as slow as that API, and as unreliable. Cache the response in a transient, set a short timeout, and refresh it in the background with WP-Cron rather than on a visitor's request.
$response = wp_remote_get(
'https://api.example.com/rates',
array( 'timeout' => 3 )
);
Work on every request
Code on init or plugins_loaded runs for every page, every REST call, every admin-ajax request and every cron run. Register hooks there, but do the work only where needed: check is_admin(), the current screen or the request type first, and run heavy jobs on a schedule.
A worked process
- Disable page caching for the test or log in, and load the slow URL with Query Monitor active.
- Note generation time, query count and query time.
- Check queries by component, slow queries, duplicates and HTTP calls.
- Trace the top item to its caller and fix it: limit, cache, precompute or move to cron.
- Check autoloaded options size.
- Measure again and compare with the baseline.
Summary
- Measure before and after every change, with page caching out of the way and realistic data.
- Query Monitor shows slow and duplicate queries, HTTP calls and hook callbacks per component.
SAVEQUERIESand thewp profilepackage give the same data without a browser; keep them off in production.- Find large autoloaded options with SQL or
wp option list, then delete orphans or turn autoload off withwp_set_option_autoload(). - Fix the usual causes: unbounded queries, slow meta queries, queries in loops, random ordering, remote calls in templates and heavy work on every request.