·

One option instead of a hundred rows: ACF repeaters on options pages

On a lending site I worked on, the footer, the mega menu, a satisfaction survey and a handful of promo settings all lived on ACF options pages. Nothing exotic. Then we profiled a cached-miss request on the home page and found that a large share of the database queries had nothing to do with the page itself. They were ACF reading global settings, one row at a time.

This post is about why that happens, the small mu-plugin we used to fix it, and the nested-repeater bug that fix introduced a few weeks later.

Why a repeater costs so many queries

ACF stores a repeater the way WordPress stores everything: as flat key/value rows. A repeater called footer_columns with three rows, each holding a title and a nested repeater of links, becomes something like this in wp_options:

options_footer_columns                       3
options_footer_columns_0_title               Products
options_footer_columns_0_links               4
options_footer_columns_0_links_0_label       Personal loans
options_footer_columns_0_links_0_url         /loans/personal/
...
_options_footer_columns_0_links_0_url        field_6512ab...   ← reference rows, too

Every value has a sibling “reference” row pointing at the field key. A footer with four columns of six links is roughly a hundred rows. Options created by update_option() through ACF are not autoloaded on modern ACF versions, so each get_option() that misses the alloptions cache is its own query. With a persistent object cache that’s mostly hidden; on hosts where the object cache is flushed often, or on the first request after a deploy, you pay for it in full — on every page, because it’s the footer.

The idea: mirror the whole value into one option

The data changes when an editor clicks “Update” on an options page — a few times a month. It’s read on every request. That asymmetry is the whole fix: do the expensive assembly on write, store the result once, and read the result.

  • On save (acf/update_value), after ACF has written its rows, read the fully formatted value back and store it as a single, non-autoloaded option.
  • On the front end, short-circuit loading with acf/pre_load_value: if the mirror exists, return it and ACF never touches the individual rows.
  • In wp-admin, do nothing. The edit screen keeps using ACF’s normal storage, so the editing experience — and ACF’s own data — are untouched. The mirror is a read cache, not a new source of truth.
<?php
/**
 * Plugin Name: ACF options mirror
 * Description: Serve options-page repeaters/groups from a single option on the front end.
 */

final class Acf_Options_Mirror {

	/** Field names that are worth mirroring. Keep this explicit. */
	private const FIELDS = array( 'footer_columns', 'main_menu', 'survey' );

	public static function boot(): void {
		add_filter( 'acf/update_value', array( self::class, 'schedule_mirror' ), 10, 3 );
		add_filter( 'acf/pre_load_value', array( self::class, 'preload' ), 1, 3 );
	}

	private static function enabled( $field, $post_id ): bool {
		return 'options' === $post_id && in_array( $field['name'] ?? '', self::FIELDS, true );
	}

	private static function option_name( array $field ): string {
		return 'acf_mirror_' . $field['name'];
	}

	public static function schedule_mirror( $value, $post_id, $field ) {
		if ( self::enabled( $field, $post_id ) ) {
			// ACF writes sub-field rows after this filter; mirror once it's done.
			add_action( 'acf/save_post', static function () use ( $field ) {
				$fresh = get_field( $field['name'], 'options', false );
				update_option( self::option_name( $field ), $fresh, false );
			}, 20 );
		}
		return $value;
	}

	public static function preload( $null, $post_id, $field ) {
		if ( is_admin() || ! self::enabled( $field, $post_id ) ) {
			return $null;
		}
		$mirror = get_option( self::option_name( $field ), null );
		return is_array( $mirror ) ? $mirror : $null;
	}
}

Acf_Options_Mirror::boot();

Two details matter. First, the third argument of get_field() is false: we store the raw value, so ACF still runs its formatting (image IDs to arrays, links to arrays, and so on) when the value is returned from pre_load_value. Second, the option is created with autoload off. A mirror that’s autoloaded on every request, including requests that never render the footer (REST, cron, admin-ajax), would just move the cost somewhere else.

If the mirror doesn’t exist yet — fresh environment, new field — preload() returns null and ACF falls back to its normal path. Nothing breaks; it’s just slower until someone saves the options page once.

The bug: nested repeaters come back as objects

A few weeks after this shipped, a JavaScript component that consumed the menu through a JSON endpoint started failing with links.map is not a function. The PHP templates were fine. The JSON was not:

"links": { "row-0": { "label": "…" }, "row-1": { "label": "…" } }

Here’s what happened. When ACF loads a repeater normally, it loads the outer field, then fires its load filters again for every sub-field — including nested repeaters, which get their rows re-keyed into a plain list. When pre_load_value short-circuits the outer field, that second pass never happens. The outer value comes back formatted; the nested ones keep ACF’s internal row keys. PHP doesn’t care — foreach works on any array — but wp_json_encode() turns an array with non-sequential keys into a JSON object.

The fix is to normalise the whole tree before returning it: walk it recursively and, wherever an array’s keys are all integers or all look like ACF row keys, replace it with array_values().

private static function normalize( $value ) {
	if ( ! is_array( $value ) ) {
		return $value;
	}
	foreach ( $value as $key => $child ) {
		$value[ $key ] = self::normalize( $child );
	}
	return self::looks_like_rows( $value ) ? array_values( $value ) : $value;
}

private static function looks_like_rows( array $value ): bool {
	if ( array() === $value ) {
		return false;
	}
	foreach ( array_keys( $value ) as $key ) {
		if ( ! is_int( $key ) && ! preg_match( '/^row-?d+$/', (string) $key ) ) {
			return false; // A group or a field array: keep the keys.
		}
	}
	return true;
}

and preload() returns self::normalize( $mirror ). The check is deliberately strict: a group field also comes back as an associative array, and flattening it would destroy the field names. Only arrays whose every key is a row index get re-indexed.

What I’d tell my past self

  • Measure before you mirror. Query Monitor’s “Queries by component” view makes the ACF share obvious. If you already have a warm Redis object cache, the gain may be small — the mirror shines on cold caches and cache-light hosting.
  • Allowlist the fields. Mirroring everything sounds tempting and makes the failure surface huge. Pick the few global fields that are read on every page.
  • Test the JSON, not only the HTML. The bug was invisible in PHP templates. A single assertion that wp_json_encode() of the value starts with [ where a list is expected would have caught it on day one.
  • Keep ACF the source of truth. Because admin reads still go through ACF, deleting the mirror options is always a safe rollback.