Your client clicks Publish. Then what?

Automatic rebuilds for a headless WordPress + Astro site — the build hook, the debounce, the WP-Cron trap, and the two failure modes nobody writes about.

Written for people who build static sites for other people. Last updated 28 August 2026.

Who's writing this. We run Blue, managed WordPress hosting for headless frontends — so we have an interest in you reading this, and you should know that before you start rather than after. Everything below is plain WordPress and runs on any host; there is nothing to install from us to make it work. Our own sites currently use the JAMstack Deployments plugin, and looking closely at what it does is how this article came about.

There are a lot of good tutorials about pairing WordPress with Astro. Almost all of them end at the same place: you fetch your posts, you run npm run build, you deploy, and the article stops. (We checked the four tutorials the Astro docs themselves link to. None covers rebuild-on-publish.)

That's a working demo. It is not a site you can hand to a client. Because the moment you hand it over, someone who is not you writes a post, presses Publish, refreshes the public site — and nothing happens. Nothing is broken, either. The static build is exactly as old as the last time you ran it.

That gap is the subject of this article. It's the least glamorous part of a headless setup and the part that decides whether the project survives contact with a real editor.

Three ways to rebuild, and what each one really costs

ApproachWhat it costs
You rebuild by hand Free, and fine while you're building. Fatal at handover: your client now has to message you to make a typo fix appear. You've made yourself a dependency for the life of the site.
Scheduled rebuild
(every 15 min, hourly, nightly)
One line of config. You pay for it twice: the editor waits up to a full interval without knowing why, and you burn build minutes on the intervals where nothing changed. A good safety net, a poor main mechanism.
Build hook on publish The site rebuilds because something changed. Costs a few lines of PHP and the edge cases in the rest of this article — which is why tutorials skip it.

Step 1 — get a build hook URL from your host

Most static hosts expose a URL you POST to in order to start a build. On Netlify (Project configuration → Build & deploy → Continuous deployment → Build hooks), Cloudflare Pages (Workers & Pages → your project → Settings → Builds → Add deploy hook) and Vercel (Settings → Git → Deploy Hooks), that URL is the secret: no API client, no auth header, no SDK.

Two exceptions worth knowing before you pick an approach. GitLab CI (Settings → CI/CD → Pipeline trigger tokens) needs both a token and a ref parameter. GitHub Actions needs a real token in an Authorization header, an event_type in the body, and a workflow listening on repository_dispatch.

Treat that URL as a password. Anyone who has it can start builds on your account until you rotate it. It does not belong in a plugin you commit, and — see step 5 — it can end up in your debug log if you're careless. Put it in wp-config.php, outside version control:
define( 'REBUILD_HOOK_URL', 'https://api.netlify.com/build_hooks/xxxxxxxx' );

Step 2 — call it when content changes

You may reach for a plugin here. The best-known one, JAMstack Deployments, still works — it's what we run ourselves — but its last release is from November 2020 and it's marked as tested up to WordPress 5.5. That may be fine for you; it's worth knowing before you make it load-bearing on a client site.

The mechanism is small enough to own. The full file is at the end of this article; here is the part that decides when to rebuild:

add_action( 'transition_post_status', 'rebuildhook_on_transition', 10, 3 );
add_action( 'before_delete_post', 'rebuildhook_on_delete', 10, 2 );

Three decisions in there matter more than the rest:

The deletion hole, which is the expensive one. wp_delete_post() does not fire transition_post_status. And wp_trash_post() starts with if ( ! EMPTY_TRASH_DAYS ) { return wp_delete_post( $post_id, true ); } — so on any site with define( 'EMPTY_TRASH_DAYS', 0 ), a common performance tweak, "Move to Trash" deletes outright and fires no transition at all. Your client removes an outdated legal page, WordPress deletes it, and it stays public indefinitely. That's why the file hooks before_delete_post as well.

Step 3 — the part everyone forgets: the burst

Here's the failure you'll actually hit. Your client sits down on a Monday morning and publishes eleven pages in a row — or, more often, clicks Update on the same page nine times while tweaking a sentence. Every one of those is a publish → publish transition. They pass every filter above.

The naive version fires eleven builds. Depending on your host they queue, they race, some get cancelled in favour of the last one (Vercel does this), or you simply pay for all eleven. Vercel also caps deploy hooks at 60 triggers per hour per project, so a big enough burst just gets refused.

So: fire immediately, then hold a lock, and remember whether anything happened while the lock was up. The order of operations matters more than it looks — the follow-up event is armed before anything else, because a "pending" flag with nothing scheduled to consume it is a change that never gets built:

