Home Blog

, , ,

A Hook That Fires Twice – and How Not to Trigger an Infinite Loop When Saving a Post

Why This Is Dangerous This isn’t some edge case — it’s one of the most common reasons a “simple” post save suddenly hangs the admin. The problem is compounded by the fact…

The Problem

A typical case: a developer hooks logic onto save_post — update a counter, sync an external service, recalculate something based on the post’s fields. The code runs… and immediately runs again. And again. save_post fires multiple times for a single save — on autosave, on revision creation, and finally on the actual post. If the “quick” fix is to call wp_update_post() inside that handler to patch something, it triggers save_post a second time, which calls wp_update_post() again, which fires save_post again. The result is recursion that either hangs the save, duplicates the action, or crashes WordPress outright with “Allowed memory size exhausted” — or just an endless spinner in the admin.

Why This Is Dangerous

This isn’t some edge case — it’s one of the most common reasons a “simple” post save suddenly hangs the admin. The problem is compounded by the fact that the same hook often carries logic from several plugins at once: an SEO plugin, a caching plugin, your own custom code — and recursion in just one of them can bring down the save for all of them. On large sites with thousands of posts it’s also a direct load on the database: every extra wp_update_post() call means another UPDATE and another full run of the entire save hook chain.

Step-by-Step Solution

Step 1 — Guard checks: filter out autosaves and revisions immediately

Without this, every save_post handler runs at least twice as often as it needs to:

Plaintext
add_action( 'save_post', function( $post_id, $post, $update ) {
	// Skip autosaves, revisions, and other post types right away.
	if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
		return;
	}
	if ( 'post' !== $post->post_type ) {
		return;
	}
	if ( ! current_user_can( 'edit_post', $post_id ) ) {
		return;
	}

	// Your code here.
}, 10, 3 );

Step 2 — If you need to update the post itself inside `save_post`: break the recursion manually

Calling wp_update_post() inside save_post is the classic cause of an infinite loop. The fix is to remove the handler for the duration of the call and add it back right after:

Javascript
// Bad — infinite recursion: save_post calls wp_update_post(),
// which fires save_post again, and so on in a loop.
add_action( 'save_post', function( $post_id ) {
	wp_update_post( array(
		'ID'         => $post_id,
		'post_title' => forwp_generate_title( $post_id ),
	) );
} );

// Good — remove the handler before updating, add it back immediately after.
add_action( 'save_post', 'forwp_normalize_title', 10, 3 );

function forwp_normalize_title( $post_id, $post, $update ) {
	if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
		return;
	}

	remove_action( 'save_post', 'forwp_normalize_title', 10 );

	wp_update_post( array(
		'ID'         => $post_id,
		'post_title' => forwp_generate_title( $post_id ),
	) );

	add_action( 'save_post', 'forwp_normalize_title', 10, 3 );
}

Step 3 — The right hook for the right task

The four hooks in this category look interchangeable, but each is meant for a specific moment:

HookWhen it firesBest fortransition_post_statusBefore the save, exactly when the status changesReacting to a specific transition (draft → publish, publish → trash)wp_insert_postRight after the database write, before the cache is clearedQuick actions immediately after insertion — logging, notificationssave_postAfter wp_insert_post, more stable, the default choice for most pluginsGeneral metadata-handling logic on savebefore_delete_postRight before deletion, while the post still existsCleaning up related data — the last chance before the record is gone for good

Plaintext
// React specifically to publishing, not to every draft save.
add_action( 'transition_post_status', function( $new_status, $old_status, $post ) {
	if ( 'publish' === $new_status && 'publish' !== $old_status ) {
		forwp_notify_subscribers( $post->ID );
	}
}, 10, 3 );

// Clean up related data before deletion — while the post still exists in the database.
add_action( 'before_delete_post', function( $post_id, $post ) {
	forwp_cleanup_related_records( $post_id );
}, 10, 2 );

A common mistake is reaching for save_post where transition_post_status is actually what’s needed: then the “on publish” logic runs on every draft save instead of only on the real status transition.

When It’s Better to Bring In a Specialist

These three steps cover most typical situations. But there are cases where debugging recursion yourself costs more than bringing in a specialist: production with active traffic, where a looping save_post blocks editing for the entire editorial team; a site with a dozen plugins already hooked onto the same action, where new code conflicts in ways that are hard to trace; or bulk post imports, where every extra save_post cycle multiplies across thousands of records into hours of unnecessary database load. In these cases, a WordPress developer with real hands-on experience in save/CRUD hooks usually finds the recursion point in minutes, not hours — this is the case where WordPress development services end up cheaper than self-guided debugging in production.

FAQ

Why does `save_post` fire multiple times for one save? WordPress creates a revision and may run an autosave before the final post write — each of these also triggers save_post. Without wp_is_post_autosave()/wp_is_post_revision() checks, your code runs on every one of them, not just on the real save.

How do I know it’s actual infinite recursion and not just slow code? The classic sign is a fatal error — “Allowed memory size exhausted” or “Maximum execution time exceeded” — appearing specifically on post save, and it shows up only when a CRUD hook like save_post calls a function that itself triggers that same hook (wp_update_post(), wp_insert_post()).

Can several of these hooks be used together? Yes, and that’s normal — they cover different moments. A typical setup: transition_post_status reacts to publishing, save_post updates metadata, before_delete_post cleans up related records. Conflicts don’t come from combining them, but from missing guard checks in each one.

What’s the difference between `wp_insert_post` and `save_post` if both fire “on save”? wp_insert_post fires earlier, right after the database write, before the post cache is cleared. save_post fires slightly later and is considered the more stable default choice for most plugins — which is why it’s the most commonly used of the four.

Does `before_delete_post` also fire when a post is moved to trash? No. It only fires on permanent deletion, not on trash. Reacting to a move to trash needs a separate hook (wp_trash_post) — this one is specifically the last moment before the record is physically removed from the database.

When should I hire a WordPress developer instead of figuring it out myself? When recursion or a hook conflict shows up in production with real users, during bulk imports, or when several plugins are already filtering the same CRUD hooks. The cost of an hour of editorial downtime usually outweighs the cost of a specialist who can pinpoint and break the loop precisely.

Summary

A full breakdown of each of the 4 hooks in this category — with examples and a hands-on IDE — is on the Save & CRUD Hooks page, and the whole set is also available as one PDF to keep.