Handling AJAX Requests in WordPress: WP REST API vs admin-ajax.php vs Must-Use Plugin

The WordPress REST API was merged into WordPress core in version 4.7. Before that, developers relied on the default AJAX implementation, otherwise known as admin-ajax after the /wp-admin/admin-ajax.php file that processes AJAX requests in WordPress.

Since the introduction of the WordPress REST API, many plugin developers have started converting their plugins to use the REST API instead of AJAX. Aside from being newer technology, the REST API is considered a better option because less of WordPress core is loaded during a typical REST request, and a REST response is always in a predictable format based on its schema. However, is it faster than an AJAX request? Is there another option if the raw performance of your asynchronous requests is critical?

In this article, we’ll compare the pros and cons of each approach. We’ll also run some benchmarks to see which is faster, as well as look at how you can speed up your request processing.

There’s Life in the Old Dog Yet: AJAX

AJAX support in WordPress, in the form of the admin-ajax.php file, was added in 2006 as a way to process a few admin UI functions without annoying page reloads. It was also heavily used by WordPress plugin and theme developers as a way to make asynchronous requests in a WordPress site. When a typical AJAX request to admin-ajax.php is made, it loads a few other core WordPress files to make sure the core functions are loaded.

  • /wp-load.php
  • /wp-config.php
  • /wp-settings.php (loads most core files, all active plugins and themes, and the REST API)
  • /wp-admin/includes/admin.php
  • /wp-admin/includes/ajax-actions.php

After loading these files, WordPress calls the admin_init hook, which several core functions hook into. The below core functions are registered on this hook in WordPress 6.3:

  • handle_legacy_widget_preview_iframe
  • wp_admin_headers
  • default_password_nag_handler
  • WP_Privacy_Policy_Content::text_change_check
  • WP_Privacy_Policy_Content::add_suggested_content
  • register_setting
  • add_privacy_policy_content
  • send_frame_options_header
  • register_admin_color_schemes
  • _wp_check_for_scheduled_split_terms
  • _wp_check_for_scheduled_update_comment_type
  • _wp_admin_bar_init
  • wp_schedule_update_network_counts
  • _maybe_update_core
  • _maybe_update_plugins
  • _maybe_update_themes

After these functions have been called, WordPress finally calls the AJAX action provided in the $_GET[‘action’] or $_POST[‘action’] variable. This triggers a callback function, retrieves the relevant WordPress data, and serves it in the response.

The New Kid on the Block: WP REST API

While relatively new to the WordPress development ecosystem, the WordPress REST API can be used for a number of implementations other than asynchronous requests, from static site generators to headless single-page apps.

A typical REST API request looks slightly different from a request to admin-ajax.php. Since the REST endpoints are handled by the WordPress Rewrite API, the request is passed to /index.php, which then loads the rest of WordPress normally.

  • /index.php
  • /wp-blog-header.php
  • /wp-load.php
  • /wp-config.php
  • /wp-settings.php (loads most core files, all active plugins and themes, and the REST API)

Unlike requests sent over admin-ajax.php, the REST API doesn’t load the WordPress admin section via /wp-admin/includes/admin.php, nor does it fire the admin_init action hook. Based on that, it would seem that any plugins or themes that don’t rely on admin-specific functionality—but are making asynchronous requests using admin-ajax.php—should see a slight performance boost by switching over to the REST API.

REST API Adoption in the WordPress Ecosystem

Since the inclusion of the REST API in WordPress, it has been largely adopted by WordPress Core, as well as theme and plugin developers.

The most popular usage of the REST API has been in the development of the WordPress Block Editor. Building custom blocks for the editor relies heavily on using the REST API through the api-fetch package.

The REST API and the api-fetch package also open up WordPress to a wider variety of use cases. Developers who work with popular frontend JavaScript frameworks can build WordPress themes or great looking plugin interfaces without needing to rely on PHP for templating. Not only that, but it gives WordPress the ability to power single-page applications and mobile apps, something that was not easily possible before.

