Speed up the output by 1000x with a WordPress menu cache

Generating the menus in WordPress is quite resource intensive. Sites with few visitors and few menu items might not notice this much. But if you have a large amount of menu items, like in a mega menu, in combination with a lot of visitors the menu generation can be a real hog on your server’s CPUs. Let’s see if we can improve the speed with a little WordPress menu cache trickery.

Theory behind the WordPress menu cache

Thanks to my friend Kaspars Dambis, it has since version 3.9 been possible in WordPress to short-circuit the WordPress menu output generation. Early in the function wp_nav_menu(), where menu output is generated, you can find a filter called pre_wp_nav_menu. If this filter returns anything but null, what it returns will be echoed/returned (depending on the arguments sent to the function, e.g. from your theme). This means that if we can store a cached version of the output somewhere, we can just output that, instead of having to regenerate the menu output on every single request. Menus tend to not change that often, so this looks like something where caching may be very suitable.

Right at the end of wp_nav_menu(), after the entire menu output is generated, is another filter we can use: wp_nav_menu. This filter gets passed roughly the same arguments as the short-circuit filter, allowing us to create a “signature” of the arguments and store the output in a cache entry with that signature as a key. This prevents the short-circuit filter from returning the wrong version of the menu ouput – a menu can appear multiple times on a page, with very different output arguments.

Whenever a menu is saved/updated, the action hook wp_update_nav_menu fires, so we can delete all cached versions of the menu whenever the menu changes. This way, we will never have to wait for the cache to expire or purge it manually.

Testing the WordPress menu cache

To really see the difference on my local development machine, I created 1000 pages, 4 levels deep and added them to a menu (yes, of course I scripted this). Then I used microtime() and added some logging in the above mentioned filters to measure how long the menu generation took. I got consistent results of ~1000 ms. That’s way too much time spent for just generating the menu. Think of all the other things on a page that needs output generation.

After applying the caching functions, the menu generation dropped to ~1 ms. That’s a 1000x speedup, and a very noticeable one too.

Oh, yes: I know that very few people will have 1000 menu items, but it made a cool (?) example (clickbait?). For the sake of making a more realistic example, I deleted the 1000 items large menu, and created a small menu with just 5 main level items with 7 2nd level items each: 40 menu items in total. I got very consistent results of 48 ms generation time on my local development setup. Still, it is a 48x speedup with this small menu. And if you’re aiming to get under the 200 ms generation time where Google will flag you as slow, a 47 ms shave of that may be useful. it will of course save you even more if you’re a busy site with some traffic relative to your server resources.

The WordPress menu cache code

Feel free to use this WordPress menu cache code as you wish, as long as you comply with the GPLv2 license. You may want to rewrite it into a proper plugin, or just dump it into WordPress as an mu-plugin.

If you want to check the timings of the WordPress menu cache yourself, here’s the code I used:

The timing code will break horribly if you don’t have a write_log() implementation. Here’s a really simple one you can use:

How to generate 1000 menu items

I simply used WP-CLI to generate 1000 pages, 4 levels deep:

wp post generate --count=1000 --post_type=page --max_depth=4

Then I quickly wrote a custom script that is horribly inefficient and takes a loooong time to run, to create a menu, loop through all those pages and create menu items with the same hierarchy. It should only be run once (or it will create even more menu items), and will run if you enter a page with the query string generate-menu, e.g. https://example.local/?generate-menu. The menu insertion script is available here.

 

14 Comments

    1. Thank you for your comment Vinny.

      There might be edge cases where a website is dynamically manipulating the menu with a hook based on an – to us – unknown variable, where caching the menu could break that functionality. If though that would be a very rare case, it is still a possibility that someone does that, so I don’t think this should be in core.

      Core provides a way to do things like this, though. If you want to make a plugin out of it and release it on wordpress.org, please go ahead :-)

  1. Hi Bjørn, that code is so awesome! I used that without shame as a mu_plugin – with attribution, of course! At the same time I’m looking into transients in general and one question arose: Is there any particular reason why there’s no lifespan set in your code? If I understand it correctly, this would prevent WordPress from cleaning up expired transients when it usually would (db update).

    1. Thank you for your comment :-)

      You should probably use a value for the transient expiration – even if it is far into the future. I’m usually using a different storage for transients without persistent storage (e.g. Redis), so I didn’t give it much thought. But you are right, yes.

  2. I have tried this out on one of my sites where the menu system is taking load on our servers. For some reason it has increased the loads by 10x. The menu is faster without this script in place. I am rather confused by the results.

    1. That sounds a bit weird, yes. I would love to try some profiling to find the cause.

  3. Hi,
    Thank you for the work you put in this article. I’m using a mega menu (a custom walker) and slow query plugin report it a a slow query. I’m trying to use the above code but i cannot make it work.

    I put 2 prints like in this screencapture http://prntscr.com/kwyg4x , and use debug bar transients for debug. I see that the transient name is menu-cache-40df7aafddea44e278a1520bebff0d8e but when i’m looking trough saved transients i cannot find him. And yes, cache is not used in his case. Here is a screencap with what i see in debug bar http://prntscr.com/kwyjxr

    This is happening for main menu (mega menu with custom walker). For a footer menu (classic menu) – everything works smooth.

    Any idea why this is happening ? Thank you

  4. Hi Bjørn,

    Thanks for the great caching script.

    Just wanted to share our experience. The caching will not work if you provide a custom Walker object to function’s ‘wp_nav_menu’ array of arguments. This is due to the Walker class object’s ‘has_children’ property which initially (and therefore on ‘pre_wp_nav_menu’ filter) is NULL. Later on when walking the tree this value is changed depending on the children existence. If there are no children found the value will be changed to bool false or if found – integer. The caches’ signatures will be different on ‘wp_nav_menu’ filter and on ‘pre_wp_nav_menu’ as the provided walker object was changed during the walk. The cached version is never found on ‘pre_wp_nav_menu’;

    In our case we have solved this by placing the following in ‘wp_nav_menu’ filter to make sure the property is the same on both filters. This is not tested throughly and is not a fix for everyone:
    if ( isset( $args->walker ) ) {
    $args->walker->has_children = NULL;
    }

    This probably is one of the edge cases you mentioned in one of your previous comments and the reason why the caching might not be included in the core currently. There are so many potential changes that can happen during the two filters to my mind.

    At the time of writing this it is WP 5.1 released.

    1. Awesome Martins, I added these lines and it works!
      Thank you too for this great script Bjørn.

Comments are closed.