A plugin that feels instant on a test site with ten events can add hundreds of milliseconds to every request on a site with fifty thousand events, a million bookings and thirty other plugins. Users blame the slowest plugin they can identify, and hosts disable it. The WordPress course showed how to profile a site with Query Monitor, SAVEQUERIES and autoload checks. This lesson turns those tools on your own plugin and fixes what a large site exposes.
Reproduce a large site
Performance problems hide in small data sets. Build a local site at production scale before you measure:
wp post generate --post_type=myplugin_event --post_status=publish --count=20000
wp eval-file tools/seed-bookings.php
<?php
// myplugin/tools/seed-bookings.php (development only, excluded by .distignore)
$myplugin_event_ids = get_posts(
array(
'post_type' => 'myplugin_event',
'fields' => 'ids',
'posts_per_page' => -1,
'no_found_rows' => true,
)
);
foreach ( $myplugin_event_ids as $myplugin_i => $myplugin_event_id ) {
update_post_meta( $myplugin_event_id, 'myplugin_event_date', gmdate( 'Y-m-d', strtotime( '+' . ( $myplugin_i % 365 ) . ' days' ) ) );
for ( $myplugin_j = 0; $myplugin_j < 25; $myplugin_j++ ) {
myplugin()->bookings()->create( $myplugin_event_id, wp_rand( 1, 500 ), wp_rand( 1, 4 ) );
}
}
That gives 20,000 events and half a million bookings. Add /tools to .distignore so the script never ships.
Measure the plugin's share
Compare like with like: the same page, warm caches, with the plugin active and inactive. Then find where the difference comes from:
- Query Monitor groups queries and hook timings by component, so you see your plugin's queries separately from core's and the theme's.
- WP-CLI's profile command (installed with
wp package install wp-cli/profile-command) breaks a request into stages and hooks from the command line.wp profile stageshows bootstrap, main query and template time;wp profile hook initshows which callbacks oninitare slow. EXPLAINon your custom table queries shows whether MySQL uses an index or scans every row.
Record the numbers. A fix you cannot measure is a guess.
Do nothing on requests that do not need you
The cheapest code is code that never runs. Lesson 16 created admin-only services only in the admin. Go further and create objects only when the hook that needs them fires:
<?php
// myplugin/src/Plugin.php (inside register())
add_action(
'rest_api_init',
function (): void {
( new RestController( $this->bookings ) )->register_routes();
}
);
This replaces adding the controller to $this->services. A front-end page view now never constructs it. The same thinking applies everywhere:
- Load admin assets only on your own screens, by checking the
$hook_suffixpassed toadmin_enqueue_scripts. - Let
block.jsonload block styles and scripts only on pages that contain the block. Enqueue a shortcode's assets inside the shortcode callback, not on every page. - Never write to the database on an ordinary page view. An
update_option()on every request is a write query, and for autoloaded options it also invalidates the cached options set that every request reads. - Never make remote HTTP calls during a page load. Fetch in a background job and cache the result.
Expensive hooks
Some hooks fire thousands of times per request. A callback that costs a microsecond on a normal hook can cost a noticeable amount on these:
| Hook | Why it is expensive | Better |
|---|---|---|
gettext | Fires for every translated string on the page | gettext_{$domain}, which fires only for one text domain |
all | Fires for every hook | Never in production code |
query, posts_clauses, posts_where | Fire for every query or every WP_Query | Check the query's post type or flags first and return early |
pre_get_posts | Fires for every WP_Query, including admin and REST | Check is_main_query() and the context before changing anything |
the_content | Runs for every post rendered, including excerpts and feeds | Do heavy work once and cache it |
save_post | Runs on autosaves, revisions and every post type | Check the post type and wp_is_post_revision(), and queue slow work |
A concrete fix. Suppose the plugin renames one admin label:
<?php
// Before: runs for every translated string on every page.
add_filter( 'gettext', 'myplugin_rename_label', 10, 3 );
// After: runs only for strings in the plugin's own text domain.
add_filter( 'gettext_myplugin', 'myplugin_rename_label', 10, 3 );
function myplugin_rename_label( string $translation, string $text, string $domain ): string {
return 'Attendees' === $text ? __( 'Guests', 'myplugin' ) : $translation;
}
Work triggered by saves should leave the request quickly. If saving an event must recalculate statistics for its bookings, queue it with Action Scheduler and return:
<?php
add_action(
'save_post_myplugin_event',
static function ( int $post_id ): void {
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
return;
}
if ( function_exists( 'as_enqueue_async_action' ) ) {
as_enqueue_async_action( 'myplugin_recalculate_event', array( $post_id ), 'myplugin', true );
}
}
);
The fourth argument, true, makes the action unique, so saving the same event five times queues one job, not five.
Query discipline
Most plugin slowness is database work. The rules:
- Bound every query. Never
posts_per_page => -1on a front-end request. Every list has a maximum. - Ask only for what you use.
'fields' => 'ids'when you need IDs,'no_found_rows' => truewhen you do not paginate, which skips the expensive row count, and'update_post_term_cache' => falsewhen you never read terms. - Index for your
WHEREclauses. The bookings table'sevent_statusindex, onevent_idandstatus, serves the totals query below. When a new query needs a different index, confirm it withEXPLAINand add it as a new schema step in the migrator from lesson 20. - No queries inside loops. This is the N+1 problem, and it is the most common one in plugins.
The upcoming events block is a good example. Showing the number of guests for each event with one query per event means 1 query for the list and N for the totals:
<?php
// Slow: one query per event.
foreach ( $events as $event ) {
$guests = myplugin_get_guest_total( $event->ID ); // The helper from the WordPress course's $wpdb lesson.
}
Fetch all totals in one grouped query instead, and cache the result.
Caching with invalidation built in
Add a batch method to the repository. It uses the last_changed pattern from the object cache lesson: the cache key includes a value that changes whenever bookings change, so stale entries are simply never read again.
<?php
// myplugin/src/Bookings/Repository.php (inside the class)
/**
* Confirmed guest totals keyed by event ID, in one query.
*
* @param int[] $event_ids Event IDs.
* @return array<int, int>
*/
public function guest_totals( array $event_ids ): array {
$event_ids = array_values( array_unique( array_filter( array_map( 'absint', $event_ids ) ) ) );
if ( ! $event_ids ) {
return array();
}
sort( $event_ids );
$key = 'guest_totals:' . md5( implode( ',', $event_ids ) ) . ':' . wp_cache_get_last_changed( 'myplugin_bookings' );
$cached = wp_cache_get( $key, 'myplugin_bookings' );
if ( is_array( $cached ) ) {
return $cached;
}
$placeholders = implode( ', ', array_fill( 0, count( $event_ids ), '%d' ) );
$rows = $this->wpdb->get_results(
$this->wpdb->prepare(
"SELECT event_id, SUM(guests) AS guests FROM %i WHERE status = %s AND event_id IN ($placeholders) GROUP BY event_id",
array_merge( array( $this->table(), 'confirmed' ), $event_ids )
),
ARRAY_A
);
$totals = array_fill_keys( $event_ids, 0 );
foreach ( (array) $rows as $row ) {
$totals[ (int) $row['event_id'] ] = (int) $row['guests'];
}
wp_cache_set( $key, $totals, 'myplugin_bookings', DAY_IN_SECONDS );
return $totals;
}
Then, in create(), delete() and anonymise(), after a successful write:
<?php
wp_cache_set_last_changed( 'myplugin_bookings' );
Every existing key now points at an old last_changed value and is ignored; new keys are built on the next read. Nothing has to know which keys exist. The block calls guest_totals( wp_list_pluck( $events, 'ID' ) ) once, before its loop.
Without a persistent object cache, this cache lasts one request, which still removes duplicate queries within the page. With one, it lasts across requests. Do not "upgrade" it to transients: on sites without a persistent cache, transients live in the options table, and one transient per combination of event IDs would fill that table with rows nobody reads twice.
Autoload discipline
Every autoloaded option is loaded on every request, used or not. For the events plugin:
myplugin_settingsandmyplugin_db_versionare small and read on every request: autoload them.- Report caches, import logs and anything large or rarely read: store with autoload off, for example
update_option( 'myplugin_report_cache', $data, false ). - Transients set without an expiry are autoloaded. Always give transients an expiry.
Audit what the plugin stores:
wp option list --search="myplugin_*" --fields=option_name,autoload,size_bytes
For existing installs, fix options that were saved with the wrong setting in a migration step, with wp_set_option_autoload( 'myplugin_report_cache', false ).
Guard performance with tests
Performance regressions arrive quietly in refactors. Count queries in a test, using the lesson 18 setup:
<?php
// myplugin/tests/Bookings/GuestTotalsTest.php
namespace MyPlugin\Tests\Bookings;
use WP_UnitTestCase;
class GuestTotalsTest extends WP_UnitTestCase {
public function test_guest_totals_use_one_query_then_the_cache(): void {
global $wpdb;
$event_ids = self::factory()->post->create_many( 10, array( 'post_type' => 'myplugin_event' ) );
foreach ( $event_ids as $event_id ) {
myplugin()->bookings()->create( $event_id, 1, 2 );
}
$before = $wpdb->num_queries;
$totals = myplugin()->bookings()->guest_totals( $event_ids );
$this->assertSame( 1, $wpdb->num_queries - $before );
$this->assertSame( 2, $totals[ $event_ids[0] ] );
$before = $wpdb->num_queries;
myplugin()->bookings()->guest_totals( $event_ids );
$this->assertSame( 0, $wpdb->num_queries - $before );
}
}
If someone reintroduces a query per event, this test fails before users notice.
Summary
- Measure on production-sized data, compare with the plugin inactive, and use Query Monitor,
wp profileandEXPLAINto find your plugin's share. - Do nothing on requests that do not need you: create services when their hook fires, load assets only where used, and never write options or call remote APIs on page views.
- Avoid or narrow hooks that fire thousands of times, such as
gettext, and queue slow work triggered by saves. - Bound queries, fetch only what you use, index for your filters and replace per-item queries with one grouped query.
- Cache with the
last_changedpattern, keep autoloaded options small and give transients an expiry, and lock the gains in with query-count tests.
You finished the course
You have taken a plugin from a single file to a professional product: settings and admin screens, custom tables, background jobs, security reviews, block editor integration, an object-oriented architecture with Composer, tests and coding standards, migrations, Multisite support, privacy tools, updates and a release on WordPress.org. Take the course quiz to check your understanding. Then keep the events plugin as your reference project, and apply one lesson at a time to a plugin you maintain: tests and PHPCS first, then migrations and performance.