Finally, it gives WordPress plugin developers a standardized way to work with custom plugin data. For example, WooCommerce has fully integrated with the WordPress REST API. This allows WooCommerce data like products and orders to be created, read, updated, and deleted using the WP REST API request formats and authentication methods. Before this integration, developers would have had to build their own custom endpoints to access this data externally.

The Dark Horse: Must Use Plugin

As with most cases in software development, there are multiple ways to solve a problem. While admin-ajax.php and the REST API are solid choices, building a custom endpoint also has its own advantages. The biggest of these is performance.

There are a few different ways to set up custom endpoints in a WordPress website. The fastest possible endpoint would likely be completely independent of WordPress. It could be as simple as a standalone PHP file that is uploaded to the web server and processes a specific GET request. While fast, it probably wouldn’t be all that helpful, because it won’t have access to any WordPress core functions. Access to these functions often saves a great deal of development time.

A better approach would be to create a custom endpoint in a must-use plugin to process custom asynchronous HTTP requests. That way it can respond to any custom requests after WordPress core loads, but before the plugins and themes are loaded. This would both provide the best performance and prevent conflicts from other plugins that are using the REST API or admin-ajax.php.

Below is a simple example of a must-use plugin that expects a GET or POST variable hfm-ajax, and outputs the value of time() in the response.

<?php
/**
 * Plugin Name: Hellfish Media Custom AJAX
 * Description: Custom Request Handler
 * Author: Hellfish Media
 * Version: 1.0
 * Author URI: https://hellfish.media
 */

if ( ! isset( $_GET['hfm-ajax'] ) ) {
    return;
}

// Define the WordPress "DOING_AJAX" constant.
if ( ! defined( 'DOING_AJAX' ) ) {
    define( 'DOING_AJAX', true );
}

wp_send_json(
    array(
     'time'  => time()
    )
);

If I installed this on the https://hellfish.media site, it could be accessed at https://hellfish.media/?hfm-ajax=1.

Creating the Benchmarks

The must-use plugin functionality is perfect for our benchmark test, so all we need to do is replicate its functionality for admin-ajax.php and the REST API. First, we’ll create the benchmark function, which returns the time value in the response. This makes it easier to see that the requests aren’t being cached.

function hfm_benchmark_request() {
    wp_send_json(
     array(
         'time'  => time()
     )
    );
}

To register this function for admin-ajax.php, we need to hook the function into a wp_ajax_nopriv_{$action} hook.

add_action( 'wp_ajax_nopriv_benchmark_request', 'hfm_benchmark_request' );

We’re using a wp_ajax_nopriv_{$action} hook so that we don’t need to be logged in to test the admin-ajax.php request.

To register this function for the REST API, we hook it into the rest_api_init hook and set up a custom REST API route.

add_action( 'rest_api_init',  'hfm_register_custom_rest_route');

function hfm_register_custom_rest_route() {
    register_rest_route( 'benchmark/v1', '/benchmark', array(
     'methods'          => GET,
     'callback'         => 'hfm_benchmark_rest_request',
     'permission_callback' => '__return_true',
    ) );
}

function hfm_benchmark_rest_request() {
    return array( 'time' => time() );
}

We can put all this code in a custom plugin or the functions.php of a theme or child theme. For the purposes of the benchmark, I’m going to be working on a 1GB/1vCPU Digital Ocean droplet running WordPress 5.9, with no additional plugins, a default theme, and page caching disabled.

Running the Numbers

To perform the actual benchmarking, we’re going to use ApacheBench, a command-line benchmarking tool that allows you to fire off multiple requests at once to get a feel for how the server is performing. To make sure I was testing valid requests, I also verified the output of each URL tested in Postman.

I’ll create an apachebench directory to run the tests and save the output to a file.

mkdir ~/apachebench
cd apachebench

Let’s test the admin-ajax.php version first. In the case of the AJAX request, we pass a query string including an action variable with a value of benchmark_request, which matched the $action value of the wp_ajax_nopriv_ hook defined in the test code.

