Files
2025-11-29 18:25:33 +08:00

35 KiB

WordPress Performance Anti-Patterns

Complete catalog of performance anti-patterns for WordPress code review.

Quick Lookup Table

Pattern to Find Severity Issue
posts_per_page => -1 CRITICAL Unbounded query
numberposts => -1 CRITICAL Unbounded query
query_posts() CRITICAL Replaces main query, breaks pagination
session_start() CRITICAL Cache bypass
setInterval.*fetch CRITICAL Polling/self-DDoS
intval($var) in query args CRITICAL Falsy → 0 → no WHERE
update_option on frontend CRITICAL DB write per request
cache_results => false WARNING Disables query cache
LIKE '%...%' WARNING Full table scan
post__not_in WARNING Slow exclusion, filter in PHP instead
meta_query with value WARNING Unindexed scan
wp_remote_get uncached WARNING Blocking HTTP
$.post( for reads WARNING Bypasses cache
add_option without autoload=no WARNING Bloats alloptions
setcookie() on public pages WARNING Prevents caching
url_to_postid() WARNING Uncached lookup
get_template_part in loops WARNING Repeated file I/O
admin-ajax.php WARNING Full WP bootstrap
in_array() without strict WARNING O(n) complexity at scale
import _ from 'lodash' WARNING Full library import bloats bundle
Heredoc/nowdoc syntax WARNING Prevents late escaping
Page builder plugins WARNING High query count
Infinite scroll with POST WARNING Uncached requests
Many registerBlockStyle() WARNING Creates iframe per style
Missing script version INFO Cache busting issues
Missing no_found_rows INFO Unnecessary count

Database Query Anti-Patterns

Unbounded Queries (CRITICAL)

Queries without limits can return millions of rows, causing OOM errors and timeouts.

// ❌ BAD: No limit - returns ALL posts.
$query = new WP_Query(
    array(
        'post_type'      => 'post',
        'posts_per_page' => -1, // CRITICAL: Unbounded.
    )
);

// ✅ GOOD: Always set reasonable limits.
$query = new WP_Query(
    array(
        'post_type'      => 'post',
        'posts_per_page' => 100,
        'no_found_rows'  => true, // Skip counting total rows if not paginating.
    )
);

Using query_posts() (CRITICAL)

query_posts() replaces the main query, breaking pagination and conditional functions. Never use it.

// ❌ CRITICAL: Never use query_posts().
query_posts( 'cat=1&posts_per_page=5' );
// Breaks: is_single(), is_page(), pagination, etc.

// ✅ GOOD: Use pre_get_posts to modify main query.
add_action( 'pre_get_posts', 'prefix_modify_main_query' );

function prefix_modify_main_query( $query ) {
    if ( ! is_admin() && $query->is_main_query() && $query->is_home() ) {
        $query->set( 'posts_per_page', 5 );
        $query->set( 'cat', 1 );
    }
}

// ✅ GOOD: Use WP_Query for secondary queries.
$custom_query = new WP_Query(
    array(
        'cat'            => 1,
        'posts_per_page' => 5,
    )
);

Disabling Query Cache (WARNING)

Setting cache_results => false prevents WordPress from caching query results.

// ❌ BAD: Disables query result caching.
$query = new WP_Query(
    array(
        'post_type'     => 'post',
        'cache_results' => false, // Forces fresh DB query every time.
    )
);

// ✅ GOOD: Let WordPress cache results (default behavior).
$query = new WP_Query(
    array(
        'post_type' => 'post',
        // cache_results defaults to true.
    )
);

Missing WHERE Clause (CRITICAL)

Falsy values cast to int become 0, removing the WHERE clause entirely.

// ❌ BAD: If $post_id is false/null, this becomes p=0 (no filter!).
$post_id = get_some_id_that_might_fail(); // Returns false.
$query   = new WP_Query(
    array(
        'p'              => intval( $post_id ), // intval( false ) = 0.
        'posts_per_page' => -1,
    )
);
// Result: SELECT * FROM wp_posts WHERE 1=1... (returns ALL posts).

// ✅ GOOD: Validate before querying.
$post_id = get_some_id_that_might_fail();

if ( $post_id ) {
    $query = new WP_Query(
        array(
            'p' => $post_id,
        )
    );
}

LIKE with Leading Wildcard (WARNING)

Leading wildcards force full table scans - indexes cannot be used.

// ❌ BAD: Full table scan.
$results = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->posts} WHERE post_title LIKE %s",
        '%search%'
    )
);

// ✅ BETTER: Trailing wildcard can use index.
$results = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->posts} WHERE post_title LIKE %s",
        'search%'
    )
);

