A practical guide for reducing database queries on WordPress sites where page caching is bypassed (logged-in users, personalized content, membership sites). This guide covers three strategies: ACF (Advanced Custom Fields) options batch caching, fragment caching, and WP_Query optimization.
- When You Need This
- Prerequisites
- Strategy 1: ACF Options Batch Cache
- Strategy 2: Fragment Caching
- Strategy 3: WP_Query Optimization
- Cache Infrastructure
- Safety Rules
- Invalidation Patterns
- Verification & Debugging
- Common Pitfalls
Page caching (Varnish, nginx, WP Engine's Evercache, etc.) is bypassed in these scenarios:
- Logged-in users — Most managed hosts skip page cache entirely for authenticated requests
- Membership/paywall sites — MemberPress, WooCommerce Memberships, Restrict Content Pro
- WooCommerce — Cart, checkout, and account pages are never page-cached
- Personalized content — Any page that varies by user
When page cache is bypassed, every request runs full PHP + MySQL. A typical WordPress theme with ACF can generate 100-200+ DB queries per page load. The strategies below can cut that by 50-80%.
Install Query Monitor and load a page as a logged-in user. Look at:
- Queries by Component — Identify which theme files generate the most queries
- Slow Queries — Anything over 0.05s
- Duplicate Queries — Same query running multiple times
These strategies require a persistent object cache backend. Without one, wp_cache_get/wp_cache_set only last for the current request (useless).
| Host | Backend | How to Enable |
|---|---|---|
| WP Engine | Memcached or Redis | Enabled by default via object-cache.php drop-in |
| Pantheon | Redis | Enabled by default |
| Kinsta | Redis | Enabled by default |
| Cloudways | Redis/Memcached | Enable via dashboard |
| Generic VPS | Redis | Install Redis Object Cache plugin |
| Local dev | None | Use database fallback (see Cache Infrastructure) |
Check if persistent object cache is active:
// Returns true if a persistent backend is available
wp_using_ext_object_cache();Strategy 1 specifically targets ACF options fields. If you're not using ACF, skip to Strategy 2.
Every get_field('field_name', 'options') call triggers multiple DB queries:
- Field key lookup —
SELECT * FROM wp_options WHERE option_name = '_field_name'(get the field key) - Raw value read —
SELECT * FROM wp_options WHERE option_name = 'field_name'(get the stored value) - Field config lookup —
SELECT * FROM wp_posts WHERE post_name = 'field_abc123'(get field type/settings) - Format/hydration — For complex fields (image, relationship, post object), ACF runs additional queries:
- Image fields:
get_post()+wp_get_attachment_metadata()(2-4 more queries) - Relationship fields:
get_post()per related post (N more queries) - Post object fields:
get_post()per post (N more queries)
- Image fields:
A theme with 48 get_field('...', 'options') calls can easily generate 60-80+ queries just from options fields.
Intercept ACF's loading pipeline with two filters to cache formatted (final) values in a single object cache blob. This skips all DB lookups AND expensive formatting on subsequent requests.
// Frontend only — never interfere with admin
if ( ! is_admin() ):
add_filter( 'acf/pre_load_value', [$this, 'acf_options_pre_load'], 10, 3 );
add_filter( 'acf/format_value', [$this, 'acf_options_format_value'], 999, 3 );
add_action( 'shutdown', [$this, 'acf_options_persist_cache'] );
endif;
// Invalidation — must run in admin too
add_action( 'acf/save_post', [$this, 'acf_options_invalidate'], 20 );This filter fires before ACF reads from the database. Returning a non-null value tells ACF "I already have the raw value, skip the DB."
public function acf_options_pre_load( $check, $post_id, $field )
{
// Only intercept options page fields
if ( $post_id !== 'options' && $post_id !== 'option' ) return $check;
$this->ensure_options_cache_loaded();
$field_name = $field['name'] ?? null;
if ( ! $field_name ) return $check;
// If we have a cached formatted value, return a sentinel
// The actual value is injected by the format_value filter
if ( array_key_exists( $field_name, $this->acf_options_formatted ) )
return '__acf_cached_sentinel__';
return $check;
}This filter fires after ACF formats the raw value into its final form. Use priority 999 to run last.
public function acf_options_format_value( $value, $post_id, $field )
{
if ( $post_id !== 'options' && $post_id !== 'option' ) return $value;
$this->ensure_options_cache_loaded();
$field_name = $field['name'] ?? null;
if ( ! $field_name ) return $value;
// Cache HIT — return stored formatted value
if ( array_key_exists( $field_name, $this->acf_options_formatted ) )
return $this->acf_options_formatted[ $field_name ];
// Cache MISS — capture the just-formatted value
$this->acf_options_formatted[ $field_name ] = $value;
$this->acf_options_dirty = true;
return $value;
}Write the entire blob to object cache once at the end of the request, only if new values were captured.
public function acf_options_persist_cache()
{
if ( ! $this->acf_options_dirty ) return;
wp_cache_set( 'acf_options_v1', $this->acf_options_formatted, 'your_cache_group', 0 );
}Using 0 for TTL (Time To Live) means the cache lives until explicitly invalidated. This is safe because options rarely change and we invalidate on save.
private function ensure_options_cache_loaded()
{
if ( $this->acf_options_loaded ) return;
$cached = wp_cache_get( 'acf_options_v1', 'your_cache_group' );
$this->acf_options_formatted = is_array( $cached ) ? $cached : [];
$this->acf_options_loaded = true;
}public function acf_options_invalidate( $post_id )
{
if ( $post_id !== 'options' && $post_id !== 'option' ) return;
wp_cache_delete( 'acf_options_v1', 'your_cache_group' );
// Also invalidate any fragment caches that depend on options data
// e.g., trending news bar, site-wide banners, etc.
wp_cache_delete( 'your_fragment_key', 'your_cache_group' );
// Reset in-memory state
$this->acf_options_formatted = [];
$this->acf_options_loaded = true;
$this->acf_options_dirty = false;
}private $acf_options_formatted = null;
private $acf_options_loaded = false;
private $acf_options_dirty = false;ACF's formatting step is where the expensive queries happen. A raw image field value is just a post ID (integer). The formatted value includes the full attachment array with URLs, dimensions, alt text — which requires get_post() + wp_get_attachment_metadata(). Caching the formatted value skips all of that.
Never run these filters in wp-admin. The ACF field editor and options pages must always read/write real values. The is_admin() check ensures:
- Admin always sees current values
- Field saves work correctly
- No stale data in the editor
Template partials that render lists of posts (related articles, trending bars, news query blocks) run WP_Query + multiple get_the_title(), get_permalink(), get_the_post_thumbnail_url() calls. Each of these hits the database.
Cache the rendered HTML output of expensive template partials. On cache hit, echo the HTML and return immediately — zero DB queries.
<?php
// 1. Guard: only use object cache on hosts that support it
if ( wp_using_ext_object_cache() ):
$cache_key = 'your_prefix_' . get_the_ID(); // or a static key for global fragments
$cached = wp_cache_get( $cache_key, 'your_cache_group' );
if ( $cached !== false ):
echo $cached;
return;
endif;
endif;
// 2. Start output buffering
ob_start();
?>
<!-- Your expensive template HTML here -->
<div class="related-posts">
<?php
$q = new WP_Query([...]);
while ( $q->have_posts() ) : $q->the_post();
// ... render post cards
endwhile;
wp_reset_postdata();
?>
</div>
<?php
// 3. Capture and output
$output = ob_get_clean();
echo $output;
// 4. Store in cache
if ( wp_using_ext_object_cache() )
wp_cache_set( $cache_key, $output, 'your_cache_group', HOUR_IN_SECONDS );| Fragment Type | Cache Key Pattern | Example |
|---|---|---|
| Per-post fragment | prefix_{post_id} |
related_123, header_456 |
| Global fragment | Static string | trending_html, footer_nav |
| Per-block instance | prefix_{md5(block_id)} |
nq_a1b2c3d4 |
| Per-category | prefix_{term_id} |
cat_posts_42 |
| Per-user | prefix_{user_id} |
profile_widget_7 |
For ACF blocks or Gutenberg blocks that can appear multiple times on a page, use the block instance ID:
$block_id = ! empty( $block['id'] ) ? $block['id'] : 'fallback_' . get_the_ID();
$cache_key = 'nq_' . md5( $block_id );The md5() ensures a clean, fixed-length key regardless of block ID format.
| Fragment Type | Recommended TTL | Rationale |
|---|---|---|
| Post metadata (header, thumbnail, profile) | 1 hour | Changes infrequently, invalidated on save_post |
| Related/category posts | 1 hour | New posts appear within TTL window |
| Global site elements (trending, banners) | 30 minutes | Safety net; also event-invalidated |
| News query blocks | 30 minutes | Content editors expect near-realtime updates |
| Navigation menus | 0 (no TTL) | Invalidate on wp_update_nav_menu |
If a template partial renders in both the block editor (admin) and the frontend, add an is_admin() guard:
if ( wp_using_ext_object_cache() && ! is_admin() ):
// cache logic...
endif;
// ... template code ...
if ( wp_using_ext_object_cache() && ! is_admin() )
wp_cache_set( $cache_key, $output, 'your_cache_group', TTL );This prevents caching admin-specific markup (preview classes, edit buttons) and serving it to frontend users.
By default, WP_Query adds SQL_CALC_FOUND_ROWS to every query and runs a follow-up SELECT FOUND_ROWS() query. This calculates the total number of matching rows for pagination.
If your query doesn't need pagination (fixed number of results, no "next page" link), disable it:
$args = [
'posts_per_page' => 4,
'no_found_rows' => true, // Skip SQL_CALC_FOUND_ROWS
// ...
];
$q = new WP_Query( $args );This eliminates one query per WP_Query call and makes the primary query faster (MySQL doesn't need to calculate the full result count).
When to use no_found_rows:
- Related posts sections
- "Latest posts" widgets
- Any fixed-count query without pagination controls
When NOT to use it:
- Archive pages with pagination
- Search results
- Any template that calls
get_next_posts_link(),paginate_links(), or$q->max_num_pages
$args = [
'posts_per_page' => 4,
'no_found_rows' => true,
'update_post_meta_cache' => false, // Skip postmeta cache priming if you don't need meta
'update_post_term_cache' => false, // Skip term cache priming if you don't need categories/tags
'fields' => 'ids', // Return only IDs if you don't need full post objects
];Use these judiciously — only disable meta/term cache priming if you truly don't access that data in the loop. If you call get_the_category() or get_post_meta() in the loop, keep cache priming enabled (it's more efficient to prime once than query per-post).
On production with a persistent object cache (Redis/Memcached), use wp_cache_* directly. On local dev without persistent cache, fall back to the wp_options table with manual expiration.
const CACHE_GROUP = 'your_prefix';
protected function get_db_cache( $key )
{
if ( wp_using_ext_object_cache() ):
$data = wp_cache_get( $key, self::CACHE_GROUP );
return $data !== false ? $data : null;
endif;
// Database fallback for local dev
$data = get_option( $key, null );
if ( $data === null || ! is_array( $data ) || ! array_key_exists( '_value', $data ) )
return null;
if ( isset( $data['_expires'] ) && $data['_expires'] > 0 && time() > $data['_expires'] ):
delete_option( $key );
return null;
endif;
return $data['_value'];
}
protected function set_db_cache( $key, $value, $expiration = 0 )
{
if ( wp_using_ext_object_cache() )
return wp_cache_set( $key, $value, self::CACHE_GROUP, $expiration );
$data = [
'_value' => $value,
'_expires' => $expiration > 0 ? time() + $expiration : 0,
];
return update_option( $key, $data, false ); // false = don't autoload
}
protected function delete_db_cache( $key )
{
if ( wp_using_ext_object_cache() )
return wp_cache_delete( $key, self::CACHE_GROUP );
return delete_option( $key );
}This is a fair question. WordPress transients (get_transient/set_transient) already have a dual-backend design — they use the object cache when available and fall back to the wp_options table when it's not. On paper, that's exactly what our get_db_cache/set_db_cache does.
The problem is what happens when the object cache clears on production.
On managed hosts like WP Engine, the object cache (Memcached/Redis) gets flushed on every deploy, manual cache clear, or infrastructure event. When a persistent object cache is active, WordPress stores transients only in the object cache — it skips the database entirely. So when the cache flushes, your transients are gone. There is no DB fallback to recover from.
This means:
- Data loss on cache clear — After a deploy or flush, the first visitor triggers a full regeneration of every transient. For expensive operations (external API calls, heavy queries), this creates a spike of slow requests.
- No cache group support — Transients are stored as flat
wp_optionsrows with no grouping mechanism. You can't bulk-invalidate "all caches for this feature" without knowing every key name. - Autoload bloat — Non-expiring transients default to
autoload = yes, meaning they load into memory on every single request even if that request never reads them.
The get_db_cache/set_db_cache pattern solves this by giving you explicit control: wp_cache_* with a cache group on production, wp_options with manual TTL on local dev. You choose the backend per environment rather than trusting WordPress to pick the right one.
Define your cache group once as a class constant:
class Client
{
const CACHE_GROUP = 'your_prefix';
}Reference it everywhere: Client::CACHE_GROUP. This avoids hardcoded strings scattered across template files and makes it easy to change.
Before caching any template fragment, audit the output for these risks:
Never cache output that varies by user. This includes:
- Logged-in/logged-out conditional content
- Username, avatar, or account links for the viewing user
- Subscription tier or membership status
- Cart contents, wishlists, or purchase history
- MemberPress
[mepr-*]shortcodes current_user_can()checks- Nonce fields (
wp_nonce_field()) - CSRF (Cross-Site Request Forgery) tokens
Safe to cache: Author profiles, bylines, and avatars for the post author (this is public data about the author, not the viewer).
Fragments that render in both admin and frontend may include:
- Edit buttons or admin bar links
- Block editor preview classes (
block-admin,is-selected) - ACF field preview wrappers
Solution: Guard cache reads/writes with ! is_admin().
If a cached fragment contains a shortcode or filter that produces dynamic output:
the_content()— Runsthe_contentfilter chain. If ads, paywalls, or user-specific shortcodes are injected here, don't cache it.do_shortcode()— Could expand user-specific shortcodes.apply_filters('widget_text', ...)— Could include dynamic widgets.
Solution: Cache around the dynamic parts, not through them. Or ensure the dynamic content is injected via JavaScript (client-side).
Never cache HTML containing:
wp_nonce_field()output (nonces are user- and time-specific)- Login forms
- Comment forms (unless the form is loaded separately)
- WooCommerce add-to-cart forms with nonces
Before adding fragment caching to a template partial, verify:
- Output is identical for all users (logged-in, logged-out, admin, subscriber)
- No
current_user_can()or role-based conditionals - No nonce fields or CSRF tokens
- No shortcodes that expand to user-specific content
- No
the_content()call (unless you've verified the filter chain is safe) - No
is_admin()conditional HTML (or you've added an admin guard) - Cache key uniquely identifies the content variant (per-post, per-block, etc.)
- TTL is appropriate for the content's change frequency
- Invalidation hook exists for manual/editorial changes
Delete the cache when the underlying data changes. This gives instant updates.
// When a post is saved/updated
add_action( 'save_post', function( $post_id, $post ) {
if ( wp_is_post_revision( $post_id ) ) return;
if ( $post->post_type !== 'post' ) return;
wp_cache_delete( 'related_' . $post_id, 'your_cache_group' );
wp_cache_delete( 'header_' . $post_id, 'your_cache_group' );
wp_cache_delete( 'thumb_' . $post_id, 'your_cache_group' );
wp_cache_delete( 'profile_' . $post_id, 'your_cache_group' );
}, 10, 2 );
// When ACF options page is saved
add_action( 'acf/save_post', function( $post_id ) {
if ( $post_id !== 'options' && $post_id !== 'option' ) return;
wp_cache_delete( 'acf_options_v1', 'your_cache_group' );
wp_cache_delete( 'trending_html', 'your_cache_group' );
// Delete any other fragments that depend on options data
}, 20 );
// When a nav menu is updated
add_action( 'wp_update_nav_menu', function() {
wp_cache_delete( 'nav_primary', 'your_cache_group' );
});Always set a TTL even on event-invalidated caches. This protects against:
- Missed invalidation hooks
- Edge cases where
save_postdoesn't fire (bulk imports, direct DB writes, REST API) - Object cache corruption
wp_cache_set( $key, $output, 'your_cache_group', HOUR_IN_SECONDS );When one cache depends on another, invalidate both:
// ACF options invalidation also clears trending fragment
// because trending reads from ACF options
public function acf_options_invalidate( $post_id )
{
if ( $post_id !== 'options' && $post_id !== 'option' ) return;
wp_cache_delete( 'acf_options_v1', 'your_cache_group' );
wp_cache_delete( 'trending_html', 'your_cache_group' ); // depends on options
}Query Monitor is the essential tool. Key panels:
- Queries > Queries by Component — Shows query count per theme file
- Queries > Duplicate Queries — Identical queries running multiple times
- Queries > Slow Queries — Queries over the threshold
- Cache > Object Cache — Hit/miss ratio for
wp_cache_getcalls
To avoid QM overhead for all users on a live site, set an authentication cookie. Only users with this cookie will see QM output:
// Add to wp-config.php or a mu-plugin
define( 'QM_COOKIE', 'your_secret_qm_cookie' );Then set the cookie in your browser:
- Open browser dev tools > Application/Storage > Cookies
- Add cookie: Name =
your_secret_qm_cookie, Value =1, Path =/ - Reload the page — QM will appear only for you
- Install QM on staging
- Load a representative page as a logged-in user — note query count
- Deploy caching changes
- Load the same page twice (first load primes cache, second load uses cache)
- Compare query counts
Expected results:
- ACF options cache: -60 to -80 queries (depending on number of options fields)
- Fragment caches: -5 to -15 queries per cached fragment (on cache hit, zero queries from that partial)
no_found_rows: -1 query per WP_Query call (also speeds up the main query)
- ACF options: Edit an options page field in wp-admin → verify frontend shows the new value immediately
- Post fragments: Edit and save a post → verify the post's cached fragments update
- Trending/global: Update the trending articles in ACF → verify the trending bar updates
// In a mu-plugin or debug context
add_action( 'wp_footer', function() {
if ( ! current_user_can( 'manage_options' ) ) return;
global $wp_object_cache;
echo '<!-- Object cache hits: ' . $wp_object_cache->cache_hits . ' -->';
echo '<!-- Object cache misses: ' . $wp_object_cache->cache_misses . ' -->';
});wp_cache_get returns false on cache miss. If your cached value is legitimately false or an empty string, you can't distinguish a miss from a hit.
Solution: Wrap values or use wp_cache_get with a $found parameter:
$found = false;
$cached = wp_cache_get( $key, $group, false, $found );
if ( $found ):
echo $cached; // Could be empty string — that's valid
return;
endif;If you cache the output of a template that calls the_post() inside a secondary query loop, the global $post is changed. On cache hit, the loop doesn't run, so $post stays correct. But on cache miss, make sure wp_reset_postdata() is called after the loop to restore the global $post.
Fragment caching naturally handles this — on cache hit, no loop runs and $post is untouched.
Redis/Memcached have per-key size limits (typically 1MB). A single cached HTML fragment is usually a few KB at most, but the ACF options blob could grow large if you have many image fields with full attachment arrays.
Monitor with: wp cache get acf_options_v1 your_cache_group via WP-CLI.
When a popular cache key expires, many simultaneous requests try to regenerate it at once, all hitting the DB. This is a "stampede" or "thundering herd" problem.
Mitigation: Use reasonable TTLs (not too short) and event-based invalidation. For extremely high-traffic fragments, consider a "stale-while-revalidate" pattern using a lock key, but this is rarely needed on WordPress.
If you nest ob_start() calls (e.g., a cached partial includes another cached partial), each buffer captures independently. This works correctly — inner ob_get_clean() returns only the inner content, outer captures everything including the inner output.
However, if a cached inner partial returns early (cache hit → echo $cached; return;), the outer buffer still captures that output correctly. No issues here, just be aware of the nesting.
Without a persistent object cache, wp_cache_* only persists for the current request. Your fragment cache will miss on every page load, adding slight overhead from ob_start()/ob_get_clean() with no benefit.
Solution: Use the wp_using_ext_object_cache() guard to skip caching entirely on local dev:
if ( wp_using_ext_object_cache() ):
// cache logic
endif;Or use the database fallback pattern so caching works locally too (useful for testing invalidation logic).
<?php
// Guard
if ( wp_using_ext_object_cache() ):
$cache_key = 'prefix_' . get_the_ID();
$cached = wp_cache_get( $cache_key, Your_Class::CACHE_GROUP );
if ( $cached !== false ):
echo $cached;
return;
endif;
endif;
ob_start();
?>
<!-- template HTML -->
<?php
$output = ob_get_clean();
echo $output;
if ( wp_using_ext_object_cache() )
wp_cache_set( $cache_key, $output, Your_Class::CACHE_GROUP, HOUR_IN_SECONDS );acf/pre_load_value → Skip DB on cache hit (return sentinel)
acf/format_value → Return cached value or capture new one (priority 999)
shutdown → Persist dirty cache blob
acf/save_post → Invalidate on options page save
wp_using_ext_object_cache() // Is persistent cache available?
wp_cache_get( $key, $group ) // Read from cache (false = miss)
wp_cache_set( $key, $value, $group, $ttl ) // Write to cache
wp_cache_delete( $key, $group ) // Delete from cache
is_admin() // Are we in wp-admin?
wp_is_post_revision( $id ) // Is this a revision save?
'no_found_rows' => true // Skip SQL_CALC_FOUND_ROWS (no pagination)
'update_post_meta_cache' => false // Skip meta cache priming (don't need post meta)
'update_post_term_cache' => false // Skip term cache priming (don't need categories)
'fields' => 'ids' // Return only post IDs- 10up Engineering Best Practices — PHP — Covers broader WordPress performance patterns beyond caching: avoiding
posts_per_page => -1, filtering in PHP instead of SQL (post__not_in), multi-dimensional query costs, cache priming on write, options table hygiene, avoiding DB writes on frontend, and using Rewrite Rules API overadmin-ajax.phpfor frontend requests. - Query Monitor — Essential tool for measuring DB queries, slow queries, duplicates, and object cache hit/miss ratios.