Skip to main content

Caching and Query Optimization for Field-Heavy Templates

Performance in ACF-heavy themes comes from predictable data flow, not one-off micro-optimizations.

Learning Focus

You will implement cache key design, save-triggered invalidation, and archive query shaping so Repeater and Flexible Content templates stay fast under real editorial load.

Concept Overview

ACF-heavy templates can generate substantial runtime overhead when each render calls get_field() repeatedly across nested loops. This is especially visible on landing pages with Flexible Content layouts and on archive pages that pull multiple custom fields per card. If you optimize only the query and ignore field access patterns, response times remain inconsistent.

The most reliable approach is to split work into two phases: compute expensive output once, then reuse it. For page-level rendering, that usually means fragment caching of fully rendered HTML keyed by post ID and schema version. For archives, that often means save-time denormalization into a compact index meta value, then lightweight reads in loops.

Invalidation strategy is the part most teams under-design. A cache is useful only if stale data is removed at the correct scope. Global flushes hurt performance and still miss corner cases. Per-post key invalidation connected to acf/save_post gives strong correctness while keeping hit rate high.

There are also query-shaping choices that reduce work before caching starts. Request only IDs when possible (fields => 'ids'), disable unnecessary totals (no_found_rows => true), and prime metadata for batches. That combination lowers query cost and keeps card rendering deterministic.

Core Idea

Cache what is expensive, invalidate at the smallest safe boundary, and make every loop consume pre-shaped data instead of recomputing field logic repeatedly.

Why It Matters

ApproachWhat HappensImpact in Production
Per-post fragment caching with save invalidationHigh cache hit ratio and fast response after warm-upBetter Core Web Vitals and lower server load
Save-time card index generation for archive viewsLoop rendering avoids repeated expensive field accessStable performance as content count grows
Query shaping with IDs and metadata primingLess query overhead before rendering startsMore predictable p95 response times
Key versioning tied to schema changesControlled cache busting when field structure evolvesSafer releases and fewer stale template bugs
One global cache key for all pages (wrong pattern)Cross-page cache collisions and stale content leaksUser-visible data mismatch and high incident risk

Reference Table

Term/APISignature/SyntaxPurposeKey Notes
get_field()`get_field(string $selector, intstring $post_id = false, bool $format_value = true): mixed`Read one ACF value for rendering or indexing
have_rows()`have_rows(string $selector, intstring $post_id = false): bool`Iterate Repeater or Flexible Content rows
get_sub_field()get_sub_field(string $selector, bool $format_value = true, bool $escape_html = false): mixedRead sub field inside row loopKeep output escaped in templates
get_transient()get_transient(string $transient): mixedFetch cached fragment or derived payloadReturns false on miss
set_transient()set_transient(string $transient, mixed $value, int $expiration): boolPersist cache entry with TTLUse versioned key naming
delete_transient()delete_transient(string $transient): boolRemove stale cache entryCall from targeted save hook
acf/save_postadd_action('acf/save_post', callable $callback, int $priority = 10, int $accepted_args = 1): voidInvalidate or rebuild data after editor saveGate by numeric ID and post type
WP_Query IDs modenew WP_Query(['post_type' => 'resource', 'post_status' => 'publish', 'fields' => 'ids', 'no_found_rows' => true])Query only IDs for lean archive fetchPair with metadata priming
update_meta_cache()`update_meta_cache(string $meta_type, array $object_ids): arrayfalse`Prime metadata for batch of posts
wp post meta getwp post meta get <post-id> <meta-key>Inspect derived index and debug payloadCore operational check for release QA
ACF Pro Required

Rows using have_rows() and get_sub_field() for Repeater or Flexible Content rely on ACF Pro field types.

Practical Use Cases

1. Cache a Flexible Content service landing fragment with targeted invalidation

A clinic landing page has several Flexible Content layouts and a Repeater FAQ block. Rendering all sections on every request causes unpredictable spikes during traffic bursts. The team needs deterministic fragment caching and safe invalidation when editors update content.

  1. Build a per-post cache key that includes schema version.
  2. Render full service fragment only on cache miss.
  3. Use a short lock transient to reduce concurrent regeneration.
  4. Store backup HTML in post meta for lock-window fallback.
  5. Invalidate only the affected post key in acf/save_post.
  6. Verify hit and invalidation behavior with CLI commands.
wp-content/themes/clinic-headless/inc/acf/service-fragment-cache.php
<?php

declare(strict_types=1);