// ✅ BEST: Offload to ElasticSearch for full-text search.
// Use ElasticPress plugin for search queries.

NOT IN Queries (WARNING)

post__not_in and NOT IN clauses scale poorly with large exclusion lists. A better approach is filtering in PHP instead.

// ❌ BAD: Slow with many IDs - creates expensive SQL.
$query = new WP_Query(
    array(
        'post__not_in'   => $hundreds_of_ids, // Each ID checked per row.
        'posts_per_page' => 10,
    )
);

// ✅ GOOD: Use positive filtering when possible.
$query = new WP_Query(
    array(
        'post__in'       => $desired_ids,
        'posts_per_page' => 10,
        'orderby'        => 'post__in',
    )
);

// ✅ BETTER: Filter in PHP after fetching extra posts.
$posts_to_exclude = array( 1, 2, 3, 4, 5 );
$query            = new WP_Query(
    array(
        'posts_per_page' => 10 + count( $posts_to_exclude ), // Fetch extra.
        'no_found_rows'  => true,
    )
);

$count = 0;
while ( $query->have_posts() && $count < 10 ) {
    $query->the_post();

    if ( in_array( get_the_ID(), $posts_to_exclude, true ) ) {
        continue; // Skip excluded posts.
    }

    // Process post.
    $count++;
}
wp_reset_postdata();

Over-use of Taxonomies (WARNING)

Include child terms multiplies query complexity.

// ❌ BAD: Queries all child categories too.
$query = new WP_Query(
    array(
        'cat' => 6, // include_children defaults to true.
    )
);

// ✅ GOOD: Exclude children when not needed.
$query = new WP_Query(
    array(
        'cat'              => 6,
        'include_children' => false,
    )
);

Missing Date Limits (INFO)

Mature sites accumulate millions of posts over years.

// ❌ BAD: Scans entire posts table.
$query = new WP_Query(
    array(
        'category_name'  => 'news',
        'posts_per_page' => 10,
    )
);

// ✅ GOOD: Limit to recent content.
$query = new WP_Query(
    array(
        'category_name'  => 'news',
        'posts_per_page' => 10,
        'date_query'     => array(
            array(
                'after' => '3 months ago',
            ),
        ),
    )
);

Hooks & Actions Anti-Patterns

Expensive Code on Every Request (WARNING)

Code hooked to init, wp_loaded, or plugins_loaded runs on EVERY request.

// ❌ BAD: Runs on every page load.
add_action( 'init', 'prefix_fetch_external_data_bad' );

function prefix_fetch_external_data_bad() {
    $data = wp_remote_get( 'https://api.example.com/data' ); // HTTP call every request!
}

// ✅ GOOD: Use transients or object cache.
add_action( 'init', 'prefix_fetch_external_data_good' );

function prefix_fetch_external_data_good() {
    $data = get_transient( 'prefix_external_data' );

    if ( false === $data ) {
        $response = wp_remote_get( 'https://api.example.com/data' );

        if ( ! is_wp_error( $response ) ) {
            $data = wp_remote_retrieve_body( $response );
            set_transient( 'prefix_external_data', $data, HOUR_IN_SECONDS );
        }
    }
}

Database Writes on Page Load (CRITICAL)

INSERT/UPDATE on every request causes database contention and binlog growth.

// ❌ CRITICAL: DB write on every page view.
add_action( 'wp_head', 'prefix_track_views_bad' );

function prefix_track_views_bad() {
    $views = get_option( 'prefix_page_views', 0 );
    update_option( 'prefix_page_views', $views + 1 );
}

// ✅ GOOD: Batch via object cache, flush periodically via cron.
add_action( 'shutdown', 'prefix_track_views_good' );

function prefix_track_views_good() {
    wp_cache_incr( 'prefix_page_views_buffer', 1, 'counters' );
}

// Cron job flushes buffer to database.
add_action( 'prefix_flush_view_counts', 'prefix_flush_views_to_db' );

function prefix_flush_views_to_db() {
    $buffer = wp_cache_get( 'prefix_page_views_buffer', 'counters' );

    if ( $buffer ) {
        $current = get_option( 'prefix_page_views', 0 );
        update_option( 'prefix_page_views', $current + $buffer );
        wp_cache_delete( 'prefix_page_views_buffer', 'counters' );
    }
}

Inefficient Hook Placement (WARNING)

Running code in hooks that fire when not needed.

// ❌ BAD: Runs on admin AND frontend.
add_action( 'init', 'prefix_frontend_only_function' );

// ✅ GOOD: Conditional execution.
add_action( 'init', 'prefix_maybe_run_frontend_code' );

