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.