function clinic_service_fragment_cache_key(int $postId): string
{
$schemaVersion = 'v1';
return 'service_fragment_' . $schemaVersion . '_' . $postId;
}

function clinic_render_cached_service_page(int $postId): string
{
$cacheKey = clinic_service_fragment_cache_key($postId);
$cachedHtml = get_transient($cacheKey);

if (is_string($cachedHtml) && $cachedHtml !== '') {
return $cachedHtml;
}

$lockKey = $cacheKey . '_lock';
if (get_transient($lockKey) !== false) {
$fallback = (string) get_post_meta($postId, '_service_fragment_last_html', true);
return $fallback;
}

set_transient($lockKey, '1', 20);

ob_start();
$headline = (string) get_field('service_headline', $postId);
$intro = (string) get_field('service_intro', $postId);
$ctaUrl = (string) get_field('service_cta_url', $postId);

echo '<section class="service-hero">';
echo '<h1>' . esc_html($headline) . '</h1>';
echo '<p>' . esc_html($intro) . '</p>';
echo '<a href="' . esc_url($ctaUrl) . '">Book Consultation</a>';
echo '</section>';

if (have_rows('service_faq_items', $postId)) {
echo '<section class="service-faq"><ul>';
while (have_rows('service_faq_items', $postId)) {
the_row();
$question = (string) get_sub_field('service_faq_question');
$answer = (string) get_sub_field('service_faq_answer');
echo '<li><strong>' . esc_html($question) . '</strong>';
echo '<p>' . esc_html($answer) . '</p></li>';
}
echo '</ul></section>';
}

$rendered = (string) ob_get_clean();
set_transient($cacheKey, $rendered, 15 * MINUTE_IN_SECONDS);
update_post_meta($postId, '_service_fragment_last_html', $rendered);
delete_transient($lockKey);

return $rendered;
}

add_action('acf/save_post', 'clinic_invalidate_service_fragment_cache', 20);

function clinic_invalidate_service_fragment_cache($post_id): void
{
if (!is_numeric($post_id)) {
return;
}

$postId = (int) $post_id;
if (get_post_type($postId) !== 'page') {
return;
}

$template = (string) get_page_template_slug($postId);
if ($template !== 'templates/service-landing.php') {
return;
}

$cacheKey = clinic_service_fragment_cache_key($postId);
delete_transient($cacheKey);
}
terminal: command
wp eval '$id=1204; update_field("service_headline","Laser Skin Renewal",$id); update_field("service_intro","Plan built by licensed clinicians.",$id); update_field("service_cta_url","https://example.com/book-consultation",$id); echo strlen(clinic_render_cached_service_page($id)) . PHP_EOL;'
wp eval '$id=1204; $key=clinic_service_fragment_cache_key($id); echo get_transient($key)!==false ? "cache-hit" : "cache-miss"; echo PHP_EOL;'
wp eval '$id=1204; $key=clinic_service_fragment_cache_key($id); set_transient($key,"cached",300); do_action("acf/save_post",$id); echo get_transient($key)===false ? "invalidated" : "stale"; echo PHP_EOL;'
terminal: output
512
cache-hit
invalidated
warning

If you cache editor preview output under the same key as public output, draft-only content can leak to visitors. Keep preview paths separate from public fragment keys.

2. Build a save-time resource card index and render archive with ID-only query

A resource archive lists 24 posts per page with tier, reading time, and top highlights. Calling several get_field() lookups for every card causes high query volume and inconsistent page time. The team wants one compact payload per resource and fast archive rendering.

  1. Build _resource_card_index JSON on each resource save.
  2. Extract summary fields and first three highlight rows.
  3. Query archive posts with fields => 'ids' and no_found_rows => true.
  4. Prime metadata cache for queried IDs in one call.
  5. Render cards from precomputed JSON payload, not raw ACF loops.
wp-content/themes/clinic-headless/inc/acf/resource-card-index.php
<?php

declare(strict_types=1);

add_action('acf/save_post', 'clinic_build_resource_card_index', 30);