function prefix_maybe_run_frontend_code() {
    if ( is_admin() ) {
        return;
    }
    prefix_frontend_only_function();
}

// ✅ BETTER: Use appropriate hook that only fires on frontend.
add_action( 'template_redirect', 'prefix_frontend_only_function' );

Excessive Hook Callbacks (WARNING)

Many callbacks on the same hook add function call overhead.

// ❌ BAD: 50 separate callbacks.
for ( $i = 0; $i < 50; $i++ ) {
    add_filter( 'the_content', "prefix_filter_{$i}" );
}

// ✅ GOOD: Consolidate into single callback.
add_filter( 'the_content', 'prefix_process_content' );

function prefix_process_content( $content ) {
    // All transformations in one place.
    $content = prefix_transform_links( $content );
    $content = prefix_add_schema( $content );
    $content = prefix_lazy_load_images( $content );

    return $content;
}

AJAX & External Request Anti-Patterns

Using admin-ajax.php (WARNING)

admin-ajax.php loads full WordPress, including admin_init hooks.

// ❌ BAD: Full WP bootstrap for simple data.
add_action( 'wp_ajax_nopriv_prefix_get_posts', 'prefix_ajax_handler' );

function prefix_ajax_handler() {
    // Handle AJAX request.
    wp_send_json_success( $data );
}

// ✅ GOOD: Use REST API - leaner bootstrap.
add_action( 'rest_api_init', 'prefix_register_rest_routes' );

function prefix_register_rest_routes() {
    register_rest_route(
        'prefix/v1',
        '/posts',
        array(
            'methods'             => 'GET',
            'callback'            => 'prefix_rest_get_posts',
            'permission_callback' => '__return_true',
        )
    );
}

function prefix_rest_get_posts( $request ) {
    // Handle REST request.
    return rest_ensure_response( $data );
}

POST for Read Operations (WARNING)

POST requests bypass page cache, causing unnecessary server load.

// ❌ BAD: POST bypasses cache
$.post(ajaxurl, { action: 'get_items' }, callback);

// ✅ GOOD: GET requests can be cached
$.get('/wp-json/myapp/v1/items', callback);

AJAX Polling (CRITICAL)

Polling creates sustained uncached load - effectively a self-DDoS.

// ❌ CRITICAL: Self-DDoS
setInterval(() => {
    fetch('/wp-json/myapp/v1/updates');
}, 5000);  // Every 5 seconds per user!

// ✅ GOOD: Use WebSockets, SSE, or long-polling with backoff
// ✅ GOOD: Poll less frequently with exponential backoff
// ✅ BEST: Push notifications instead of polling

Uncached External HTTP (WARNING)

Synchronous HTTP calls block page generation.

// ❌ BAD: Blocks page render.
function prefix_get_weather_widget() {
    $response = wp_remote_get( 'https://api.weather.com/current' );
    return prefix_process_weather( $response );
}

// ✅ GOOD: Cache external responses.
function prefix_get_weather_widget_cached() {
    $weather = wp_cache_get( 'prefix_weather_data', 'external_api' );

    if ( false === $weather ) {
        $response = wp_remote_get(
            'https://api.weather.com/current',
            array( 'timeout' => 2 )
        );

        if ( ! is_wp_error( $response ) ) {
            $weather = wp_remote_retrieve_body( $response );
            wp_cache_set( 'prefix_weather_data', $weather, 'external_api', 300 );
        }
    }

    return prefix_process_weather( $weather );
}

// ✅ BEST: Fetch via cron, display from cache.
add_action( 'prefix_fetch_weather', 'prefix_cron_fetch_weather' );

function prefix_cron_fetch_weather() {
    $response = wp_remote_get( 'https://api.weather.com/current' );

    if ( ! is_wp_error( $response ) ) {
        $weather = wp_remote_retrieve_body( $response );
        wp_cache_set( 'prefix_weather_data', $weather, 'external_api', HOUR_IN_SECONDS );
    }
}

Template Anti-Patterns

Over-use of get_template_part (WARNING)

Each template part requires file system access and additional processing.

// ❌ BAD: Template part called 50 times in loop.
while ( have_posts() ) {
    the_post();
    get_template_part( 'partials/card' ); // File access each iteration.
}

// ✅ GOOD: Use template part with data passing (WordPress 5.5+).
while ( have_posts() ) {
    the_post();
    get_template_part(
        'partials/card',
        null,
        array(
            'post_id' => get_the_ID(),
            'title'   => get_the_title(),
        )
    );
}

