Speeding up the Appearance > Menus Screen in WordPress 3.6 / 3.7 / 3.8 / 3.9 / 4.0 / 4.1

Quick Synopsis

WordPress 3.6 introduced new accessibility functionality in the Appearance > Menus admin screen. This extra javascript processing creates inefficiencies when handling menus with large numbers of menu items, resulting in very slow and sometimes unresponsive interactions when managing menus in WordPress. By deferring this processing using “lazy” techniques, we can eliminate the bottleneck and have the menu management system run much faster for large numbers of menu items. I have provided a plugin which does just that.

Skip to Video Skip to plugin

Update April 2015

The patch submitted has finally been incorporated into the core in WordPress 4.2! See: core trac ticket. The Faster Appearance – Menus plugin should no longer be necessary with this update.

Update October 25, 2013

I have submitted this plugin/solution as a core trac ticket, and the patch is currently awaiting review to be included in a future version of WordPress core. If you’d like to test, head over to the track ticket and report your feedback 🙂

Background

As the author of several WordPress menu plugins which handle large numbers of menu items, I’ve recently been fielding a lot of support requests from concerned customers who have experienced a dramatic slow-down in their Appearance > Menus admin screen since they have upgraded to WordPress 3.6. For one user who was using about 300 menu items (note: definitely not recommended in any event), the Appearance > Menus page completely freezes and crashes. As I was worried that my code might be causing the issue, I began to investigate.

Stripping my installation down to just TwentyTwelve running on WordPress 3.6, without any plugins, I was surprise to find that the Appearance > Menus page “freezes” for about 5 seconds upon loading with a menu of about 100 menu items. That means no scrolling, no clicking – the average user might even think that the browser has crashed, as the only indication that anything is going on is that the progress spinner continues to spin indicating that the page has not completely loaded.

Testing with a much smaller menu, say 5 menu items, the page loads immediately.

When profiling the javascript, it became apparent that the majority of the processing was occurring in wp-admin/js/nav-menu.js (or the minified equivalent). Digging deeper into this file, I found that the bottleneck occurs with the refreshAdvancedAccessibility function. This function is responsible for adding buttons that allow the menu items’ positions to be moved without having to drag and drop.

Searching the issue in trac, I came across this ticket: Give the menus page an accessibility mode option, like the widgets screen, which confirms that this code was added in WordPress 3.6. The core team has done a nice job adding some useful accessibility features to the Appearance > Menus screen, which is great. See the relevant commit.