function rebuildhook_request() {
	// Arm the follow-up FIRST: the pending branch below must never be able to
	// leave a flag set with nothing scheduled to consume it. And check that the
	// scheduling actually worked — wp_schedule_single_event() can return false.
	$armed = rebuildhook_arm();

	if ( $armed && get_transient( 'rebuildhook_lock' ) ) {
		set_transient( 'rebuildhook_pending', 1, DAY_IN_SECONDS );
		return;
	}

	// Clear the flag BEFORE claiming the lock: in the other order, a concurrent
	// request can raise it in between and we'd delete a change we never built.
	delete_transient( 'rebuildhook_pending' );
	set_transient( 'rebuildhook_lock', 1, rebuildhook_window() );
	rebuildhook_defer_fire();
}

The first publish still goes out instantly — that's what the client sees, and it's what makes the setup feel alive. The rest collapse into a single follow-up build.

Three honest limits of this lock. get_transient() followed by set_transient() is not atomic: two simultaneous publishes can both take the lock and both fire. If that matters, the core pattern for a real lock is WP_Upgrader::create_lock(), which uses an INSERT IGNORE on the options table. On a site with a persistent object cache — standard at managed hosts — transients can be evicted before they expire: an evicted lock means extra builds, an evicted pending flag means a change that never gets built. And don't set REBUILDHOOK_WINDOW to 0 to "disable" the debounce: set_transient() treats an expiration of 0 as never expires, so the lock would freeze permanently and nothing would ever build again. The file clamps it to a 10-second minimum for that reason.

Step 4 — the WP-Cron trap

That follow-up build depends on wp_schedule_single_event, and here's what catches people out: WP-Cron isn't a cron job. It's hooked to init, so it only gets a chance to run when something requests the site — any front-end hit, wp-admin, REST or AJAX — and it fires a non-blocking loopback request so the visitor never waits for the job itself.

On a headless site there's no front-end traffic at all. Your REST and GraphQL requests do spawn cron, but that traffic is your build's: it arrives in a burst during a build and then stops. So an event scheduled for two minutes after a build can sit there until the next build asks for it — which is exactly the wrong way round. That's why the symptom is intermittent rather than total: the catch-up build shows up hours later, when you happen to open wp-admin.

If you have SSH

# wp-config.php — above the require_once ABSPATH . 'wp-settings.php'; line
define( 'DISABLE_WP_CRON', true );
# crontab -u www-data -e   — the web user, NOT root: WP-CLI refuses to run as
# root without --allow-root, and files it writes as root will break PHP-FPM.
# Absolute path: cron's PATH is /usr/bin:/bin, and wp lives in /usr/local/bin.
# Keep stderr: this log is the only way you'll learn that cron stopped running.
*/5 * * * * cd /var/www/example.com && /usr/local/bin/wp cron event run --due-now >> /var/log/wp-cron.log 2>&1

If you don't — shared hosting, cPanel, no WP-CLI

This is most agency projects, and it's the step where people give up after doing all the work. You don't need WP-CLI. WordPress ships an endpoint you can hit over plain HTTP, which is all a cPanel cron task or a free external scheduler can do:

*/5 * * * * curl -fsS "https://example.com/wp-cron.php?doing_wp_cron" >/dev/null

Same DISABLE_WP_CRON line in wp-config.php, then paste that URL into your panel's cron section — or into any external ping service, if your host doesn't offer cron at all. It's the same mechanism WordPress would have triggered itself; you're just supplying the requests that a headless site no longer gets.

And if you can't have cron at all: the file hooks the same catch-up to admin_init, so any admin page load drains a pending build. It's not a schedule — nothing happens while nobody is logged in — but it means a change made inside the lock window isn't lost forever, it's built the next time someone opens wp-admin. If even that isn't enough, shorten REBUILDHOOK_WINDOW to 30 seconds and accept the extra builds. That's a real, defensible setup, not a broken one.

Step 5 — check that it actually works

  1. The hook, alone. Don't paste the URL on the command line — it lands in your shell history and in ps. Read it in first:
    read -rs HOOK
    curl -X POST -i "$HOOK"
    A build should start within seconds. If this fails, nothing downstream matters.
  2. The hook, from WordPress. Publish a test post; a build should start. If it doesn't, check your debug log — and check that outbound HTTP isn't blocked: WP_HTTP_BLOCK_EXTERNAL in wp-config.php will silently kill the hook. It's normally set by the site owner or a security plugin rather than by a host, and WP_ACCESSIBLE_HOSTS lets you allow just your build hook's domain through.
  3. The burst. Publish three posts in under a minute. You should see two builds: one immediate, one when the follow-up event runs — allow a full cron interval for that (five minutes with the crontab above, not two). Three builds means the lock isn't holding. One build after two cron intervals means the follow-up never ran: back to step 4.
Don't log the error message raw. When WP_HTTP_BLOCK_EXTERNAL blocks a request, WordPress builds the error string with sprintf( 'User has blocked requests through HTTP to the URL: %s.', $url ) — your build hook secret, in full, in wp-content/debug.log, which is often readable over HTTP. Log the error code, not the message. The file below does.