function clinic_build_resource_card_index($post_id): void
{
if (!is_numeric($post_id)) {
return;
}

$resourceId = (int) $post_id;
if (get_post_type($resourceId) !== 'resource') {
return;
}

$tier = (string) get_field('resource_tier', $resourceId);
$readMinutes = (int) get_field('resource_read_time_minutes', $resourceId);
$summary = (string) get_field('resource_summary', $resourceId);

$highlights = [];
if (have_rows('resource_highlights', $resourceId)) {
while (have_rows('resource_highlights', $resourceId)) {
the_row();
$line = trim((string) get_sub_field('resource_highlight_text'));
if ($line !== '') {
$highlights[] = $line;
}
}
}

$payload = [
'title' => get_the_title($resourceId),
'tier' => $tier,
'read_minutes' => $readMinutes,
'summary' => $summary,
'highlights' => array_slice($highlights, 0, 3),
'updated_at' => get_post_modified_time('c', true, $resourceId),
];

update_post_meta($resourceId, '_resource_card_index', wp_json_encode($payload));
}

function clinic_get_resource_card_payload(int $resourceId): array
{
$raw = (string) get_post_meta($resourceId, '_resource_card_index', true);
$decoded = json_decode($raw, true);

if (!is_array($decoded)) {
return [
'title' => get_the_title($resourceId),
'tier' => '',
'read_minutes' => 0,
'summary' => '',
'highlights' => [],
'updated_at' => '',
];
}

return $decoded;
}

function clinic_render_resource_archive_cards(): string
{
$query = new WP_Query([
'post_type' => 'resource',
'post_status' => 'publish',
'posts_per_page' => 24,
'fields' => 'ids',
'no_found_rows' => true,
]);

$ids = array_map('intval', (array) $query->posts);
if ($ids !== []) {
update_meta_cache('post', $ids);
}

ob_start();
echo '<section class="resource-grid">';

foreach ($ids as $resourceId) {
$card = clinic_get_resource_card_payload($resourceId);
echo '<article class="resource-card">';
echo '<h3>' . esc_html((string) $card['title']) . '</h3>';
echo '<p>' . esc_html((string) $card['tier']) . ' | ' . esc_html((string) $card['read_minutes']) . ' min</p>';
echo '<p>' . esc_html((string) $card['summary']) . '</p>';
if (is_array($card['highlights']) && $card['highlights'] !== []) {
echo '<ul>';
foreach ($card['highlights'] as $highlight) {
echo '<li>' . esc_html((string) $highlight) . '</li>';
}
echo '</ul>';
}
echo '</article>';
}

echo '</section>';
return (string) ob_get_clean();
}
terminal: command
wp eval '$id=2301; do_action("acf/save_post",$id); echo get_post_meta($id,"_resource_card_index",true) . PHP_EOL;'
wp eval '$html=clinic_render_resource_archive_cards(); echo "html-bytes=" . strlen($html) . PHP_EOL;'
wp eval '$q=new WP_Query(["post_type"=>"resource","post_status"=>"publish","fields"=>"ids","posts_per_page"=>12,"no_found_rows"=>true]); echo "ids=" . count($q->posts) . PHP_EOL;'
terminal: output
{"title":"Choosing the Right Peel","tier":"advanced","read_minutes":9,"summary":"When and why clinicians choose medium-depth peel pathways.","highlights":["Downtime planning","Skin type checks","Pre-treatment prep"],"updated_at":"2026-02-23T18:30:22+00:00"}
html-bytes=8640
ids=12
note

This pattern shifts work from request time to save time. That tradeoff is usually correct for public content where reads far outnumber writes.

3. Edge case: global cache key collision across different pages

An implementation uses one transient key for every service page fragment. The first generated fragment is reused everywhere until expiry. Editors update one page and users see another page headline due to key collision.

Fragile Pattern (Bad)

wp-content/themes/clinic-headless/inc/acf/global-fragment-key-fragile.php
<?php

declare(strict_types=1);

function clinic_render_service_fragment_fragile(int $postId): string
{
$key = 'service_fragment_global_v1';
$html = get_transient($key);

if (!is_string($html) || $html === '') {
$headline = (string) get_field('service_headline', $postId);
$html = '<h2>' . esc_html($headline) . '</h2>';
set_transient($key, $html, 10 * MINUTE_IN_SECONDS);
}

return $html;
}

Robust Pattern (Good)

wp-content/themes/clinic-headless/inc/acf/service-cache-key-isolation.php
<?php

declare(strict_types=1);

function clinic_safe_service_cache_key(int $postId): string
{
$version = (int) get_post_meta($postId, '_service_cache_version', true);
if ($version <= 0) {
$version = 1;
}

return 'service_fragment_' . $postId . '_v' . $version;
}