// ✅ ALTERNATIVE: Cache rendered output for identical partials.
$card_cache = array();

while ( have_posts() ) {
    the_post();
    $post_id = get_the_ID();

    if ( ! isset( $card_cache[ $post_id ] ) ) {
        ob_start();
        get_template_part( 'partials/card' );
        $card_cache[ $post_id ] = ob_get_clean();
    }

    echo $card_cache[ $post_id ]; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}

N+1 Query Problem (CRITICAL)

Querying inside loops multiplies database calls.

// ❌ CRITICAL: 1 query per post.
while ( have_posts() ) {
    the_post();
    $author = get_user_by( 'id', get_the_author_meta( 'ID' ) ); // Query per post!
    $meta   = get_post_meta( get_the_ID(), 'views', true );     // Another query per post!
}

// ✅ GOOD: Prime caches before the loop.
$post_ids = wp_list_pluck( $query->posts, 'ID' );
update_postmeta_cache( $post_ids ); // Single query for all meta.

while ( have_posts() ) {
    the_post();
    // Now get_post_meta() uses cached data - no additional queries.
    $meta = get_post_meta( get_the_ID(), 'views', true );
}

PHP Code Anti-Patterns

in_array() Without Strict Comparison (WARNING)

in_array() has O(n) complexity - at scale, use associative array with isset() for O(1) lookups.

// ❌ BAD: O(n) lookup - slow with large arrays.
$allowed = array( 'foo', 'bar', 'baz' );
if ( in_array( $value, $allowed ) ) {
    // Process.
}

// ❌ ALSO BAD: Missing strict comparison (type coercion issues).
if ( in_array( $value, $allowed ) ) {
    // 0 == 'foo' is true due to type juggling!
}

// ✅ GOOD: O(1) lookup with isset().
$allowed = array(
    'foo' => true,
    'bar' => true,
    'baz' => true,
);

if ( isset( $allowed[ $value ] ) ) {
    // Process.
}

// ✅ ACCEPTABLE: in_array() with strict comparison for small arrays.
if ( in_array( $value, $allowed, true ) ) {
    // Third parameter enables strict type checking.
}

Heredoc/Nowdoc Syntax (WARNING)

Heredoc/nowdoc prevents late escaping - escape data at point of output, not before.

// ❌ BAD: Can't escape inside heredoc.
$html = <<<HTML
<div class="$class">$user_content</div>
HTML;
// XSS vulnerability - $user_content not escaped.

// ✅ GOOD: Late escaping at output.
printf(
    '<div class="%s">%s</div>',
    esc_attr( $class ),
    esc_html( $user_content )
);

// ✅ GOOD: Use template part for complex HTML.
get_template_part(
    'partials/card',
    null,
    array(
        'class'   => $class,
        'content' => $user_content,
    )
);

Storing Large Data in Options (WARNING)

The wp_options table should stay lean. Best practice: under 500 rows, autoloaded data under 1MB total. Large autoloaded options slow every page load.

// ❌ BAD: Storing HTML/large data in options.
update_option( 'prefix_plugin_cache', $huge_html_string );

// ✅ GOOD: Store IDs, fetch data on demand.
update_option( 'prefix_plugin_post_ids', array( 1, 2, 3 ) ); // Small data.

// ✅ GOOD: Use transients with expiration for cache-like data.
set_transient( 'prefix_cache', $data, HOUR_IN_SECONDS );

// ✅ GOOD: Use object cache for large frequently-accessed data.
wp_cache_set( 'prefix_data', $large_data, 'prefix_plugin', HOUR_IN_SECONDS );

Options & Transients Anti-Patterns

Large Autoloaded Options (WARNING)

All autoloaded options load on every request into alloptions cache.

// ❌ BAD: Large data autoloaded.
add_option( 'prefix_plugin_log', $massive_array ); // autoload defaults to 'yes'.

// ✅ GOOD: Disable autoload for large/infrequent data.
add_option( 'prefix_plugin_log', $massive_array, '', 'no' );

// ✅ GOOD: Use update_option with explicit autoload (WordPress 4.2+).
update_option( 'prefix_plugin_log', $data, false ); // false = no autoload.

Transients in Database (WARNING)

Without object cache, transients bloat wp_options table.

// ❌ BAD on hosts without persistent object cache: DB bloat.
set_transient( 'prefix_data', $data, DAY_IN_SECONDS );

// ✅ GOOD: Check for object cache availability.
if ( wp_using_ext_object_cache() ) {
    set_transient( 'prefix_data', $data, DAY_IN_SECONDS );
} else {
    // Use file cache or skip caching on shared hosting.
}

WP Cron Anti-Patterns

