·

A page cache that knows what time it is

Full-page caching assumes that HTML changes when content changes: someone saves a post, the cache plugin purges the affected URLs, done. That assumption breaks the moment a page depends on the clock. A promo banner that starts on Monday at 9:00 and ends on Friday at midnight doesn’t involve anyone saving anything at 9:00 on Monday — and so the cached page happily keeps showing last week’s banner.

On a marketing site for a financial product we had exactly this: time-boxed banners with countdowns, configured by editors in ACF, behind a full-page cache. Here’s how we made the cache aware of time without giving up caching.

The options we rejected

  • A short TTL on every page. Works, and throws away most of the cache’s value to fix something that happens a few times a month.
  • Render the banner client-side. Fetch “current banners” via JavaScript and inject them. It moves the problem rather than solving it (that endpoint needs caching too), and it adds layout shift right in the hero area.
  • Purge every hour, just in case. Predictable, wasteful, and still up to 59 minutes late.

The thing all of them miss is that we know exactly when the HTML will change. The start and end dates are sitting in the database. So the cache only needs to be purged at those moments — and nowhere else.

Schedule a purge for every boundary

Whenever the banner settings are saved, collect every start and end timestamp in the future and schedule a single WP-Cron event at each one. Each event purges the page cache and re-schedules from the current data, so edits made in the meantime are respected.

final class Timed_Content_Purge {

	private const HOOK = 'site_timed_content_purge';
	private const LOCK = 'site_timed_content_purge_lock';

	public static function boot(): void {
		add_action( 'acf/save_post', array( self::class, 'on_save' ), 20 );
		add_action( self::HOOK, array( self::class, 'run' ) );
	}

	public static function on_save( $post_id ): void {
		if ( 'options' === $post_id ) {
			self::reschedule();
		}
	}

	public static function reschedule(): void {
		wp_clear_scheduled_hook( self::HOOK );
		foreach ( self::boundaries() as $timestamp ) {
			if ( $timestamp > time() ) {
				wp_schedule_single_event( $timestamp, self::HOOK );
			}
		}
	}

	public static function run(): void {
		if ( get_transient( self::LOCK ) ) {
			return; // two boundaries in the same minute: one purge is enough
		}
		set_transient( self::LOCK, 1, MINUTE_IN_SECONDS );

		do_action( 'litespeed_purge_all' ); // or your cache plugin's equivalent
		self::reschedule();
	}
}

wp_clear_scheduled_hook() followed by a fresh schedule keeps this idempotent: no matter how many times editors save, there is exactly one event per future boundary.

Compare timestamps, never date strings

ACF’s date-time picker stores values like 2025-03-30 02:30:00 in the site’s timezone. The first version compared those strings with current_time( 'mysql' ). That’s fine 363 days a year and wrong around DST changes — and on the night clocks go forward, 02:30 doesn’t exist at all. Convert everything to Unix timestamps through wp_timezone() once, and do all maths on integers:

private static function to_timestamp( ?string $local ): ?int {
	if ( ! $local ) {
		return null;
	}
	$dt = date_create_immutable_from_format( 'Y-m-d H:i:s', $local, wp_timezone() );
	return $dt ? $dt->getTimestamp() : null;
}

private static function boundaries(): array {
	$stamps = array();
	foreach ( (array) get_field( 'timed_banners', 'options' ) as $banner ) {
		$stamps[] = self::to_timestamp( $banner['starts_at'] ?? null );
		$stamps[] = self::to_timestamp( $banner['ends_at'] ?? null );
	}
	return array_values( array_unique( array_filter( $stamps ) ) );
}

Use the same helper in the template that decides whether a banner is visible. If the template and the scheduler interpret dates differently, the cache will be purged on time and re-filled with the wrong banner.

WP-Cron is not a clock

Here is the catch that makes or breaks this approach. WP-Cron only runs when someone requests an uncached page. Behind a full-page cache, almost every request is a cache hit, PHP never runs, and your 9:00 event fires at 9:47 when the first visitor hits an uncached URL. The feature that fixes stale cache is defeated by the cache.

So: DISABLE_WP_CRON set to true, and a real system cron calling WP-CLI every minute. Wrap it in flock so a slow run never overlaps with the next one:

# crontab
* * * * * flock -n /tmp/wp-cron.lock wp --path=/var/www/site cron event run --due-now --quiet

Survive deploys and database copies

Scheduled events live in the cron option. Restore a database snapshot, or copy production to staging, and the schedule may be missing or belong to another environment. We added a cheap self-heal: on init, at most once every 12 hours (guarded by a transient), call reschedule(). It costs one option read most of the time and guarantees the schedule converges back to the data.

Help editors avoid conflicts

The last piece was not technical. Two banners with overlapping date ranges would both be “active”, and the template would pick one more or less arbitrarily. Instead of silently choosing, the options page shows an admin notice listing overlapping ranges. Ten lines of code, and a whole class of “why is the wrong banner showing?” tickets disappeared.

The general idea

Cache invalidation is easy when you can name the events that change the output. “Someone saved a post” is one such event. “The clock crossed a date stored in the database” is another, and it’s just as precise — you only have to schedule it yourself, and make sure something other than visitors is driving the clock.