The trap that broke this article's first draft

The first version of everything above called the build hook straight from transition_post_status. That looks right and is subtly wrong, and it took a code review to catch. In wp-includes/post.php:

5180:  wp_transition_post_status( ... )   <- the hook fires HERE
5290:  do_action( 'save_post' )
5304:  wp_after_insert_post( ... )

And in the REST controller — the path the block editor and every headless client take — wp_update_post() runs on line 980, then handle_featured_media(), handle_terms() and meta->update_value() on lines 1003 to 1025.

Categories, tags, the featured image and every custom field are written after the transition fires. Trigger the build there and your static site rebuilds from content that is one save behind: the post appears, uncategorised, with no featured image and empty Pods or ACF fields. Worse, it's intermittent — a fast build loses the race, a slow one doesn't.

The fix is to detect on the transition but fire on shutdown, which runs after every write:

function rebuildhook_defer_fire() {
	static $deferred = false;

	if ( $deferred ) {
		return;
	}
	$deferred = true;
	add_action( 'shutdown', 'rebuildhook_fire', 1 );
}

The static flag also collapses a bulk edit or a WP-CLI import loop into a single call per request, which the transient lock alone wouldn't do.

A trap in the obvious optimisation

The natural instinct is to pass 'blocking' => false so the editor's Publish click doesn't wait for the HTTP round-trip. It doesn't do that. With the default Requests transport, curl_exec() runs synchronously either way; WordPress just returns early and hands you a fake response array. Publish waits for the round-trip regardless.

What you actually buy with it is the loss of every error: process_response() returns before the curl_errno() check, so a revoked hook, a DNS failure or a 404 all come back looking like nothing happened. A short timeout with blocking on is strictly better — you wait the same time and you learn the status code:

	$response = wp_remote_post(
		REBUILD_HOOK_URL,
		array(
			'timeout'  => 5,
			'blocking' => true, // 'false' is not async in WordPress.
		)
	);

If you genuinely want it off the request path, the only reliable route is fastcgi_finish_request() on shutdown, where your host supports it.

When the build fails — the last call you'll still get

Everything above makes the site rebuild when your client publishes. None of it tells you when that stops working: build minutes exhausted, a broken build, a rotated hook. The client publishes, waits, sees nothing, and calls you — the exact call this whole setup exists to prevent.

Two things, neither of which takes long:

What to tell the client, in one sentence

This part has nothing to do with code and saves the most support time. An editor who publishes and sees no change assumes it's broken, and messages you.

Tell them, in writing, at handover:

"When you press Publish, the public site updates itself within a couple of minutes. It is not instant, and that's normal — no need to click Publish again, it won't go faster. If it's been ten minutes, then something is wrong and you should tell me."

Ou, en français : « Quand vous cliquez sur Publier, le site public se met à jour tout seul en une à deux minutes. Ce n'est pas instantané, c'est normal — inutile de recliquer sur Publier, ça n'ira pas plus vite. Si au bout de dix minutes il ne s'est rien passé, c'est qu'il y a un problème : prévenez-moi. »

Give them a number and a threshold. Without the threshold they'll either panic at 30 seconds or never report a genuine outage. The "don't click again" line matters too: it's the human half of the debounce you just built.

What this doesn't solve

The whole file

Drop this in wp-content/mu-plugins/rebuild-on-publish.php. Must-use plugins load automatically and can't be deactivated by accident from the admin. Set REBUILD_HOOK_URL in wp-config.php, and add your own post types to the list at the top.

<?php
/**
 * Plugin Name: Rebuild on publish
 * Description: Calls the static host's build hook when public content changes.
 */

defined( 'ABSPATH' ) || exit;

// Override in wp-config.php if you want a different window.
// Minimum 10 seconds: set_transient() treats an expiration of 0 as "never
// expires" and stores the option as autoloaded, which would freeze the debounce.
defined( 'REBUILDHOOK_WINDOW' ) || define( 'REBUILDHOOK_WINDOW', 120 );

add_action( 'transition_post_status', 'rebuildhook_on_transition', 10, 3 );
add_action( 'before_delete_post', 'rebuildhook_on_delete', 10, 2 );
add_action( 'rebuildhook_flush', 'rebuildhook_flush_pending' );
// Catch-up leg. WP-Cron is traffic-driven (wp_cron() runs on 'init' and defers
// to shutdown), and a headless WordPress has no public traffic by design; many
// installs also set DISABLE_WP_CRON with no system cron behind it. Any admin
// page load drains a pending build.
add_action( 'admin_init', 'rebuildhook_flush_pending' );

function rebuildhook_window() {
	return max( 10, (int) REBUILDHOOK_WINDOW );
}

/**
 * Post types your frontend actually queries. Add your own CPTs here.
 */