Default WP Cron Behavior (WARNING)

By default, WP Cron runs on page requests, adding latency.

// ❌ BAD: Cron tasks run during user requests (default behavior).

// ✅ GOOD: Disable WP Cron and use server cron.
// In wp-config.php:
define( 'DISABLE_WP_CRON', true );

// Server crontab:
// * * * * * cd /path/to/wp && wp cron event run --due-now

Uncached Function Calls

Functions That Need Caching (WARNING)

These WordPress core functions query the database on every call without caching. At scale, they cause significant performance issues.

Function Issue Solution
url_to_postid() Full posts table scan Wrap with object cache
attachment_url_to_postid() Expensive meta lookup Wrap with object cache
count_user_posts() COUNT query per call Cache result per user
get_adjacent_post() Complex query Cache or avoid in loops
wp_oembed_get() External HTTP + parsing Cache with transient
wp_old_slug_redirect() Meta table lookup Cache result
file_get_contents() Filesystem/HTTP call Cache external content

Generic Caching Wrapper Pattern

/**
 * Cached version of url_to_postid() - works on any WordPress installation.
 *
 * @param string $url The URL to look up.
 * @return int Post ID, or 0 if not found.
 */
function prefix_cached_url_to_postid( $url ) {
    $cache_key = 'url_to_postid_' . md5( $url );
    $post_id   = wp_cache_get( $cache_key, 'url_lookups' );

    if ( false === $post_id ) {
        $post_id = url_to_postid( $url );
        wp_cache_set( $cache_key, $post_id, 'url_lookups', HOUR_IN_SECONDS );
    }

    return $post_id;
}

/**
 * Cached version of count_user_posts().
 *
 * @param int    $user_id   User ID.
 * @param string $post_type Post type to count.
 * @return int Number of posts.
 */
function prefix_cached_count_user_posts( $user_id, $post_type = 'post' ) {
    $cache_key = 'user_post_count_' . $user_id . '_' . $post_type;
    $count     = wp_cache_get( $cache_key, 'user_counts' );

    if ( false === $count ) {
        $count = count_user_posts( $user_id, $post_type );
        wp_cache_set( $cache_key, $count, 'user_counts', HOUR_IN_SECONDS );
    }

    return $count;
}

/**
 * Reusable wrapper for any expensive function call.
 *
 * @param string   $cache_key Unique cache key.
 * @param callable $callback  Function to call if cache miss.
 * @param string   $group     Cache group.
 * @param int      $ttl       Time to live in seconds.
 * @return mixed Cached or fresh result.
 */
function prefix_cached_call( $cache_key, $callback, $group = '', $ttl = 3600 ) {
    $result = wp_cache_get( $cache_key, $group );

    if ( false === $result ) {
        $result = call_user_func( $callback );
        wp_cache_set( $cache_key, $result, $group, $ttl );
    }

    return $result;
}

WordPress VIP Platform Helpers

On WordPress VIP, use the platform's pre-built cached helper functions instead of writing your own wrappers:

// ❌ AVOID on VIP: Uncached core functions.
$post_id    = url_to_postid( $url );
$attach_id  = attachment_url_to_postid( $image_url );
$post_count = count_user_posts( $user_id );
$prev_post  = get_adjacent_post( false, '', true );
$embed_html = wp_oembed_get( $video_url );
$response   = wp_remote_get( $api_url );

// ✅ USE on VIP: Cached platform alternatives.
$post_id    = wpcom_vip_url_to_postid( $url );
$attach_id  = wpcom_vip_attachment_url_to_postid( $image_url );
$post_count = wpcom_vip_count_user_posts( $user_id );
$prev_post  = wpcom_vip_get_adjacent_post( false, '', true );
$embed_html = wpcom_vip_wp_oembed_get( $video_url );
$response   = vip_safe_wp_remote_get( $api_url );

// VIP-specific: Safe remote request with built-in fallback and caching.
$response = vip_safe_wp_remote_get(
    $api_url,
    array(
        'fallback_value' => '',           // Return this on failure.
        'threshold'      => 3,            // Failures before fallback.
        'timeout'        => 2,            // Request timeout in seconds.
    )
);

Platform Guidance

Platform Recommendation
WordPress VIP Use wpcom_vip_* helpers - they handle caching, fallbacks, and edge cases
WP Engine, Pantheon, etc. Check host documentation for platform-specific optimizations
Self-hosted with object cache Use the generic caching wrapper pattern above
Shared hosting (no object cache) Use transients, but be aware they fall back to database storage

External API Anti-Patterns

Plugin-Initiated PHP Sessions (CRITICAL)

