Caching and Query Optimization for Field-Heavy Templates
Performance in ACF-heavy themes comes from predictable data flow, not one-off micro-optimizations.
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.
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
| Approach | What Happens | Impact in Production |
|---|---|---|
| Per-post fragment caching with save invalidation | High cache hit ratio and fast response after warm-up | Better Core Web Vitals and lower server load |
| Save-time card index generation for archive views | Loop rendering avoids repeated expensive field access | Stable performance as content count grows |
| Query shaping with IDs and metadata priming | Less query overhead before rendering starts | More predictable p95 response times |
| Key versioning tied to schema changes | Controlled cache busting when field structure evolves | Safer releases and fewer stale template bugs |
| One global cache key for all pages (wrong pattern) | Cross-page cache collisions and stale content leaks | User-visible data mismatch and high incident risk |
Reference Table
| Term/API | Signature/Syntax | Purpose | Key Notes |
|---|---|---|---|
get_field() | `get_field(string $selector, int | string $post_id = false, bool $format_value = true): mixed` | Read one ACF value for rendering or indexing |
have_rows() | `have_rows(string $selector, int | string $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): mixed | Read sub field inside row loop | Keep output escaped in templates |
get_transient() | get_transient(string $transient): mixed | Fetch cached fragment or derived payload | Returns false on miss |
set_transient() | set_transient(string $transient, mixed $value, int $expiration): bool | Persist cache entry with TTL | Use versioned key naming |
delete_transient() | delete_transient(string $transient): bool | Remove stale cache entry | Call from targeted save hook |
acf/save_post | add_action('acf/save_post', callable $callback, int $priority = 10, int $accepted_args = 1): void | Invalidate or rebuild data after editor save | Gate by numeric ID and post type |
WP_Query IDs mode | new WP_Query(['post_type' => 'resource', 'post_status' => 'publish', 'fields' => 'ids', 'no_found_rows' => true]) | Query only IDs for lean archive fetch | Pair with metadata priming |
update_meta_cache() | `update_meta_cache(string $meta_type, array $object_ids): array | false` | Prime metadata for batch of posts |
wp post meta get | wp post meta get <post-id> <meta-key> | Inspect derived index and debug payload | Core operational check for release QA |
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.
- Build a per-post cache key that includes schema version.
- Render full service fragment only on cache miss.
- Use a short lock transient to reduce concurrent regeneration.
- Store backup HTML in post meta for lock-window fallback.
- Invalidate only the affected post key in
acf/save_post. - Verify hit and invalidation behavior with CLI commands.
<?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);
}
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;'
512
cache-hit
invalidated
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.
- Build
_resource_card_indexJSON on eachresourcesave. - Extract summary fields and first three highlight rows.
- Query archive posts with
fields => 'ids'andno_found_rows => true. - Prime metadata cache for queried IDs in one call.
- Render cards from precomputed JSON payload, not raw ACF loops.
<?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();
}
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;'
{"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
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)
<?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)
<?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);
}
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;'
<h2>Cached for page 1204</h2>
isolated
old-cleared
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
| Mistake | Root Cause | What Breaks in Production | Correct Pattern |
|---|---|---|---|
| Cache key does not include post ID | Key scope defined too broadly | Page A content appears on page B | wp eval '$id=1204; echo clinic_service_fragment_cache_key($id) . PHP_EOL;' |
| Cache invalidation not attached to save workflow | Team relies only on short TTL | Editors see stale updates after publish | add_action('acf/save_post','clinic_invalidate_service_fragment_cache',20); |
Running full get_field() loops inside every archive card | No denormalized index strategy | High query load and unstable response times | wp post meta get 2301 _resource_card_index |
| Query pulls full objects when only IDs are needed | Default query settings left untouched | More memory and unnecessary processing | new WP_Query(['fields' => 'ids', 'no_found_rows' => true]) |
| No lock around expensive fragment generation | Concurrent misses regenerate same fragment repeatedly | CPU spikes during traffic bursts | set_transient($cacheKey . '_lock','1',20); |
| Storing unescaped HTML from untrusted input | Rendering and security concerns mixed in cache | Inconsistent output and higher XSS risk | Escape 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
- Version every fragment key and verify with
wp eval '$id=1204; echo clinic_service_fragment_cache_key($id) . PHP_EOL;'. - Keep invalidation narrow to affected post only:
wp eval '$id=1204; do_action("acf/save_post",$id);'. - Build archive card indexes at save time and inspect with
wp post meta get 2301 _resource_card_index. - 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;'. - Prime metadata for ID batches:
wp eval '$ids=[2301,2302,2303]; update_meta_cache("post",$ids); echo "primed" . PHP_EOL;'. - Keep lock TTL short so failed generations self-heal quickly.
- 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
| Command | Purpose | Real Example Output |
|---|---|---|
wp eval '$id=1204; echo clinic_service_fragment_cache_key($id) . PHP_EOL;' | Print deterministic fragment key for one page | service_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 status | hit |
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 invalidation | invalidated |
wp post meta get 2301 _resource_card_index | Read 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 count | ids=24 |
wp eval '$ids=[2301,2302,2303]; update_meta_cache("post",$ids); echo "meta-cache-primed" . PHP_EOL;' | Prime metadata for card batch render | meta-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 pages | isolated |
ls wp-content/themes/clinic-headless/inc/acf/ | Verify cache and index implementation files are present | resource-card-index.php service-fragment-cache.php service-cache-key-isolation.php |
What's Next
- Continue to Migration, Versioning, and Rollback Playbooks.
- Return to Module 8 Overview to keep this performance work aligned with your broader enterprise ACF patterns.
- Related lesson: Performance, Debugging, Migration, and Launch Checklist.
- Related lesson: Local JSON and Version Control Workflow.
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.