With server time identified as the culprit, queries are next. Turn on a profiler such as Query Monitor and you get every query the request made — and judging that list by its total count is usually wrong. A page with 200 queries can easily be slower than one with 300.
There are two things to look for, and they have different causes and different fixes.
Reading the panel in order
Read the profiler top to bottom. In the overview, take total queries, total query time and peak memory. The important part is that total query time is only a slice of response time. If query time is small and the response is still slow, the bottleneck is PHP execution — outbound API calls, image processing, an overloaded hook — not the database.
Then sort the query list by duration and read the top few: those are your slow queries. Move to the duplicates panel and look at how many times the same SQL ran. Scanning the list as a single blob hides both: the one statement that owns the time, and the repetition that owns the count.
The most valuable column in any profiler is the caller. Where a query came from tells you where to fix it far better than the SQL does. Thirty identical statements from one function means a loop; thirty from thirty different places means the cache is simply empty.
When you cannot install a profiler
Query Monitor belongs on development and staging only. It exposes query and hook detail on screen and installs a db.php drop-in that adds overhead to every query — leave it on in production and the diagnostic tool becomes part of the problem.
Where a plugin is not an option, one core constant gives you the same data.
// wp-config.php — staging only
define( 'SAVEQUERIES', true );
With it enabled, $wpdb->queries collects [SQL, duration, call stack] for the request. One sort and one tally at the end of the request reproduce both lists.
add_action( 'shutdown', function () {
global $wpdb;
if ( ! defined( 'SAVEQUERIES' ) || ! SAVEQUERIES ) {
return;
}
$rows = $wpdb->queries;
// 1. slow — sort by duration, descending
usort( $rows, fn( $a, $b ) => $b[1] <=> $a[1] );
error_log( 'slowest: ' . round( $rows[0][1] * 1000 ) . 'ms ' . $rows[0][0] );
// 2. duplicated — how often did the same SQL run?
$dupes = array_count_values( array_column( $rows, 0 ) );
arsort( $dupes );
error_log( 'top repeat: ' . reset( $dupes ) . 'x ' . key( $dupes ) );
} );
Under twenty lines, and those two log entries decide what you work on. One statement standing far above the rest has to be rewritten — a cache in front of it still leaves the first request paying the full cost — while the same SQL appearing dozens of times mostly disappears once the caches are primed.
Traps when reading the numbers
Fewer queries does not automatically mean faster, and fixing one slow statement can transform a page while the count stays identical. Count is a clue, not a target. When you report an improvement, give response time and query count together, and if only one of them moved, say so.
Also, never mix an admin-screen profile with a front-end one. Admin screens legitimately run more queries — list tables, notices, update checks — and using that figure as a baseline for a public page sends you digging in the wrong place.
Articles on query structure and indexing sit in the performance archive, and if you would rather hand diagnosis and remediation over as one job, that is our optimization program.
Next part
If duplication dominates your profile, there is usually a single cause. The next part is N+1 — what get_post_meta() inside a loop actually costs, and the priming functions core already ships.