Plugins that call session_start() on frontend requests make the entire site uncacheable.

// ❌ CRITICAL: This single line can disable page caching site-wide
session_start();

// Detection: Search codebase for session_start()
grep -r "session_start" wp-content/plugins/ wp-content/themes/

// ✅ SOLUTION: Use cookies or wp_cache for non-sensitive data
// ✅ SOLUTION: Only use sessions for logged-in users
if (is_user_logged_in()) {
    session_start();
}

Query Parameter Cache Busting (WARNING)

Marketing UTM parameters and tracking IDs create unique URLs, causing cache misses.

// ❌ BAD: Each unique URL = separate cache entry
// https://example.com/?utm_source=facebook&utm_campaign=summer&fbclid=abc123
// https://example.com/?utm_source=twitter&utm_campaign=summer
// Result: Homepage cached hundreds of times with different params

// ✅ SOLUTION: Configure CDN to ignore/strip marketing params
// Cloudflare, Fastly, Varnish can all strip query params before cache lookup

// ✅ SOLUTION: Canonical redirect in WordPress
add_action('template_redirect', function() {
    $strip_params = ['utm_source', 'utm_medium', 'utm_campaign', 'fbclid', 'gclid'];
    $dominated_query = array_diff_key($_GET, array_flip($strip_params));
    
    if (count($_GET) !== count($dominated_query)) {
        $url = remove_query_arg($strip_params);
        wp_redirect($url, 301);
        exit;
    }
});

Unnecessary Cookies on Public Pages (WARNING)

Setting cookies prevents CDN caching for that visitor.

// ❌ BAD: Cookie set on every visit
add_action('init', function() {
    setcookie('visitor_tracking', uniqid(), time() + 86400, '/');
});
// Result: No page caching for any visitor with this cookie

// ✅ GOOD: Set tracking cookies via JavaScript (doesn't affect server cache)
// ✅ GOOD: Use analytics services instead of custom cookies
// ✅ GOOD: If cookie needed, set only on specific actions (not every page)

Missing Timeout Set (WARNING)

Default timeout is 5 seconds - too long for page generation.

// ❌ BAD: Default 5 second timeout blocks page render
$response = wp_remote_get('https://api.example.com/data');

// ✅ GOOD: Short timeout with fallback
$response = wp_remote_get('https://api.example.com/data', [
    'timeout' => 2,  // 2 seconds max
]);

if (is_wp_error($response)) {
    // Return cached/default data
    return get_fallback_data();
}

No Error Handling (WARNING)

API failures can break page output or cause PHP errors.

// ❌ BAD: Assumes success
$response = wp_remote_get($url);
$data = json_decode($response['body']);

// ✅ GOOD: Handle failures gracefully
$response = wp_remote_get($url, ['timeout' => 2]);

if (is_wp_error($response)) {
    error_log('API Error: ' . $response->get_error_message());
    return $cached_fallback;
}

$code = wp_remote_retrieve_response_code($response);
if (200 !== $code) {
    error_log("API returned status: $code");
    return $cached_fallback;
}

$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);

Synchronous API Calls Without Caching (WARNING)

Every page load waits for external API response.

// ❌ BAD: Blocks every page load
function get_weather() {
    return wp_remote_get('https://api.weather.com/...');
}

// ✅ GOOD: Cache responses, fetch via cron
function get_weather() {
    $cached = wp_cache_get('weather_data');
    if (false !== $cached) {
        return $cached;
    }
    
    // Fallback to slightly stale data if API fails
    $stale = get_transient('weather_data_stale');
    
    $response = wp_remote_get('https://api.weather.com/...', ['timeout' => 2]);
    if (!is_wp_error($response)) {
        $data = wp_remote_retrieve_body($response);
        wp_cache_set('weather_data', $data, '', 300);
        set_transient('weather_data_stale', $data, DAY_IN_SECONDS);
        return $data;
    }
    
    return $stale ?: '';
}

Sitemap Anti-Patterns

Uncached Dynamic Sitemaps (WARNING)

Crawlers can hammer sitemap endpoints, generating expensive queries.

// ❌ BAD: Sitemap generated on every request
// WordPress core sitemaps can be slow for large archives

// ✅ GOOD: Pre-generate and cache sitemaps.
// Use msm-sitemaps plugin for large sites.
// Or cache sitemap output:
function cached_sitemap() {
    $cache_key = 'sitemap_' . get_query_var('sitemap');
    $sitemap = wp_cache_get($cache_key, 'sitemaps');
    
    if (false === $sitemap) {
        $sitemap = generate_sitemap();
        wp_cache_set($cache_key, $sitemap, 'sitemaps', HOUR_IN_SECONDS);
    }
    
    return $sitemap;
}