The code looks like this:

		refreshAdvancedAccessibility : function() {

			// Hide all links by default
			$( '.menu-item-settings .field-move a' ).hide();

			$( '.item-edit' ).each( function() {
				var $this = $(this),
					movement = [],
					availableMovement = '',
					menuItem = $this.parents( 'li.menu-item' ).first(),
					depth = menuItem.menuItemDepth(),
					isPrimaryMenuItem = ( 0 === depth ),
					itemName = $this.parents( '.menu-item-handle' ).find( '.menu-item-title' ).text(),
					position = parseInt( menuItem.index() ),
					prevItemDepth = ( isPrimaryMenuItem ) ? depth : parseInt( depth - 1 ),
					prevItemNameLeft = menuItem.prevAll('.menu-item-depth-' + prevItemDepth).first().find( '.menu-item-title' ).text(),
					prevItemNameRight = menuItem.prevAll('.menu-item-depth-' + depth).first().find( '.menu-item-title' ).text(),
					totalMenuItems = $('#menu-to-edit li').length,
					hasSameDepthSibling = menuItem.nextAll( '.menu-item-depth-' + depth ).length;

				// Where can they move this menu item?
				if ( 0 !== position ) {
					var thisLink = menuItem.find( '.menus-move-up' );
					thisLink.prop( 'title', menus.moveUp ).show();
				}

				if ( 0 !== position && isPrimaryMenuItem ) {
					var thisLink = menuItem.find( '.menus-move-top' );
					thisLink.prop( 'title', menus.moveToTop ).show();
				}

				if ( position + 1 !== totalMenuItems && 0 !== position ) {
					var thisLink = menuItem.find( '.menus-move-down' );
					thisLink.prop( 'title', menus.moveDown ).show();
				}

				if ( 0 === position && 0 !== hasSameDepthSibling ) {
					var thisLink = menuItem.find( '.menus-move-down' );
					thisLink.prop( 'title', menus.moveDown ).show();
				}

				if ( ! isPrimaryMenuItem ) {
					var thisLink = menuItem.find( '.menus-move-left' ),
						thisLinkText = menus.outFrom.replace( '%s', prevItemNameLeft );
					thisLink.prop( 'title', menus.moveOutFrom.replace( '%s', prevItemNameLeft ) ).html( thisLinkText ).show();
				}

				if ( 0 !== position ) {
					if ( menuItem.find( '.menu-item-data-parent-id' ).val() !== menuItem.prev().find( '.menu-item-data-db-id' ).val() ) {
						var thisLink = menuItem.find( '.menus-move-right' ),
							thisLinkText = menus.under.replace( '%s', prevItemNameRight );
						thisLink.prop( 'title', menus.moveUnder.replace( '%s', prevItemNameRight ) ).html( thisLinkText ).show();
					}
				}

				if ( isPrimaryMenuItem ) {
					var primaryItems = $( '.menu-item-depth-0' ),
						itemPosition = primaryItems.index( menuItem ) + 1,
						totalMenuItems = primaryItems.length,

						// String together help text for primary menu items
						title = menus.menuFocus.replace( '%1$s', itemName ).replace( '%2$d', itemPosition ).replace( '%3$d', totalMenuItems );
				} else {
					var parentItem = menuItem.prevAll( '.menu-item-depth-' + parseInt( depth - 1 ) ).first(),
						parentItemId = parentItem.find( '.menu-item-data-db-id' ).val(),
						parentItemName = parentItem.find( '.menu-item-title' ).text(),
						subItems = $( '.menu-item .menu-item-data-parent-id[value="' + parentItemId + '"]' ),
						itemPosition = $( subItems.parents('.menu-item').get().reverse() ).index( menuItem ) + 1;

						// String together help text for sub menu items
						title = menus.subMenuFocus.replace( '%1$s', itemName ).replace( '%2$d', itemPosition ).replace( '%3$s', parentItemName );
				}

				$this.prop('title', title).html( title );
			});
		},

It uses jQuery’s .each() function to loop through every menu item and add in accessibility functionality. This is some relatively intensive processing, but for only a few menu items it’s not a big deal.

Unfortunately, for menus with a large number of menu items, this code creates a significant bottleneck as the amount of processing compounds with each menu item. Every item in the menu needs to be processed at page load, as well as when the menu item order changes. As a result, we get long pauses while the browser executes this javascript over and over. This creates user experience issues as the browser seems to seize up every time a change is made.

Demonstration

Here is a video demonstration of the issue. Best viewed in 720p.

A Potential Solution

The main problem here is that when all the items must be processed at once, this creates a severe bottleneck when working with large numbers of menu items. Processing the items one at a time, only when necessary, would alleviate this issue.

Before this accessibility functionality can be used, the items will need to be either hovered, focused, or touched. Therefore we can defer processing until those events occur on a per-item basis. Here’s what I’ve done:

First, we move all of the processing to a function called refreshAdvancedAccessibilityLazy. This function will be passed an individual menu item for processing, and will only be processed if it hasn’t already been. The rest of the processing is identical, however, we’ll bind this function to certain events as a callback, rather than looping over everything at once.

		/* This function takes all code from refreshAdvancedAccessibility() and executes it only on a single 
			menu item, to be used as a callback */
		refreshAdvancedAccessibilityLazy : function( $itemEdit ){

			//don't reprocess
			if( $itemEdit.data( 'accessibility_refreshed' ) ) return;

			//mark as processed
			$itemEdit.data( 'accessibility_refreshed' , true );

			//do the accessibility processing
			var $this = $itemEdit,
				movement = [],
				availableMovement = '',
				menuItem = $this.parents( 'li.menu-item' ).first(),
				depth = menuItem.menuItemDepth(),
				isPrimaryMenuItem = ( 0 === depth ),
				itemName = $this.parents( '.menu-item-handle' ).find( '.menu-item-title' ).text(),
				position = parseInt( menuItem.index() ),
				prevItemDepth = ( isPrimaryMenuItem ) ? depth : parseInt( depth - 1 ),
				prevItemNameLeft = menuItem.prevAll('.menu-item-depth-' + prevItemDepth).first().find( '.menu-item-title' ).text(),
				prevItemNameRight = menuItem.prevAll('.menu-item-depth-' + depth).first().find( '.menu-item-title' ).text(),
				totalMenuItems = $('#menu-to-edit li').length,
				hasSameDepthSibling = menuItem.nextAll( '.menu-item-depth-' + depth ).length;

			// Where can they move this menu item?
			if ( 0 !== position ) {
				var thisLink = menuItem.find( '.menus-move-up' );
				thisLink.prop( 'title', menus.moveUp ).show();
			}

			if ( 0 !== position && isPrimaryMenuItem ) {
				var thisLink = menuItem.find( '.menus-move-top' );
				thisLink.prop( 'title', menus.moveToTop ).show();
			}

			if ( position + 1 !== totalMenuItems && 0 !== position ) {
				var thisLink = menuItem.find( '.menus-move-down' );
				thisLink.prop( 'title', menus.moveDown ).show();
			}

			if ( 0 === position && 0 !== hasSameDepthSibling ) {
				var thisLink = menuItem.find( '.menus-move-down' );
				thisLink.prop( 'title', menus.moveDown ).show();
			}

			if ( ! isPrimaryMenuItem ) {
				var thisLink = menuItem.find( '.menus-move-left' ),
					thisLinkText = menus.outFrom.replace( '%s', prevItemNameLeft );
				thisLink.prop( 'title', menus.moveOutFrom.replace( '%s', prevItemNameLeft ) ).html( thisLinkText ).show();
			}

			if ( 0 !== position ) {
				if ( menuItem.find( '.menu-item-data-parent-id' ).val() !== menuItem.prev().find( '.menu-item-data-db-id' ).val() ) {
					var thisLink = menuItem.find( '.menus-move-right' ),
						thisLinkText = menus.under.replace( '%s', prevItemNameRight );
					thisLink.prop( 'title', menus.moveUnder.replace( '%s', prevItemNameRight ) ).html( thisLinkText ).show();
				}
			}

			if ( isPrimaryMenuItem ) {
				var primaryItems = $( '.menu-item-depth-0' ),
					itemPosition = primaryItems.index( menuItem ) + 1,
					totalMenuItems = primaryItems.length,

					// String together help text for primary menu items
					title = menus.menuFocus.replace( '%1$s', itemName ).replace( '%2$d', itemPosition ).replace( '%3$d', totalMenuItems );
			} else {
				var parentItem = menuItem.prevAll( '.menu-item-depth-' + parseInt( depth - 1 ) ).first(),
					parentItemId = parentItem.find( '.menu-item-data-db-id' ).val(),
					parentItemName = parentItem.find( '.menu-item-title' ).text(),
					subItems = $( '.menu-item .menu-item-data-parent-id[value="' + parentItemId + '"]' ),
					itemPosition = $( subItems.parents('.menu-item').get().reverse() ).index( menuItem ) + 1;

					// String together help text for sub menu items
					title = menus.subMenuFocus.replace( '%1$s', itemName ).replace( '%2$d', itemPosition ).replace( '%3$s', parentItemName );
			}

			$this.prop('title', title).html( title );

			//console.log( 'refresh ' + title );
		},

Next, we’ll strip everything from the original function. Rather than reprocessing everything every time something changes, we’ll simply reset all states to unprocessed, and then they will be reprocessed next time those items are interacted with

		/* Functionality stripped from this function and deferred to callback 
			function refreshAdvancedAccessibilityLazy() */
		refreshAdvancedAccessibility : function() {

			// Hide all links by default
			$( '.menu-item-settings .field-move a' ).hide();

			//Mark all items as unprocessed
			$( '.item-edit' ).data( 'accessibility_refreshed' , false );

			return;
		},

