Plugin performance at scale

Measure what the events plugin costs on a large site, then cut it with lazy loading, query discipline, caching and small autoloaded options.

  • Intermediate
  • 10 min read
  • Updated 17 Oct 2026

What you will learn

  • Profile a plugin on a large site
  • Remove expensive hooks
  • Batch and cache queries to avoid N+1 patterns
  • Keep autoloaded options small
  • Guard performance with automated tests

Before you start

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:

Shell
wp post generate --post_type=myplugin_event --post_status=publish --count=20000
wp eval-file tools/seed-bookings.php
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 stage shows bootstrap, main query and template time; wp profile hook init shows which callbacks on init are slow.
  • EXPLAIN on 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
<?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_suffix passed to admin_enqueue_scripts.
  • Let block.json load 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:

HookWhy it is expensiveBetter
gettextFires for every translated string on the pagegettext_{$domain}, which fires only for one text domain
allFires for every hookNever in production code
query, posts_clauses, posts_whereFire for every query or every WP_QueryCheck the query's post type or flags first and return early
pre_get_postsFires for every WP_Query, including admin and RESTCheck is_main_query() and the context before changing anything
the_contentRuns for every post rendered, including excerpts and feedsDo heavy work once and cache it
save_postRuns on autosaves, revisions and every post typeCheck the post type and wp_is_post_revision(), and queue slow work

A concrete fix. Suppose the plugin renames one admin label:

PHP
<?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
<?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 => -1 on a front-end request. Every list has a maximum.
  • Ask only for what you use. 'fields' => 'ids' when you need IDs, 'no_found_rows' => true when you do not paginate, which skips the expensive row count, and 'update_post_term_cache' => false when you never read terms.
  • Index for your WHERE clauses. The bookings table's event_status index, on event_id and status, serves the totals query below. When a new query needs a different index, confirm it with EXPLAIN and 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
<?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
<?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
<?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_settings and myplugin_db_version are 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:

Shell
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
<?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 profile and EXPLAIN to 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_changed pattern, 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.

Further reading