Including Deep Archives (INFO)

Sitemaps for years-old content trigger unnecessary query load.

// ✅ GOOD: Exclude old content from sitemaps
add_filter('wp_sitemaps_posts_query_args', function($args) {
    $args['date_query'] = [
        'after' => '2 years ago'
    ];
    return $args;
});

// ✅ GOOD: Exclude specific post types
add_filter('wp_sitemaps_post_types', function($post_types) {
    unset($post_types['attachment']);
    unset($post_types['revision']);
    return $post_types;
});

Plugin & Theme Anti-Patterns

Page Builder Performance (WARNING)

Page builder plugins add significant overhead - extra code, database queries, and processing.

// ❌ WARNING: Page builders at scale.
// - Generate inefficient HTML/CSS.
// - Add many database queries per page.
// - May compile templates on every request (especially problematic on read-only filesystems).
// - Block editor (Gutenberg) is more performant than most page builders.

// ✅ RECOMMENDATIONS:
// - Use Gutenberg/block editor for new projects.
// - Custom code for high-traffic landing pages.
// - If using page builders, test query count and generation time.
// - Avoid page builders on pages receiving high traffic.

Infinite Scroll with POST Requests (WARNING)

Infinite scroll plugins often use POST requests, bypassing cache entirely.

// ❌ BAD: POST request for each scroll (bypasses cache)
// As user scrolls, each AJAX request = uncached hit to origin
jQuery.post(ajaxurl, { action: 'load_more_posts', page: 2 });

// ✅ GOOD: Use GET requests for infinite scroll (cacheable)
jQuery.get('/wp-json/mysite/v1/posts', { page: 2 });

// ✅ GOOD: Pre-render next page URLs that can be cached
// /page/2/, /page/3/ etc. are cacheable at CDN level

// ✅ GOOD: Implement cache warming for paginated content

JavaScript Bundle Anti-Patterns

Full Library Imports (WARNING)

Importing entire libraries when only parts are needed bloats JavaScript bundles.

// ❌ BAD: Imports entire lodash library (~70KB)
import _ from 'lodash';
const result = _.map(items, transform);

// ✅ GOOD: Import only what you need (~2KB)
import map from 'lodash/map';
const result = map(items, transform);

// ❌ BAD: Barrel file exports pull in everything
// utils/index.js: export * from './heavy-module';
import { smallFunction } from './utils';  // Loads entire utils

// ✅ GOOD: Import directly from module
import { smallFunction } from './utils/small-module';

Missing Script Loading Strategy (INFO)

WordPress 6.3+ supports native defer/async for non-blocking script loading.

// ❌ BAD: Blocks rendering (default behavior)
wp_enqueue_script('my-script', get_template_directory_uri() . '/js/script.js');

// ✅ GOOD: Defer non-critical scripts (WordPress 6.3+)
wp_enqueue_script('my-script', get_template_directory_uri() . '/js/script.js', [], '1.0.0', [
    'strategy' => 'defer',  // or 'async'
]);

// ✅ GOOD: Load in footer for older WP versions
wp_enqueue_script('my-script', get_template_directory_uri() . '/js/script.js', [], '1.0.0', true);

Missing Asset Version Strings (INFO)

Without version strings, browser caches may serve stale assets after deployments.

// ❌ BAD: No version - cache busting issues
wp_enqueue_script('my-script', get_template_directory_uri() . '/js/script.js');
wp_enqueue_style('my-style', get_template_directory_uri() . '/css/style.css');

// ✅ GOOD: Use theme/plugin version constant
define('THEME_VERSION', '1.0.0');
wp_enqueue_script('my-script', get_template_directory_uri() . '/js/script.js', [], THEME_VERSION);
wp_enqueue_style('my-style', get_template_directory_uri() . '/css/style.css', [], THEME_VERSION);

// ✅ GOOD: Use file modification time for development
wp_enqueue_script('my-script', get_template_directory_uri() . '/js/script.js', [], 
    filemtime(get_template_directory() . '/js/script.js'));

Block Editor Anti-Patterns

Too Many Custom Block Styles (WARNING)

Each block style creates a preview iframe in the editor, causing severe performance degradation.

// ❌ BAD: Each style = separate iframe for preview
registerBlockStyle('core/group', { name: 'green-dots', label: 'Green Dots' });
registerBlockStyle('core/group', { name: 'blue-waves', label: 'Blue Waves' });
registerBlockStyle('core/group', { name: 'red-stripes', label: 'Red Stripes' });
// 10+ styles = editor becomes unusable