function clinic_render_isolated_service_fragment(int $postId): string
{
$cacheKey = clinic_safe_service_cache_key($postId);
$cached = get_transient($cacheKey);
if (is_string($cached) && $cached !== '') {
return $cached;
}

$headline = (string) get_field('service_headline', $postId);
$intro = (string) get_field('service_intro', $postId);
$html = '<section class="service-cache-fragment">';
$html .= '<h2>' . esc_html($headline) . '</h2>';
$html .= '<p>' . esc_html($intro) . '</p>';
$html .= '</section>';

set_transient($cacheKey, $html, 10 * MINUTE_IN_SECONDS);
update_post_meta($postId, '_service_last_cache_key', $cacheKey);

return $html;
}

add_action('acf/save_post', 'clinic_bump_service_cache_version', 25);

function clinic_bump_service_cache_version($post_id): void
{
if (!is_numeric($post_id)) {
return;
}

$postId = (int) $post_id;
if (get_post_type($postId) !== 'page') {
return;
}

$oldKey = (string) get_post_meta($postId, '_service_last_cache_key', true);
if ($oldKey !== '') {
delete_transient($oldKey);
}

$version = (int) get_post_meta($postId, '_service_cache_version', true);
$version = $version > 0 ? $version + 1 : 2;
update_post_meta($postId, '_service_cache_version', $version);
}
terminal: command
wp eval 'set_transient("service_fragment_global_v1","<h2>Cached for page 1204</h2>",300); echo get_transient("service_fragment_global_v1") . PHP_EOL;'
wp eval '$k1=clinic_safe_service_cache_key(1204); $k2=clinic_safe_service_cache_key(1205); echo $k1===$k2 ? "collision" : "isolated"; echo PHP_EOL;'
wp eval '$id=1204; $old=clinic_safe_service_cache_key($id); set_transient($old,"cached",300); update_post_meta($id,"_service_last_cache_key",$old); do_action("acf/save_post",$id); echo get_transient($old)===false ? "old-cleared" : "old-present"; echo PHP_EOL;'
terminal: output
<h2>Cached for page 1204</h2>
isolated
old-cleared
warning

A cache key that does not encode post identity can produce cross-page content leakage. Treat key design as a correctness requirement, not only a performance detail.

Common Mistakes

MistakeRoot CauseWhat Breaks in ProductionCorrect Pattern
Cache key does not include post IDKey scope defined too broadlyPage A content appears on page Bwp eval '$id=1204; echo clinic_service_fragment_cache_key($id) . PHP_EOL;'
Cache invalidation not attached to save workflowTeam relies only on short TTLEditors see stale updates after publishadd_action('acf/save_post','clinic_invalidate_service_fragment_cache',20);
Running full get_field() loops inside every archive cardNo denormalized index strategyHigh query load and unstable response timeswp post meta get 2301 _resource_card_index
Query pulls full objects when only IDs are neededDefault query settings left untouchedMore memory and unnecessary processingnew WP_Query(['fields' => 'ids', 'no_found_rows' => true])
No lock around expensive fragment generationConcurrent misses regenerate same fragment repeatedlyCPU spikes during traffic burstsset_transient($cacheKey . '_lock','1',20);
Storing unescaped HTML from untrusted inputRendering and security concerns mixed in cacheInconsistent output and higher XSS riskEscape at render: esc_html(), esc_url() before caching
Deep Dive: Why Cache Key Collisions Are Harder to Debug Than They Look

Key-collision bugs often appear random because the wrong content depends on which page warmed the cache first. If QA always opens one page before another, they may never see the mismatch. This also slips past unit tests that do not simulate multiple post IDs against the same key pattern. The fix is to make key composition explicit and testable in CLI, including post ID and version token checks. Treat key generation as a public contract inside your codebase and review changes to it like API changes.

wp eval '$a=clinic_safe_service_cache_key(1204); $b=clinic_safe_service_cache_key(1205); echo $a . PHP_EOL . $b . PHP_EOL;'

Best Practices

  1. Version every fragment key and verify with wp eval '$id=1204; echo clinic_service_fragment_cache_key($id) . PHP_EOL;'.
  2. Keep invalidation narrow to affected post only: wp eval '$id=1204; do_action("acf/save_post",$id);'.
  3. Build archive card indexes at save time and inspect with wp post meta get 2301 _resource_card_index.
  4. Use ID-only archive queries and confirm count with wp eval '$q=new WP_Query(["post_type"=>"resource","fields"=>"ids"]); echo count($q->posts) . PHP_EOL;'.
  5. Prime metadata for ID batches: wp eval '$ids=[2301,2302,2303]; update_meta_cache("post",$ids); echo "primed" . PHP_EOL;'.
  6. Keep lock TTL short so failed generations self-heal quickly.
  7. Store one last-known-good fragment copy in post meta as a fallback during lock windows.