Finally, we’ll rewrite the accessibility initialization function to trigger our deferred processing on hover/focus/touch, and use on() with event delegation in order to ensure newly added items also work.

		initAccessibility : function() {
			api.refreshKeyboardAccessibility();
			api.refreshAdvancedAccessibility();

			//Setup the refresh on hover/focus/touch event
			$( '#menu-management' ).on( 'mouseenter.refreshAccessibility focus.refreshAccessibility touchstart.refreshAccessibility' , '.menu-item' , function(){
				api.refreshAdvancedAccessibilityLazy( $( this ).find( '.item-edit' ) );
			});

			//Modified events to use on() with event delegation so that newly added menu items work as well
			
			// Events
			$( '#menu-management' ).on( 'click', '.menus-move-up' , function ( e ) {
				api.moveMenuItem( $( this ).parents( 'li.menu-item' ).find( 'a.item-edit' ), 'up' );
				e.preventDefault();
			});
			$( '#menu-management' ).on( 'click', '.menus-move-down', function ( e ) {
				api.moveMenuItem( $( this ).parents( 'li.menu-item' ).find( 'a.item-edit' ), 'down' );
				e.preventDefault();
			});
			$( '#menu-management' ).on( 'click', '.menus-move-top', function ( e ) {
				api.moveMenuItem( $( this ).parents( 'li.menu-item' ).find( 'a.item-edit' ), 'top' );
				e.preventDefault();
			});
			$( '#menu-management' ).on( 'click', '.menus-move-left' , function ( e ) {
				api.moveMenuItem( $( this ).parents( 'li.menu-item' ).find( 'a.item-edit' ), 'left' );
				e.preventDefault();
			});
			$( '#menu-management' ).on( 'click', '.menus-move-right', function ( e ) {
				api.moveMenuItem( $( this ).parents( 'li.menu-item' ).find( 'a.item-edit' ), 'right' );
				e.preventDefault();
			});

		},

The Plugin

In order to make this new code work as a plugin rather than edit the Core, I’ve deregistered the standard nav-menu.js and re-registered this newly edited version from the plugin. Activating the plugin will immediately switch to the revised script. The code and plugin are available for download on GitHub

View Plugin on GitHub Download from WordPress Plugin Repository

36 thoughts on “Speeding up the Appearance > Menus Screen in WordPress 3.6 / 3.7 / 3.8 / 3.9 / 4.0 / 4.1

    • Hi Zedd,

      I only see about 11 menu items on your site, so you don’t need this plugin – it’s only for menus with many menu items. With only 11, you shouldn’t see any noticeable slowness with or without the plugin installed.

      If you have a slow admin panel with only 11 menu items, it would indicate that you’ve got something else going on with your installation that is slowing things down.

      You should never modify the core, and this plugin does not require or suggest that.

      Hope that helps,

      Chris

      • Dear Chris,

        Thank you for your quick answer. I really don’t understand why the admin panel is so slow. The website is quite optimized (88 % GT Metrix). With any internet browser the website is very fast (opera, FFox, Safari, Dolphin) but with Chrome it is incredebly slow. I was hoping an improvement with your plug in.

        I have to read more and more things, I guess.

        Thank you once again.

  1. Pingback: Speeding up Menus UI for WordPress | Muhammad Haris

  2. I have the same issue Ubermenu is slowing down the whole admin area Tried the plugin but no luck.

    everything is up to date
    WP 3.7.1
    ubermenu 2.3.2.2

    if I deactivate the site is fine

    cheers
    Steve

    • Hi Steve,

      If you’ve got more than just a slow Menus Panel, that’s unrelated to this issue. Most likely you’re getting a CURL timeout on your server, so try disabling the Update Alerts in your UberMenu Control Panel. If you have any further questions, please post them in the Support Forum – thanks!

      Chris

  3. Pingback: Navigation menu hangs when added too many pages? Here’s a SOLUTION! | honeyscoop

  4. You are a lifesaver Chris. I am working on a site with a large number of ‘mega menus’ and the backend ‘Appearance – Menus’ was slowing to the point of being unuseable.

    ***** 5 STARS *****

  5. I have a menu with over 90 pages listed and have tried all sorts of ‘hacks’ and changes, but nothing works.

    As it is, the menu [when trying to save] either ends up timing out or taking me to an internal server error.

    I simply cannot make any additions to the current menu.

    I thought your plugin might help, but sadly, there is no change.

    Do you have any ideas as to what else I could try?

    Thank you if you can help.

    • Hi James,

      This plugin deals with the slowness in the UI in the Menus panel. It is a client-side only issue, and has no effect on the server-side of things. If you’re experiencing a timeout or internal server error, it means you’re having a server issue. I’d recommend that you enable debugging on your site so you can determine what issue is occurring server-side and then tackle it from there.

      Good luck!

      Chris

  6. Hello, I am thankful I found your page! I spent all morning setting up a complex mega menu, It was very slow and tested my patience at every step. a couple hours later I saved, and ran into a menu limit problem which I am going to address and fix in PHP. What that means is now I will have to rebuild half my menu, which is going to drive me crazy sense the menu page is so darn slow. I really hope your plugin still works for WordPress 4.1. Please continue to support this plugin so that it works with the latest version. It literally can save the sanity of people. Thank you again!

Leave a Reply

Your email address will not be published. Required fields are marked *