// ✅ GOOD: Use custom attributes with block filters
import { addFilter } from '@wordpress/hooks';

addFilter('blocks.registerBlockType', 'namespace/bg-patterns', (settings, name) => {
    if (name !== 'core/group') return settings;
    
    return {
        ...settings,
        attributes: {
            ...settings.attributes,
            backgroundPattern: { type: 'string', default: '' }
        }
    };
});
// Add UI via BlockEdit filter, style via render_block filter

Re-sanitizing InnerBlocks Content (WARNING)

InnerBlocks content is already sanitized - running wp_kses_post breaks embeds and core functionality.

// ❌ BAD: Breaks embeds, iframes, and other allowed content
function render_my_block($attributes, $content) {
    return '<div class="my-block">' . wp_kses_post($content) . '</div>';
}

// ✅ GOOD: InnerBlocks content is pre-sanitized
function render_my_block($attributes, $content) {
    return '<div class="my-block">' . $content . '</div>';
}

// ✅ GOOD: Escape attributes, not InnerBlocks content
function render_my_block($attributes, $content) {
    $class = esc_attr($attributes['className'] ?? '');
    return '<div class="my-block ' . $class . '">' . $content . '</div>';
}

Static Blocks for Client Builds (INFO)

Static blocks store markup in the database, requiring deprecations for any design changes.

// ❌ PROBLEMATIC: Static block - markup stored in DB
// Any HTML change requires deprecation handler or manual re-save of all posts

// ✅ GOOD: Dynamic block - only attributes stored, markup rendered on request
register_block_type('namespace/my-block', [
    'render_callback' => 'render_my_block',  // Dynamic rendering
    'attributes' => [
        'title' => ['type' => 'string'],
    ]
]);

// Design changes update all instances automatically without re-saving posts

Redirect Anti-Patterns

Redirect Loops (CRITICAL)

Infinite server-side redirects consume CPU without easy detection.

// Debug redirect source using x-redirect-by header
add_filter('x_redirect_by', function($x_redirect_by, $status, $location) {
    // Log for debugging
    error_log("Redirect to $location by: $x_redirect_by (status: $status)");
    
    // For deep debugging, output stack trace:
    // error_log(wp_debug_backtrace_summary());
    
    return $x_redirect_by;
}, 10, 3);

// Always set x_redirect_by when creating redirects
wp_redirect($url, 301, 'My Plugin Name');

Redirect Chains (WARNING)

Multiple sequential redirects add latency and confuse caches.

// ❌ BAD: A → B → C → D (chain of redirects)
// Each redirect = full HTTP round-trip

// ✅ GOOD: Direct redirect A → D
// Audit redirects regularly, collapse chains

Post Meta Anti-Patterns

Querying meta_value Without Index (WARNING)

The meta_value column isn't indexed by default - full table scan.

// ❌ BAD: Scans entire postmeta table
$query = new WP_Query([
    'meta_query' => [
        ['key' => 'color', 'value' => 'red']
    ]
]);

// ✅ BETTER: Use taxonomy for filterable attributes
// Register 'color' taxonomy, term 'red'
$query = new WP_Query([
    'tax_query' => [
        ['taxonomy' => 'color', 'field' => 'slug', 'terms' => 'red']
    ]
]);

Binary Meta Values (WARNING)

Checking meta_value = 'true' requires scanning all matching keys.

// ❌ BAD: Must scan meta_value column
$query = new WP_Query([
    'meta_key' => 'is_featured',
    'meta_value' => 'true'
]);

// ✅ GOOD: Key presence = true, absence = false
$query = new WP_Query([
    'meta_key' => 'is_featured',
    'meta_compare' => 'EXISTS'
]);

// ✅ ALTERNATIVE: Encode value in key name
// Instead of: meta_key='category', meta_value='sports'
// Use: meta_key='category_sports' (just check EXISTS)

Excessive Post Meta (INFO)

wp_postmeta table grows to multiples of wp_posts - optimize storage.

// ❌ BAD: Storing large data in post meta
update_post_meta($id, 'full_api_response', $huge_json);

// ✅ GOOD: Store minimal data, fetch details on demand
update_post_meta($id, 'api_resource_id', $resource_id);

// ❌ BAD: Many separate meta entries
update_post_meta($id, 'address_line1', $line1);
update_post_meta($id, 'address_line2', $line2);
update_post_meta($id, 'address_city', $city);
// ... 10 more fields

// ✅ GOOD: Serialize related data
update_post_meta($id, 'address', [
    'line1' => $line1,
    'city' => $city,
    // ...
]);