ab -n 100 -c 1 -g ajax.tsv \
https://dev.hellfish.media/wp-admin/admin-ajax.php?action=benchmark_request

The above command sends 100 GET requests to the /wp-admin/admin-ajax.php file and logs the response times.

➜  ~ ab -n 100 -c 1 -g ajax.tsv \
https://dev.hellfish.media/wp-admin/admin-ajax.php\?action\=benchmark_request
This is ApacheBench, Version 2.3 <$Revision: 1903618 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking dev.hellfish.media (be patient).....done


Server Software:        nginx/1.25.2
Server Hostname:        dev.hellfish.media
Server Port:            443
SSL/TLS Protocol:       TLSv1.2,ECDHE-RSA-CHACHA20-POLY1305,2048,256
Server Temp Key:        ECDH X25519 253 bits
TLS Server Name:        dev.hellfish.media

Document Path:          /wp-admin/admin-ajax.php?action=benchmark_request
Document Length:        19 bytes

Concurrency Level:      1
Time taken for tests:   7.028 seconds
Complete requests:      100
Failed requests:        0
Total transferred:      43900 bytes
HTML transferred:       1900 bytes
Requests per second:    14.23 [#/sec] (mean)
Time per request:       70.280 [ms] (mean)
Time per request:       70.280 [ms] (mean, across all concurrent requests)
Transfer rate:          6.10 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        6   11  16.8     10     177
Processing:    56   59   5.8     58     114
Waiting:       56   59   5.8     58     114
Total:         64   70  22.4     68     291

Percentage of the requests served within a certain time (ms)
  50%     68
  66%     69
  75%     69
  80%     69
  90%     70
  95%     71
  98%     73
  99%    291
 100%    291 (longest request)

With 100 requests, the average response time was 72.28ms. This gives a good baseline for the same test over the REST API, so let’s test that next.

ab -n 100 -c 1 -g rest.tsv \
https://dev.hellfish.media/wp-json/benchmark/v1/benchmark
➜  ~ ab -n 100 -c 1 -g rest.tsv \
https://dev.hellfish.media/wp-json/benchmark/v1/benchmark
This is ApacheBench, Version 2.3 <$Revision: 1903618 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking dev.hellfish.media (be patient).....done


Server Software:        nginx/1.25.2
Server Hostname:        dev.hellfish.media
Server Port:            443
SSL/TLS Protocol:       TLSv1.2,ECDHE-RSA-CHACHA20-POLY1305,2048,256
Server Temp Key:        ECDH X25519 253 bits
TLS Server Name:        dev.hellfish.media

Document Path:          /wp-json/benchmark/v1/benchmark
Document Length:        19 bytes

Concurrency Level:      1
Time taken for tests:   6.085 seconds
Complete requests:      100
Failed requests:        0
Total transferred:      53100 bytes
HTML transferred:       1900 bytes
Requests per second:    16.43 [#/sec] (mean)
Time per request:       60.851 [ms] (mean)
Time per request:       60.851 [ms] (mean, across all concurrent requests)
Transfer rate:          8.52 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        6   10   5.2     10      60
Processing:    47   51   6.1     49     106
Waiting:       47   50   6.1     49     105
Total:         54   61  11.0     59     166

Percentage of the requests served within a certain time (ms)
  50%     59
  66%     60
  75%     61
  80%     61
  90%     63
  95%     66
  98%     71
  99%    166
 100%    166 (longest request)

Not surprisingly, the REST API is slightly faster in this comparison, with an average response time of 60.85ms over 100 requests. In these tests, the REST API is about 17.3% faster than the AJAX API.

But what about the must-use plugin?

ab -n 100 -c 1 -g custom-ajax.tsv https://dev.hellfish.media/?hfm-ajax=1
hellfishdev@dev:~/apachebench$ ab -n 100 -c 1 -g custom-ajax.tsv \
> https://dev.hellfish.media/?hfm-ajax=1
This is ApacheBench, Version 2.3 <$Revision: 1843412 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking dev.hellfish.media (be patient).....done


Server Software:        nginx
Server Hostname:        dev.hellfish.media
Server Port:            443
SSL/TLS Protocol:       TLSv1.2,ECDHE-RSA-CHACHA20-POLY1305,2048,256
Server Temp Key:        X25519 253 bits
TLS Server Name:        dev.hellfish.media

Document Path:          /?hfm-ajax=1
Document Length:        19 bytes

Concurrency Level:      1
Time taken for tests:   0.560 seconds
Complete requests:      100
Failed requests:        0
Total transferred:      43100 bytes
HTML transferred:       1900 bytes
Requests per second:    178.52 [#/sec] (mean)
Time per request:       5.602 [ms] (mean)
Time per request:       5.602 [ms] (mean, across all concurrent requests)
Transfer rate:          75.14 [Kbytes/sec] received

Connection Times (ms)
            min  mean[+/-sd] median   max
Connect:        2   2   0.2     2       3
Processing:     3   4   0.3     4       5
Waiting:        3   4   0.3     3       5
Total:          5   6   0.4     5       7

Percentage of the requests served within a certain time (ms)
  50%   5
  66%   6
  75%   6
  80%   6
  90%   6
  95%   6
  98%   7
  99%   7
 100%   7 (longest request)

As expected, this is faster than both the AJAX and REST APIs. The average time for the custom request handler is 5.49ms, which is blisteringly fast compared to the other options.

To validate this data, I decided to run the same benchmarks with some content, a few plugins, and a custom theme activated. I used WP Migrate to make a copy of our https://hellfish.media demo site, which has some demo content, a custom theme, and a bunch of active plugins, including:

  • Advanced Custom Fields PRO
  • Advanced Forms
  • WP DB Backup
  • EWWW Image Optimizer
  • Simple Custom Post Order
  • WordPress Importer
  • WP Migrate
  • WP Offload Media Lite
  • WP Pusher

The average request times are listed below:

  • admin-ajax: 92.4ms
  • REST API: 89.47ms
  • custom request handler: 6.57ms

The first thing you notice is the overall increase in both the admin-ajax.php and REST API request times compared to the must-use plugin. Both admin-ajax.php and REST API requests are much slower, but the must-use plugin is just 1ms slower. The second thing you’ll notice is that the gap in performance between the REST API and admin-ajax.php has been reduced to just a few milliseconds.

A website with a different set of plugins, or a different theme, could see a different set of results, but that depends completely on the theme and plugins running on the site, and how they are coded.

How Plugins Affect Asynchronous Request Performance

Every time an admin-ajax.php or REST API request is served by WordPress, most of WordPress core, the active theme, and all the active plugins are loaded. We experienced this firsthand when we developed WP Migrate. In early versions, plugins conflicting with migrations made up the majority of our support requests. Some plugins came up so often that we started publishing a list of plugins known to conflict with WP Migrate.

This was not an ideal solution, so we developed Compatibility Mode.

WP Migrate Compatibility Mode.

Compatibility Mode allows the user to select which plugins to load for requests made by WP Migrate. By default, all plugins are prevented from loading. This greatly reduces the chance of another plugin disrupting the migration process.

Compatibility Mode installs a must-use plugin that hooks into the option_active_plugins filter whenever get_option('active_plugins') is called, and executes a function which may modify which plugins are active.

You can make use of the same principle to prevent plugins from loading when running asynchronous requests. Below is an example of a must-use plugin that could be used to exclude specific plugins from an AJAX request.

/**
 * Plugin Name: Hellfish Media Exclude plugins
 * Description: Exclude plugins from ajax requests
 * Author: Hellfish Media
 * Version: 1.0
 * Author URI: https://hellfish.media
 */

/**
 * Plugin Name: Hellfish Media Exclude plugins
 * Description: Exclude plugins from ajax requests
 * Author: Hellfish Media
 * Version: 1.0
 * Author URI: https://hellfish.media
 */

add_filter( 'option_active_plugins', 'hfm_exclude_plugins' );

function hfm_exclude_plugins( $plugins ) {
    /**
     * If we're not performing our AJAX request, return early.
     */
    if ( ! defined( 'DOING_AJAX' ) || ! DOING_AJAX || ! isset( $_REQUEST['action'] ) || 'benchmark_request' !== $_REQUEST['action'] ) {
     return $plugins;
    }
    /**
     * The list of plugins to exclude. Flip the array to make the check that happens later possible
     */
    $denylist_plugins = array_flip(
     array(
         'advanced-custom-fields-pro/acf.php',
         'advanced-forms/advanced-forms.php',
         'classic-editor/classic-editor.php',
         'wp-db-backup/wp-db-backup.php',
         'ewww-image-optimizer/ewww-image-optimizer.php',
         'limit-login-attempts-reloaded/limit-login-attempts-reloaded.php',
         'simple-custom-post-order/simple-custom-post-order.php',
         'theme-switcha/theme-switcha.php',
         'woocommerce/woocommerce.php',
         'wordpress-importer/wordpress-importer.php',
         'wp-mail-log/wp-mail-log.php',
         'amazon-s3-and-cloudfront/wordpress-s3.php',
         'wp-offload-ses/wp-offload-ses.php',
         'wppusher/wppusher.php'
     )
    );
    /**
     * Loop through the active plugins, if it's not in the deny list, allow the plugin to be loaded
     * Otherwise, remove it from the list of plugins to load
     */
    foreach ( $plugins as $key => $plugin ) {
     if ( ! isset( $denylist_plugins[ $plugin ] ) ) {
         continue;
     }
     unset( $plugins[ $key ] );
    }

    return $plugins;
}

You have two options if you want to exclude a list of plugins that are loaded for a REST API request. If the REST API endpoint is a WordPress core endpoint, you can check if the REST_REQUEST constant is defined and set to true. This constant is defined in the rest_api_loaded core function. So instead of the DOING_AJAX checks, you could do the following:

if ( ! defined( 'REST_REQUEST' ) || ! REST_REQUEST ) {
    return $plugins;
}

If it’s a custom endpoint registered using register_rest_route (the same as we used in our benchmarks), it doesn’t seem possible to use this constant. Instead, we can get the REQUEST_URI variable in the PHP $_SERVER predefined constant, and see if it matches our custom endpoint.

$request_uri = parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH );
if ( '/wp-json/benchmark/v1/benchmark/' !== trailingslashit( $request_uri ) ) {
    return $plugins;
}

With those changes implemented, you should see a reduction in response times that correlates with the number of plugins that were prevented from loading.

Should You Use the WordPress REST API?

In addition to a slight performance increase, creating endpoints for other developers to use is less complicated with the REST API. When we added REST API integration for Advanced Custom Fields, we appreciated that we could leverage the REST standards, schema, and authentication methods, and just register ACF field data on the defined REST API endpoints. The REST API is also better documented than using admin-ajax.php, and therefore more straightforward to implement.

While admin-ajax.php and a custom request handler can require more work to get the data you want to return, they also give you more flexibility in how you format that data. And as we pointed out, a custom request handler is the obvious choice if raw performance is what you’re after.

In terms of reliability, the REST API and admin-ajax.php still depend on the quality and integrity of the active plugins or themes. A poorly coded plugin could still easily interfere with REST API or admin-ajax.php requests, and the likelihood of this increases with the number of plugins installed on your site.

Overall, it’s definitely a good idea to at least consider using the REST API. Adding custom API endpoints is relatively straightforward compared to admin-ajax.php, and it doesn’t take much to switch over existing code either.

Have you converted your admin-ajax requests over to the REST API? Did you see performance increase, decrease, or not change at all? Let us know in the comments below.

About the Author

Matt Shaw Senior WordPress Developer

Matt is a WordPress plugin developer located near Philadelphia, PA. He loves to create awesome new tools with PHP, JavaScript, and whatever else he happens to get his hands on.