Hands-On Practice

Exercise 1: Create service landing pages for cache behavior tests

Run:

wp post create --post_title="Service Perf Drill A" --post_status=publish --post_type=page
wp post create --post_title="Service Perf Drill B" --post_status=publish --post_type=page

After completing this exercise, running wp post list --post_type=page --search="Service Perf Drill" --fields=ID,post_title --format=table should return two rows.

Exercise 2: Generate first fragment and verify transient hit

Run:

wp eval '$id=(int)get_page_by_title("Service Perf Drill A",OBJECT,"page")->ID; update_field("service_headline","Perf Drill A",$id); echo strlen(clinic_render_cached_service_page($id)) . PHP_EOL;'
wp eval '$id=(int)get_page_by_title("Service Perf Drill A",OBJECT,"page")->ID; $key=clinic_service_fragment_cache_key($id); echo get_transient($key)!==false ? "hit" : "miss"; echo PHP_EOL;'

After completing this exercise, output pattern should be:

<positive-length>
hit

Exercise 3: Trigger save invalidation and confirm cache deletion

Run:

wp eval '$id=(int)get_page_by_title("Service Perf Drill A",OBJECT,"page")->ID; $key=clinic_service_fragment_cache_key($id); set_transient($key,"cached",300); do_action("acf/save_post",$id); echo get_transient($key)===false ? "cleared" : "present"; echo PHP_EOL;'

After completing this exercise, output should be:

cleared

Exercise 4: Build and inspect resource card index JSON

Run:

wp eval '$id=2301; do_action("acf/save_post",$id); echo get_post_meta($id,"_resource_card_index",true) . PHP_EOL;'

After completing this exercise, output should be valid JSON with these keys:

title
tier
read_minutes
summary
highlights
updated_at

Exercise 5: Run archive render smoke gate

Run:

wp eval '$html=clinic_render_resource_archive_cards(); echo strlen($html) > 0 ? "archive-ok" : "archive-empty"; echo PHP_EOL;'

After completing this exercise, output should be:

archive-ok

CLI Reference

CommandPurposeReal Example Output
wp eval '$id=1204; echo clinic_service_fragment_cache_key($id) . PHP_EOL;'Print deterministic fragment key for one pageservice_fragment_v1_1204
wp eval '$id=1204; $key=clinic_service_fragment_cache_key($id); echo get_transient($key)!==false ? "hit" : "miss"; echo PHP_EOL;'Check current cache hit statushit
wp eval '$id=1204; $key=clinic_service_fragment_cache_key($id); do_action("acf/save_post",$id); echo get_transient($key)===false ? "invalidated" : "present"; echo PHP_EOL;'Verify save-hook invalidationinvalidated
wp post meta get 2301 _resource_card_indexRead denormalized archive payload for one resource{"title":"Choosing the Right Peel","tier":"advanced","read_minutes":9,"summary":"When and why clinicians choose medium-depth peel pathways.","highlights":["Downtime planning","Skin type checks","Pre-treatment prep"],"updated_at":"2026-02-23T18:30:22+00:00"}
wp eval '$q=new WP_Query(["post_type"=>"resource","post_status"=>"publish","fields"=>"ids","posts_per_page"=>24,"no_found_rows"=>true]); echo "ids=" . count($q->posts) . PHP_EOL;'Confirm archive query shape and result countids=24
wp eval '$ids=[2301,2302,2303]; update_meta_cache("post",$ids); echo "meta-cache-primed" . PHP_EOL;'Prime metadata for card batch rendermeta-cache-primed
wp eval '$k1=clinic_safe_service_cache_key(1204); $k2=clinic_safe_service_cache_key(1205); echo $k1===$k2 ? "collision" : "isolated"; echo PHP_EOL;'Detect key collision risk across pagesisolated
ls wp-content/themes/clinic-headless/inc/acf/Verify cache and index implementation files are presentresource-card-index.php service-fragment-cache.php service-cache-key-isolation.php

What's Next

tip

Revisit this lesson when a template becomes "suddenly slow" after content model growth, because that usually means your read-time work needs to move into save-time indexing and tighter cache keys.