The Symptom: A Menu That Refuses to Acknowledge Your New Post
You publish a post. You add it to a menu via Appearance → Menus. You save. You reload the front end. The menu still shows the old set of items. You clear your browser cache. You clear your page cache. You clear your object cache. The menu still lies to you.
This is not a browser problem. It is not a CDN problem. It is a WordPress core behavior problem, and it lives in the interaction between wp_nav_menu(), the wp_get_nav_menu_items() function, and the transient cache that WordPress uses to store menu data. The cache key is derived from the menu’s term_taxonomy_id and the arguments passed to wp_nav_menu(). When you add a new post to a menu, the menu’s term_taxonomy_id does not change. The arguments do not change. The transient does not expire. The menu does not update.
This article traces that failure through core hooks, the wp_posts and wp_term_relationships tables, the object cache, and the REST API. It ends with a selective invalidation strategy that does not require flushing the entire cache or installing a plugin that flushes everything on every save.
The Trace: Where WordPress Caches Menu Output
Start with the function that renders the menu. In wp-includes/nav-menu-template.php, wp_nav_menu() calls wp_get_nav_menu_items() to fetch the menu items. That function, in wp-includes/nav-menu.php, does this:
$items = get_transient( 'wp_get_nav_menu_items_' . $menu->term_taxonomy_id );
if ( false === $items ) {
$items = wp_get_nav_menu_items_from_db( $menu, $args );
set_transient( 'wp_get_nav_menu_items_' . $menu->term_taxonomy_id, $items, DAY_IN_SECONDS );
}
The transient key is wp_get_nav_menu_items_{term_taxonomy_id}. The expiration is one day. The cache is stored in the wp_options table under the _transient_ prefix, or in the object cache if a persistent object cache is active.
When you add a new post to a menu, WordPress updates the wp_term_relationships table. It does not delete the transient. It does not update the transient. It does not change the term_taxonomy_id. The transient remains valid for up to 24 hours.
You can verify this with WP-CLI:
wp transient get wp_get_nav_menu_items_123
Replace 123 with the term_taxonomy_id of your menu. You will see a serialized array of menu items. If you recently added a post, the array will not contain it.
You can also check the wp_options table directly:
SELECT option_name, option_value, option_id
FROM wp_options
WHERE option_name LIKE '_transient_wp_get_nav_menu_items_%';
This returns every cached menu transient. The option_value is a serialized array. The option_id is the row identifier. The transient does not have an explicit expiration column; WordPress stores the expiration time in the _transient_timeout_ option.
Why the Cache Key Does Not Change
The cache key is based on term_taxonomy_id, not on the menu’s contents. A menu is a taxonomy term in the nav_menu taxonomy. The term_taxonomy_id is assigned when the menu is created. Adding or removing items from the menu does not change the term_taxonomy_id. The transient key remains the same.
This is a deliberate design choice. It avoids cache invalidation on every menu item change. It assumes that menu changes are infrequent and that a 24-hour stale window is acceptable. For a small-to-mid publishing team, that assumption fails when you publish multiple posts per day and add them to menus immediately.
What About the REST API?
The REST API does not use the same transient. When you fetch a menu via /wp-json/wp/v2/menu-items or /wp-json/wp/v2/menus, the API queries the database directly. It does not read the wp_get_nav_menu_items transient. This means the REST API can return fresh data while the front end returns stale data. That discrepancy is a useful diagnostic: if the REST API shows the new item but the front end does not, the transient is the problem.
You can test this with cURL:
curl -s https://example.com/wp-json/wp/v2/menu-items?menus=123 | jq '.[].title.rendered'
If the new post appears in the REST response but not in the front-end menu, the transient is stale.
The Root Cause: No Invalidation Hook on Post Save
WordPress does not delete the wp_get_nav_menu_items transient when a post is saved. It does not delete it when a post is added to a menu. It does not delete it when a menu item is updated. The only time the transient is deleted is when the menu itself is updated via wp_update_nav_menu(), which calls delete_transient( 'wp_get_nav_menu_items_' . $menu_id ). That function is called when you save the menu in the admin, but not when you save a post that is already in the menu.
This is the failure mode: the menu transient is invalidated on menu save, but not on post save. If you add a post to a menu and then edit that post, the menu transient is not invalidated. If you publish a new post and add it to a menu via the block editor’s menu panel, the menu transient is not invalidated. If you add a post to a menu via the REST API, the menu transient is not invalidated.
The core ticket for this behavior is #29408, which discusses transient invalidation for nav menus. It has been open since 2014. The current behavior is documented in the wp_get_nav_menu_items() function reference.
What About the Block Editor?
The block editor uses the REST API to fetch menu items. It does not use the wp_get_nav_menu_items transient. When you add a post to a menu in the block editor, the editor sends a POST request to /wp-json/wp/v2/menu-items. That request updates the wp_posts table and the wp_term_relationships table. It does not delete the transient. The front end remains stale.
You can confirm this by watching the network tab in DevTools while adding a post to a menu. The request payload will include the menus parameter and the object_id of the post. The response will include the new menu item. The transient is not touched.
The Fix: Selective Invalidation on Post Save
The fix is to delete the relevant menu transients when a post is saved. You do not need to delete all menu transients. You only need to delete the transients for menus that contain the post. This is selective invalidation.
Here is a runnable snippet for functions.php or a site-specific plugin:
add_action( 'save_post', function( $post_id, $post, $update ) {
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
return;
}
$menus = wp_get_nav_menus();
foreach ( $menus as $menu ) {
$items = wp_get_nav_menu_items( $menu->term_id );
if ( ! $items ) {
continue;
}
foreach ( $items as $item ) {
if ( (int) $item->object_id === (int) $post_id ) {
delete_transient( 'wp_get_nav_menu_items_' . $menu->term_taxonomy_id );
break;
}
}
}
}, 10, 3 );
This hook runs on every post save. It fetches all menus, fetches the items for each menu, and checks if the saved post is in the menu. If it is, it deletes the transient for that menu. The next front-end request will regenerate the transient with fresh data.
This is not the most efficient approach. It fetches all menus and all menu items on every post save. For a site with many menus and many items, this can be slow. A more efficient approach is to query the wp_term_relationships table directly:
add_action( 'save_post', function( $post_id ) {
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
return;
}
global $wpdb;
$menus = $wpdb->get_col( $wpdb->prepare(
"SELECT tt.term_taxonomy_id
FROM {$wpdb->term_relationships} tr
INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
WHERE tr.object_id = %d
AND tt.taxonomy = 'nav_menu'",
$post_id
) );
foreach ( $menus as $menu_id ) {
delete_transient( 'wp_get_nav_menu_items_' . $menu_id );
}
}, 10, 1 );
This query joins wp_term_relationships and wp_term_taxonomy to find the term_taxonomy_id of every menu that contains the post. It then deletes the transient for each menu. This is faster and more precise.
You can verify the query with WP-CLI:
wp db query "SELECT tt.term_taxonomy_id FROM wp_term_relationships tr INNER JOIN wp_term_taxonomy tt ON tr.term_taxonomy_id = tt.term_taxonomy_id WHERE tr.object_id = 123 AND tt.taxonomy = 'nav_menu';"
Replace 123 with the post ID. The result is the term_taxonomy_id of the menu that contains the post.
What About Persistent Object Cache?
If you use a persistent object cache like Redis or Memcached, delete_transient() will delete the transient from the object cache. The next request will regenerate it. This works as expected. The only caveat is that the object cache may have a separate expiration policy. Check your object cache configuration to ensure that transients are not cached indefinitely.
What About Multisite?
On multisite, delete_transient() operates on the current site’s options table. If the post is saved on one site and the menu is on another site, the transient will not be deleted. You need to switch to the correct site before deleting the transient:
add_action( 'save_post', function( $post_id ) {
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
return;
}
global $wpdb;
$menus = $wpdb->get_col( $wpdb->prepare(
"SELECT tt.term_taxonomy_id
FROM {$wpdb->term_relationships} tr
INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
WHERE tr.object_id = %d
AND tt.taxonomy = 'nav_menu'",
$post_id
) );
foreach ( $menus as $menu_id ) {
$blog_id = get_current_blog_id();
switch_to_blog( $blog_id );
delete_transient( 'wp_get_nav_menu_items_' . $menu_id );
restore_current_blog();
}
}, 10, 1 );
This is a simplified example. In practice, you need to determine which site the menu belongs to. The term_taxonomy_id is unique per site, so you need to query the correct site’s tables. This is an edge case; most small-to-mid publishing teams run single-site installs.
Verification: How to Confirm the Fix Works
After adding the snippet, publish a new post and add it to a menu. Then check the transient:
wp transient get wp_get_nav_menu_items_123
If the transient is deleted, the command will return false or an error. If the transient is regenerated, it will contain the new post. You can also check the front end. The menu should update immediately.
You can also use Query Monitor to inspect the transient. Query Monitor shows all transients that are set and deleted during a request. Look for wp_get_nav_menu_items in the Transients panel. If the transient is deleted on post save, it will appear in the list.
Another verification method is to use the REST API. Fetch the menu items via the REST API and compare them to the front-end menu. If they match, the transient is fresh. If they differ, the transient is stale.
What About Caching Plugins?
If you use a caching plugin like WP Rocket or W3 Total Cache, the plugin may cache the entire page. The menu transient is only one layer of caching. You need to clear the page cache when the menu changes. Most caching plugins clear the page cache on post save, but they do not clear the menu transient. The snippet above handles the menu transient. The page cache is handled by the plugin.
If you use a CDN, you need to purge the CDN cache. The CDN does not know about the menu transient. It caches the HTML output. You need to purge the CDN cache when the menu changes. This is usually done via the caching plugin’s CDN integration.
FAQ
Why does the menu cache last for 24 hours?
The transient expiration is set to DAY_IN_SECONDS in wp_get_nav_menu_items(). This is a core decision to reduce database queries. It assumes that menu changes are infrequent. For sites that publish frequently, this assumption fails.
Can I disable the menu transient entirely?
Yes. You can use the pre_wp_get_nav_menu_items filter to bypass the transient and query the database directly. This is not recommended for high-traffic sites because it increases database load. For low-traffic sites, it is a viable workaround.
add_filter( 'pre_wp_get_nav_menu_items', function( $items, $menu, $args ) {
return wp_get_nav_menu_items_from_db( $menu, $args );
}, 10, 3 );
This filter is not documented in the Codex. It is used internally by wp_get_nav_menu_items(). Use it with caution.
Does the block editor invalidate the menu transient?
No. The block editor uses the REST API to update menu items. The REST API does not delete the wp_get_nav_menu_items transient. The front end remains stale until the transient expires or is deleted manually.
What about the wp_nav_menu cache in the object cache?
The wp_nav_menu function does not cache its output in the object cache. It caches the menu items via the transient. The output is generated on every request. If you use a page cache, the output is cached at the page level. The transient is the only core-level cache for menu items.
How do I find the term_taxonomy_id of a menu?
Use WP-CLI:
wp term list nav_menu --fields=term_id,term_taxonomy_id,name
This lists all menus with their term_id and term_taxonomy_id. The term_taxonomy_id is the one used in the transient key.
Can I use a cron job to clear the menu transient?
Yes. You can schedule a WP-Cron event to delete the menu transients every hour. This is a blunt instrument. It does not invalidate selectively. It deletes all menu transients, which increases database load. The selective approach is better.
Next Steps
This article is part of a series on failure-mode-first WordPress systems engineering. The next article in this series will trace the wp_options autoload mechanism and explain why your alloptions cache is bloated. If you maintain a production install and debug core behavior instead of installing around it, that article is for you.
For a related failure mode, see What to Fix First When a New WordPress Site Says Nothing Found. That article covers the WP_Query and wp_posts table interactions that cause empty archives.
If you want to go deeper into the wp_get_nav_menu_items() function, the WordPress developer reference is the canonical source. The core ticket #29408 tracks the invalidation issue.