function rebuildhook_types() {
	$types = apply_filters( 'rebuildhook_post_types', array( 'post', 'page' ) );

	return is_array( $types ) ? $types : array();
}

function rebuildhook_on_transition( $new_status, $old_status, $post ) {
	// Public now, or just stopped being public.
	if ( 'publish' !== $new_status && 'publish' !== $old_status ) {
		return;
	}
	// Belt and braces: revisions are status 'inherit' and never get here anyway.
	if ( wp_is_post_revision( $post ) ) {
		return;
	}
	// Menu items really are stored as 'publish' — this guard is load-bearing.
	if ( 'nav_menu_item' === $post->post_type ) {
		return;
	}
	if ( ! in_array( $post->post_type, rebuildhook_types(), true ) ) {
		return;
	}
	rebuildhook_request();
}

/**
 * Permanent deletion never fires transition_post_status.
 * Reachable with EMPTY_TRASH_DAYS = 0, REST ?force=true, wp post delete --force.
 */
function rebuildhook_on_delete( $post_id, $post = null ) {
	// $post only exists since WP 5.5. Without the default value, deleting a post
	// raises an ArgumentCountError (fatal) on older installs.
	if ( ! $post instanceof WP_Post ) {
		$post = get_post( $post_id );
	}
	if ( ! $post instanceof WP_Post || 'publish' !== $post->post_status ) {
		return;
	}
	if ( ! in_array( $post->post_type, rebuildhook_types(), true ) ) {
		return;
	}
	rebuildhook_request();
}

function rebuildhook_request() {
	if ( wp_installing() ) {
		return;
	}

	// Arm the follow-up FIRST: the pending branch must never leave a flag set
	// with nothing scheduled to consume it. wp_schedule_single_event() can fail
	// (pre_schedule_event / schedule_event filters, cron manager plugins), so
	// check that it actually worked.
	$armed = rebuildhook_arm();

	if ( $armed && get_transient( 'rebuildhook_lock' ) ) {
		set_transient( 'rebuildhook_pending', 1, DAY_IN_SECONDS );
		return;
	}

	// Clear the flag BEFORE claiming the lock: in the other order, a concurrent
	// request can raise it in between and we would delete a change we never built.
	delete_transient( 'rebuildhook_pending' );
	set_transient( 'rebuildhook_lock', 1, rebuildhook_window() );
	rebuildhook_defer_fire();
}

function rebuildhook_arm() {
	if ( wp_next_scheduled( 'rebuildhook_flush' ) ) {
		return true;
	}

	return (bool) wp_schedule_single_event( time() + rebuildhook_window() + 5, 'rebuildhook_flush' );
}

function rebuildhook_flush_pending() {
	if ( ! get_transient( 'rebuildhook_pending' ) ) {
		return;
	}
	// Still inside a window: keep the flag, come back after it.
	if ( get_transient( 'rebuildhook_lock' ) ) {
		rebuildhook_arm();
		return;
	}
	delete_transient( 'rebuildhook_pending' );
	set_transient( 'rebuildhook_lock', 1, rebuildhook_window() );
	rebuildhook_defer_fire();
}

/**
 * transition_post_status runs BEFORE save_post, and before terms, meta, featured
 * image and template are written (the REST controller writes them after
 * wp_update_post()); before_delete_post runs before the row is gone. Calling the
 * build hook from there would let the build read stale content back. shutdown
 * runs after every write.
 */
function rebuildhook_defer_fire() {
	static $deferred = false;

	if ( $deferred ) {
		return;
	}
	$deferred = true;
	add_action( 'shutdown', 'rebuildhook_fire', 1 );
}

function rebuildhook_fire() {
	if ( ! defined( 'REBUILD_HOOK_URL' ) || ! REBUILD_HOOK_URL ) {
		rebuildhook_log( 'REBUILD_HOOK_URL is not defined, nothing was called' );
		return;
	}

	$response = wp_remote_post(
		REBUILD_HOOK_URL,
		array(
			'timeout'  => 5,
			'blocking' => true, // 'false' is not async in WordPress. See the article.
		)
	);

	if ( is_wp_error( $response ) ) {
		// Never log the message or the data: several WP_Http errors embed the URL
		// (http_request_not_executed, and cURL messages such as "Could not resolve host").
		rebuildhook_log( 'request failed (' . $response->get_error_code() . ')' );
		return;
	}

	$code = (int) wp_remote_retrieve_response_code( $response );
	if ( $code < 200 || $code > 299 ) {
		rebuildhook_log( 'build hook returned HTTP ' . $code );
	}
}

function rebuildhook_log( $message ) {
	if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
		error_log( '[rebuild] ' . $message ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
	}
}

If you try this and something in it is wrong, we'd genuinely like to know — yves@cms.blue. It's had three rounds of review and none of them is a production site with your plugins on it.