WordPress performance: queries, autoload and profiling

Measure where a slow page spends its time, then fix the usual causes, from heavy queries to bloated autoloaded options.

  • Intermediate
  • 9 min read
  • Updated 17 Oct 2026

What you will learn

  • Profile a slow page
  • Reduce autoloaded options
  • Find slow and duplicate queries with Query Monitor and SAVEQUERIES
  • Fix common WP_Query and remote request bottlenecks

Before you start

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:

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
<?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:

PHP
$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:

Shell
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:

Shell
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:

SQL
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:

Shell
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():
PHP
// Run once, for example in your plugin's upgrade routine.
wp_set_option_autoload( 'myplugin_import_log', false );
  • Your own plugin's data: pass false as the autoload argument when you store it, and move growing data such as logs into a custom table or post meta.
PHP
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:

PHP
$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 EXISTS on 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.

PHP
$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

  1. Disable page caching for the test or log in, and load the slow URL with Query Monitor active.
  2. Note generation time, query count and query time.
  3. Check queries by component, slow queries, duplicates and HTTP calls.
  4. Trace the top item to its caller and fix it: limit, cache, precompute or move to cron.
  5. Check autoloaded options size.
  6. 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.
  • SAVEQUERIES and the wp profile package 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 with wp_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.

Further reading