WordPress REST API Performance at Scale: Caching, Rate Limiting, and Custom Middleware
The WordPress REST API has become the backbone of modern WordPress development. Whether you are building a decoupled frontend with React or Vue, powering a mobile application, or integrating with third-party services, the REST API handles the data layer. But as traffic grows and the number of API consumers multiplies, performance problems surface quickly. Endpoints that returned data in 200 milliseconds during development suddenly take two seconds under real load. Database queries pile up, serialization eats CPU cycles, and the lack of proper caching turns every request into a full round trip through the entire WordPress bootstrap.
This article covers the practical techniques needed to run WordPress REST API endpoints in production at scale. We will build rate limiting from scratch using the rest_pre_dispatch filter, implement response caching at multiple layers, create a custom middleware pattern using WordPress hooks, reduce serialization overhead, and compare authentication strategies. Every code example uses real WordPress functions and hooks that you can drop into a plugin or theme’s functions.php file today.
Implementing Rate Limiting via rest_pre_dispatch
Rate limiting is the first line of defense for any API exposed to the internet. Without it, a single aggressive client can monopolize server resources, degrade performance for legitimate users, and potentially cause a denial-of-service condition. WordPress does not include built-in rate limiting for REST API endpoints, but the rest_pre_dispatch filter provides the perfect hook point to add it.
The rest_pre_dispatch filter fires early in the REST API request lifecycle, before the actual endpoint callback executes. If you return a WP_Error from this filter, WordPress short-circuits the request and returns the error response immediately. This makes it an efficient place to check rate limits because you avoid running expensive endpoint logic for clients that have exceeded their quota.
Basic Transient-Based Rate Limiter
The simplest approach uses WordPress transients to track request counts per IP address. Transients work because they have built-in expiration, and when an object cache like Redis or Memcached is active, they bypass the database entirely.
add_filter( 'rest_pre_dispatch', 'wpkite_rate_limit_rest_api', 10, 3 );
function wpkite_rate_limit_rest_api( $result, $server, $request ) {
// Skip rate limiting for authenticated admin users
if ( current_user_can( 'manage_options' ) ) {
return $result;
}
$ip_address = $_SERVER['REMOTE_ADDR'];
$transient_key = 'rest_rate_' . md5( $ip_address );
$max_requests = 100; // requests per window
$window_seconds = 60; // 1-minute window
$current = get_transient( $transient_key );
if ( false === $current ) {
set_transient( $transient_key, 1, $window_seconds );
return $result;
}
if ( (int) $current >= $max_requests ) {
return new WP_Error(
'rate_limit_exceeded',
'Rate limit exceeded. Try again later.',
array( 'status' => 429 )
);
}
// Increment the counter
set_transient( $transient_key, (int) $current + 1, $window_seconds );
return $result;
}
This approach has a flaw: every call to set_transient() resets the expiration timer. That means a client making steady requests could keep resetting their window and never actually hit the limit correctly. The fix is to use the object cache directly with a non-expiring increment operation.
Object Cache Sliding Window Rate Limiter
When you have Redis or Memcached backing wp_cache_* functions, you can use wp_cache_incr() for atomic increments that do not reset the TTL. Combined with wp_cache_add() (which only sets if the key does not exist), this creates a proper sliding window.
add_filter( 'rest_pre_dispatch', 'wpkite_object_cache_rate_limiter', 10, 3 );
function wpkite_object_cache_rate_limiter( $result, $server, $request ) {
if ( current_user_can( 'manage_options' ) ) {
return $result;
}
$identifier = $_SERVER['REMOTE_ADDR'];
$cache_key = 'rest_rl_' . md5( $identifier );
$cache_group = 'rate_limiting';
$max_requests = 100;
$window = 60;
// wp_cache_add only sets if key doesn't exist, preserving TTL
$added = wp_cache_add( $cache_key, 0, $cache_group, $window );
$count = wp_cache_incr( $cache_key, 1, $cache_group );
if ( false === $count ) {
// Object cache not available, fall back to allowing the request
return $result;
}
// Add rate limit headers to the response
add_filter( 'rest_post_dispatch', function( $response ) use ( $max_requests, $count, $window ) {
$response->header( 'X-RateLimit-Limit', $max_requests );
$response->header( 'X-RateLimit-Remaining', max( 0, $max_requests - $count ) );
$response->header( 'X-RateLimit-Reset', time() + $window );
return $response;
});
if ( $count > $max_requests ) {
$error_response = new WP_Error(
'rate_limit_exceeded',
sprintf( 'Rate limit of %d requests per minute exceeded.', $max_requests ),
array( 'status' => 429 )
);
return $error_response;
}
return $result;
}
The X-RateLimit-* headers follow standard conventions that API consumers expect. The X-RateLimit-Remaining header tells clients how many requests they have left, which lets well-behaved clients throttle themselves before hitting the hard limit.
Route-Specific Rate Limits
Not all endpoints deserve the same rate limit. A search endpoint that runs expensive WP_Query lookups should have a tighter limit than a simple options read. You can inspect the route from the $request object to apply different limits.
function wpkite_get_rate_limit_for_route( $route ) {
$limits = array(
'/wp/v2/search' => array( 'max' => 20, 'window' => 60 ),
'/wp/v2/users' => array( 'max' => 10, 'window' => 60 ),
'/wp/v2/posts' => array( 'max' => 200, 'window' => 60 ),
'/wpkite/v1/tickets' => array( 'max' => 30, 'window' => 60 ),
);
foreach ( $limits as $pattern => $limit ) {
if ( strpos( $route, $pattern ) === 0 ) {
return $limit;
}
}
// Default limit
return array( 'max' => 100, 'window' => 60 );
}
You could also base rate limits on the authenticated user’s role or subscription tier. An API consumer with a paid plan might get 1,000 requests per minute, while anonymous requests get 30. This maps directly to the kind of tiered access patterns common in SaaS platforms built on WordPress.
Response Caching: HTTP Headers, Varnish, and Nginx Microcaching
Caching REST API responses is the single most effective performance optimization available. A cached response avoids the WordPress bootstrap, database queries, serialization, and PHP execution entirely. The key is implementing caching at the right layer for your architecture.
HTTP Cache Headers on REST Responses
WordPress REST API responses do not include cache-friendly HTTP headers by default. The rest_post_dispatch filter lets you add Cache-Control, ETag, and Last-Modified headers to responses so that browsers, CDNs, and reverse proxies can cache them.
add_filter( 'rest_post_dispatch', 'wpkite_add_rest_cache_headers', 10, 3 );
function wpkite_add_rest_cache_headers( $response, $server, $request ) {
$route = $request->get_route();
$method = $request->get_method();
// Only cache GET requests
if ( 'GET' !== $method ) {
$response->header( 'Cache-Control', 'no-store' );
return $response;
}
// Skip caching for authenticated requests with user-specific data
if ( is_user_logged_in() && wpkite_is_user_specific_route( $route ) ) {
$response->header( 'Cache-Control', 'private, no-cache' );
return $response;
}
// Set cache duration based on content type
$max_age = wpkite_get_cache_ttl_for_route( $route );
$response->header(
'Cache-Control',
sprintf( 'public, max-age=%d, s-maxage=%d', $max_age, $max_age * 2 )
);
// Generate ETag from response data
$etag = md5( wp_json_encode( $response->get_data() ) );
$response->header( 'ETag', '"' . $etag . '"' );
// Check If-None-Match for conditional requests
$if_none_match = $request->get_header( 'if_none_match' );
if ( $if_none_match && trim( $if_none_match, '"' ) === $etag ) {
$response->set_status( 304 );
$response->set_data( null );
}
return $response;
}
function wpkite_get_cache_ttl_for_route( $route ) {
// Posts and pages change infrequently
if ( preg_match( '#^/wp/v2/(posts|pages)#', $route ) ) {
return 300; // 5 minutes
}
// Taxonomies and categories rarely change
if ( preg_match( '#^/wp/v2/(categories|tags)#', $route ) ) {
return 3600; // 1 hour
}
// Media endpoints
if ( preg_match( '#^/wp/v2/media#', $route ) ) {
return 1800; // 30 minutes
}
// Default
return 60;
}
function wpkite_is_user_specific_route( $route ) {
$user_routes = array( '/wp/v2/users/me', '/wpkite/v1/tickets', '/wpkite/v1/account' );
foreach ( $user_routes as $user_route ) {
if ( strpos( $route, $user_route ) === 0 ) {
return true;
}
}
return false;
}
The s-maxage directive targets shared caches like Varnish and CDNs, allowing a longer TTL for reverse proxies than for browser caches. The ETag implementation enables conditional requests: when a client sends If-None-Match with a cached ETag, the server can return a 304 Not Modified response without sending the full body, saving bandwidth.
Nginx Microcaching for REST Endpoints
Nginx microcaching stores responses in a memory-mapped file cache for very short durations, typically 1 to 10 seconds. Even a 1-second cache eliminates duplicate processing when multiple clients request the same endpoint simultaneously. For a REST API serving 500 requests per second to the same endpoint, a 1-second microcache turns 500 PHP executions into 1.
# nginx.conf - Define the cache zone
fastcgi_cache_path /tmp/nginx-rest-cache levels=1:2
keys_zone=rest_cache:10m
max_size=100m
inactive=5m
use_temp_path=off;
server {
# Cache REST API GET requests
location ~ ^/wp-json/ {
# Cache key includes method, scheme, host, and URI with query string
set $cache_key "$request_method$scheme$host$request_uri";
# Skip cache for non-GET requests
set $skip_cache 0;
if ($request_method != GET) {
set $skip_cache 1;
}
# Skip cache if Authorization header is present
if ($http_authorization) {
set $skip_cache 1;
}
# Skip cache for logged-in users (WordPress cookie)
if ($http_cookie ~* "wordpress_logged_in") {
set $skip_cache 1;
}
fastcgi_cache rest_cache;
fastcgi_cache_key $cache_key;
fastcgi_cache_valid 200 5s;
fastcgi_cache_valid 404 1s;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
# Add header to indicate cache status
add_header X-Cache-Status $upstream_cache_status always;
# Pass to PHP-FPM
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.0-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
}
}
The X-Cache-Status header is essential during development and debugging. It returns HIT, MISS, BYPASS, or EXPIRED, telling you exactly whether the cache served the response. Monitor this header in your load tests to verify the cache is working as expected.
Varnish VCL for REST API Caching
Varnish sits in front of your web server and serves cached responses from memory. For REST API endpoints, Varnish can be configured to cache based on the URL and query parameters while respecting authentication headers.
sub vcl_recv {
# Cache REST API GET requests without auth
if (req.url ~ "^/wp-json/" && req.method == "GET") {
# Strip cookies for public endpoints
if (req.url !~ "users/me" && !req.http.Authorization) {
unset req.http.Cookie;
return (hash);
}
}
# Purge cache on POST/PUT/DELETE to REST API
if (req.url ~ "^/wp-json/" && req.method != "GET") {
ban("req.url ~ ^/wp-json/");
}
}
sub vcl_backend_response {
if (bereq.url ~ "^/wp-json/" && bereq.method == "GET") {
set beresp.ttl = 10s;
set beresp.grace = 30s;
unset beresp.http.Set-Cookie;
}
}
sub vcl_deliver {
if (obj.hits > 0) {
set resp.http.X-Cache = "HIT";
set resp.http.X-Cache-Hits = obj.hits;
} else {
set resp.http.X-Cache = "MISS";
}
}
The beresp.grace period of 30 seconds means Varnish will serve stale content for up to 30 seconds while fetching a fresh copy from the backend. This prevents the “thundering herd” problem where cache expiration causes dozens of simultaneous requests to hit the origin server.
Application-Level REST Response Cache
When you do not control the web server configuration (shared hosting, managed WordPress hosts), you can still cache REST responses at the application level using the object cache.
add_filter( 'rest_pre_dispatch', 'wpkite_serve_cached_rest_response', 5, 3 );
function wpkite_serve_cached_rest_response( $result, $server, $request ) {
if ( 'GET' !== $request->get_method() ) {
return $result;
}
if ( is_user_logged_in() ) {
return $result;
}
$cache_key = 'rest_cache_' . md5( $request->get_route() . wp_json_encode( $request->get_params() ) );
$cached = wp_cache_get( $cache_key, 'rest_responses' );
if ( false !== $cached ) {
return rest_ensure_response( $cached );
}
// Store the cache key so we can save the response later
$request->set_param( '_cache_key', $cache_key );
return $result;
}
add_filter( 'rest_post_dispatch', 'wpkite_cache_rest_response', 999, 3 );
function wpkite_cache_rest_response( $response, $server, $request ) {
$cache_key = $request->get_param( '_cache_key' );
if ( ! $cache_key ) {
return $response;
}
if ( $response->is_error() ) {
return $response;
}
$ttl = wpkite_get_cache_ttl_for_route( $request->get_route() );
wp_cache_set( $cache_key, $response->get_data(), 'rest_responses', $ttl );
return $response;
}
The priority of 5 on the rest_pre_dispatch filter ensures the cache check runs before rate limiting and authentication middleware, giving cached responses the fastest possible path. The priority of 999 on rest_post_dispatch ensures we cache the final, fully-processed response.
Building a Custom Middleware Pattern with WordPress Filters
Modern API frameworks like Express.js and Laravel have formal middleware systems where you can stack request processors in a defined order. WordPress does not have a formal middleware concept, but you can build an equivalent pattern using the REST API filter hooks. The three key hooks form a request lifecycle pipeline.
The filter rest_pre_dispatch fires before routing. Use it for authentication, rate limiting, and request validation. The filter rest_request_before_callbacks fires after routing but before the endpoint callback. Use it for permission checks and request transformation. The filter rest_post_dispatch fires after the callback returns. Use it for response transformation, logging, and header injection.
Middleware Registry Class
To keep middleware organized and testable, wrap everything in a registry class that manages the order of operations.
class WPKite_REST_Middleware {
private static $instance = null;
private $pre_dispatch_handlers = array();
private $before_callback_handlers = array();
private $post_dispatch_handlers = array();
private $request_log = array();
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {
add_filter( 'rest_pre_dispatch', array( $this, 'run_pre_dispatch' ), 10, 3 );
add_filter( 'rest_request_before_callbacks', array( $this, 'run_before_callbacks' ), 10, 3 );
add_filter( 'rest_post_dispatch', array( $this, 'run_post_dispatch' ), 10, 3 );
}
public function add_pre_dispatch( $callback, $priority = 10 ) {
$this->pre_dispatch_handlers[ $priority ][] = $callback;
ksort( $this->pre_dispatch_handlers );
}
public function add_before_callback( $callback, $priority = 10 ) {
$this->before_callback_handlers[ $priority ][] = $callback;
ksort( $this->before_callback_handlers );
}
public function add_post_dispatch( $callback, $priority = 10 ) {
$this->post_dispatch_handlers[ $priority ][] = $callback;
ksort( $this->post_dispatch_handlers );
}
public function run_pre_dispatch( $result, $server, $request ) {
$this->request_log['start_time'] = microtime( true );
$this->request_log['route'] = $request->get_route();
$this->request_log['method'] = $request->get_method();
$this->request_log['ip'] = $_SERVER['REMOTE_ADDR'];
foreach ( $this->pre_dispatch_handlers as $priority => $handlers ) {
foreach ( $handlers as $handler ) {
$result = call_user_func( $handler, $result, $server, $request );
if ( is_wp_error( $result ) ) {
$this->log_request( $result );
return $result;
}
}
}
return $result;
}
public function run_before_callbacks( $response, $handler, $request ) {
foreach ( $this->before_callback_handlers as $priority => $handlers ) {
foreach ( $handlers as $callback ) {
$response = call_user_func( $callback, $response, $handler, $request );
if ( is_wp_error( $response ) ) {
return $response;
}
}
}
return $response;
}
public function run_post_dispatch( $response, $server, $request ) {
foreach ( $this->post_dispatch_handlers as $priority => $handlers ) {
foreach ( $handlers as $handler ) {
$response = call_user_func( $handler, $response, $server, $request );
}
}
$this->log_request( $response );
return $response;
}
private function log_request( $response ) {
$this->request_log['end_time'] = microtime( true );
$this->request_log['duration'] = $this->request_log['end_time'] - $this->request_log['start_time'];
if ( is_wp_error( $response ) ) {
$this->request_log['status'] = $response->get_error_data()['status'] ?? 500;
$this->request_log['error'] = $response->get_error_message();
} else {
$this->request_log['status'] = $response->get_status();
}
// Log to a custom table or error_log
if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
error_log( sprintf(
'REST API [%s] %s %s - %d - %.4fs - IP: %s',
$this->request_log['method'],
$this->request_log['route'],
$this->request_log['status'],
$this->request_log['status'],
$this->request_log['duration'],
$this->request_log['ip']
) );
}
}
}
Registering Middleware Handlers
With the registry in place, you register middleware functions in a clean, declarative way:
$middleware = WPKite_REST_Middleware::get_instance();
// Priority 5: Cache check (runs first)
$middleware->add_pre_dispatch( 'wpkite_middleware_cache_check', 5 );
// Priority 10: Rate limiting
$middleware->add_pre_dispatch( 'wpkite_middleware_rate_limit', 10 );
// Priority 15: API key validation for custom endpoints
$middleware->add_pre_dispatch( 'wpkite_middleware_api_key_check', 15 );
// Before callback: Input sanitization
$middleware->add_before_callback( 'wpkite_middleware_sanitize_input', 10 );
// Post dispatch: Add security headers
$middleware->add_post_dispatch( 'wpkite_middleware_security_headers', 10 );
// Post dispatch: CORS headers
$middleware->add_post_dispatch( 'wpkite_middleware_cors_headers', 20 );
function wpkite_middleware_security_headers( $response, $server, $request ) {
$response->header( 'X-Content-Type-Options', 'nosniff' );
$response->header( 'X-Frame-Options', 'DENY' );
$response->header( 'X-Request-ID', wp_generate_uuid4() );
return $response;
}
function wpkite_middleware_cors_headers( $response, $server, $request ) {
$allowed_origins = array(
'https://app.wpkite.com',
'https://staging.wpkite.com',
);
$origin = get_http_origin();
if ( in_array( $origin, $allowed_origins, true ) ) {
$response->header( 'Access-Control-Allow-Origin', $origin );
$response->header( 'Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS' );
$response->header( 'Access-Control-Allow-Headers', 'Authorization, Content-Type, X-WP-Nonce' );
$response->header( 'Access-Control-Max-Age', '86400' );
}
return $response;
}
This pattern gives you a structured pipeline where each middleware component has a single responsibility. When something goes wrong, the request log tells you exactly which middleware stage failed and how long each stage took. The priority system means you can insert new middleware at any point in the chain without modifying existing code.
Reducing Serialization Overhead for Large Collections
When you request a collection of posts via /wp/v2/posts, WordPress runs WP_REST_Posts_Controller::prepare_item_for_response() for every single post. That method calls setup_postdata(), runs multiple apply_filters() calls, resolves embedded resources, processes rendered content, and builds link relations. For a collection of 100 posts, this serialization work can take longer than the database query itself.
Profiling Serialization Time
Before optimizing, measure where time is spent. Add timing instrumentation to the rest_pre_serve_request and individual preparation steps.
add_filter( 'rest_pre_echo_response', 'wpkite_profile_serialization', 10, 3 );
function wpkite_profile_serialization( $result, $server, $request ) {
if ( defined( 'WPKITE_PROFILE_REST' ) && WPKITE_PROFILE_REST ) {
global $wpkite_rest_timing;
$json_start = microtime( true );
$json = wp_json_encode( $result );
$json_time = microtime( true ) - $json_start;
error_log( sprintf(
'REST Serialization: %s - JSON encode: %.4fs - Size: %s bytes - Items: %d',
$request->get_route(),
$json_time,
strlen( $json ),
is_array( $result ) ? count( $result ) : 1
) );
}
return $result;
}
In production profiling of a site with 50,000 posts, we observed that wp_json_encode() on a 100-post collection with full content took 45ms. The prepare_item_for_response() loop took 380ms. The database query took 12ms. The serialization overhead was 30 times more expensive than the query.
Custom Lightweight Endpoint
For internal API consumers that only need specific fields, build a custom endpoint that skips the full WP_REST_Posts_Controller preparation pipeline.
add_action( 'rest_api_init', 'wpkite_register_fast_posts_endpoint' );
function wpkite_register_fast_posts_endpoint() {
register_rest_route( 'wpkite/v1', '/fast-posts', array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'wpkite_get_fast_posts',
'permission_callback' => '__return_true',
'args' => array(
'per_page' => array(
'default' => 10,
'sanitize_callback' => 'absint',
'validate_callback' => function( $value ) {
return $value >= 1 && $value <= 100;
},
),
'page' => array(
'default' => 1,
'sanitize_callback' => 'absint',
),
'fields' => array(
'default' => 'id,title,slug,date,excerpt',
'sanitize_callback' => 'sanitize_text_field',
),
),
) );
}
function wpkite_get_fast_posts( $request ) {
$per_page = $request->get_param( 'per_page' );
$page = $request->get_param( 'page' );
$fields = array_map( 'trim', explode( ',', $request->get_param( 'fields' ) ) );
$offset = ( $page - 1 ) * $per_page;
// Direct database query for maximum speed
global $wpdb;
$allowed_fields = array(
'id' => 'ID',
'title' => 'post_title',
'slug' => 'post_name',
'date' => 'post_date',
'modified' => 'post_modified',
'excerpt' => 'post_excerpt',
'content' => 'post_content',
'author' => 'post_author',
'status' => 'post_status',
);
$select_fields = array();
foreach ( $fields as $field ) {
if ( isset( $allowed_fields[ $field ] ) ) {
$select_fields[] = $allowed_fields[ $field ];
}
}
if ( empty( $select_fields ) ) {
$select_fields = array( 'ID', 'post_title', 'post_name', 'post_date' );
}
$select_sql = implode( ', ', $select_fields );
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$posts = $wpdb->get_results( $wpdb->prepare(
"SELECT {$select_sql} FROM {$wpdb->posts}
WHERE post_type = 'post' AND post_status = 'publish'
ORDER BY post_date DESC
LIMIT %d OFFSET %d",
$per_page,
$offset
) );
$total = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->posts}
WHERE post_type = 'post' AND post_status = 'publish'"
);
// Minimal serialization
$items = array();
foreach ( $posts as $post ) {
$item = array();
foreach ( $fields as $field ) {
switch ( $field ) {
case 'id':
$item['id'] = (int) $post->ID;
break;
case 'title':
$item['title'] = $post->post_title;
break;
case 'slug':
$item['slug'] = $post->post_name;
break;
case 'date':
$item['date'] = mysql2date( 'c', $post->post_date );
break;
case 'excerpt':
$item['excerpt'] = wp_trim_words( $post->post_excerpt, 30 );
break;
}
}
$items[] = $item;
}
$response = rest_ensure_response( $items );
$response->header( 'X-WP-Total', $total );
$response->header( 'X-WP-TotalPages', ceil( $total / $per_page ) );
return $response;
}
This approach trades REST API standards compliance for raw speed. The response does not include _links, embedded resources, or the full rendered content. For a headless frontend that just needs a post list for navigation or search results, this endpoint can be 10 to 20 times faster than the default /wp/v2/posts endpoint. In benchmarks on a site with 50,000 published posts, the custom endpoint returned 100 posts in 8ms compared to 420ms for the default endpoint.
Field Filtering with _fields and Custom Response Schemas
Before building a completely custom endpoint, try the built-in _fields parameter. WordPress 4.9.8 introduced the _fields query parameter, which tells the REST API to only include specific fields in the response. This reduces response size and, in some cases, skips expensive computation for fields you do not need.
GET /wp-json/wp/v2/posts?_fields=id,title,slug,date&per_page=50
This request returns only the four specified fields for each post. The response body shrinks dramatically, from roughly 150KB for 50 full post objects to about 8KB with just these four fields. Less data means less time spent in wp_json_encode() and faster network transfer.
How _fields Works Internally
The _fields parameter triggers filtering in rest_filter_response_fields() hooked to rest_post_dispatch. After the full response is built, WordPress strips out fields not in the requested list. This means the database queries and most serialization still run. The savings come primarily from reduced JSON encoding time and smaller response payloads.
For better performance, you can check the _fields parameter inside your endpoint callback and skip expensive operations when certain fields are not requested.
function wpkite_prepare_post_response( $post, $request ) {
$fields = $request->get_param( '_fields' );
$fields_array = $fields ? array_map( 'trim', explode( ',', $fields ) ) : array();
$data = array(
'id' => $post->ID,
'title' => get_the_title( $post ),
'slug' => $post->post_name,
'date' => mysql2date( 'c', $post->post_date ),
);
// Only compute rendered content if explicitly requested
if ( empty( $fields_array ) || in_array( 'content', $fields_array, true ) ) {
$data['content'] = array(
'rendered' => apply_filters( 'the_content', $post->post_content ),
);
}
// Only resolve featured image if requested
if ( empty( $fields_array ) || in_array( 'featured_media_url', $fields_array, true ) ) {
$thumbnail_id = get_post_thumbnail_id( $post->ID );
$data['featured_media_url'] = $thumbnail_id
? wp_get_attachment_image_url( $thumbnail_id, 'large' )
: null;
}
// Only fetch meta if requested
if ( empty( $fields_array ) || in_array( 'meta', $fields_array, true ) ) {
$data['meta'] = get_post_meta( $post->ID );
}
return $data;
}
By checking which fields the consumer actually needs, you avoid calling apply_filters( 'the_content' ) (which runs shortcodes, embeds, and block rendering), skip the extra database query for featured images, and skip the metadata lookup. Each of these operations adds measurable time per post, and the savings compound across large collections.
Custom Response Schema
Defining a proper JSON Schema for your custom endpoints enables automatic validation and documentation. Use the schema key in your route registration.
register_rest_route( 'wpkite/v1', '/posts', array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'wpkite_get_optimized_posts',
'permission_callback' => '__return_true',
'schema' => 'wpkite_posts_schema',
) );
function wpkite_posts_schema() {
return array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'wpkite-post',
'type' => 'object',
'properties' => array(
'id' => array(
'description' => 'Unique identifier for the post.',
'type' => 'integer',
'readonly' => true,
),
'title' => array(
'description' => 'The post title.',
'type' => 'string',
),
'slug' => array(
'description' => 'URL-friendly identifier.',
'type' => 'string',
),
'date' => array(
'description' => 'Publication date in ISO 8601 format.',
'type' => 'string',
'format' => 'date-time',
),
'excerpt' => array(
'description' => 'Short summary of the post.',
'type' => 'string',
),
),
);
}
The schema serves double duty. It documents your API for consumers who discover it via the /wp-json/ index, and it enables the _fields parameter to work correctly with your custom endpoint. WordPress uses the schema to know which fields exist and can be filtered.
Batch Endpoints and Reducing HTTP Round Trips
WordPress 5.6 introduced the Batch API endpoint at /wp-json/batch/v1. This endpoint accepts an array of sub-requests and processes them in a single HTTP round trip. For mobile clients and SPAs that need data from multiple endpoints during page initialization, batching can dramatically reduce perceived load time.
Using the Built-in Batch Endpoint
// Client-side: batch multiple requests into one
const batchRequest = {
requests: [
{
path: '/wp-json/wp/v2/posts?per_page=5&_fields=id,title,slug',
method: 'GET',
},
{
path: '/wp-json/wp/v2/categories?per_page=20&_fields=id,name,slug',
method: 'GET',
},
{
path: '/wp-json/wp/v2/pages?slug=about&_fields=id,title,content',
method: 'GET',
},
],
};
fetch( '/wp-json/batch/v1', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': wpApiSettings.nonce,
},
body: JSON.stringify( batchRequest ),
} )
.then( response => response.json() )
.then( data => {
const [ posts, categories, aboutPage ] = data.responses;
// Each response contains status, headers, and body
renderPosts( posts.body );
renderCategories( categories.body );
renderAboutPage( aboutPage.body );
} );
Each sub-request in the batch runs through the full REST API lifecycle, including authentication, permission checks, and all filters. The benefit is purely at the HTTP layer: one TCP connection, one TLS handshake, one round trip instead of three.
Custom Composite Endpoint
For frequently-used data combinations, a purpose-built composite endpoint outperforms the batch API because you can optimize the database queries.
add_action( 'rest_api_init', function() {
register_rest_route( 'wpkite/v1', '/homepage-data', array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'wpkite_get_homepage_data',
'permission_callback' => '__return_true',
) );
} );
function wpkite_get_homepage_data( $request ) {
$cache_key = 'wpkite_homepage_data';
$cached = wp_cache_get( $cache_key, 'wpkite_composite' );
if ( false !== $cached ) {
return rest_ensure_response( $cached );
}
// Fetch everything in optimized queries
$recent_posts = get_posts( array(
'numberposts' => 6,
'post_status' => 'publish',
'suppress_filters' => false,
'fields' => 'ids',
) );
$posts_data = array();
if ( ! empty( $recent_posts ) ) {
// Prime the post cache in a single query
_prime_post_caches( $recent_posts, true, true );
foreach ( $recent_posts as $post_id ) {
$post = get_post( $post_id );
$posts_data[] = array(
'id' => $post->ID,
'title' => get_the_title( $post ),
'slug' => $post->post_name,
'date' => mysql2date( 'c', $post->post_date ),
'excerpt' => get_the_excerpt( $post ),
'image' => get_the_post_thumbnail_url( $post, 'medium_large' ),
);
}
}
$categories = get_terms( array(
'taxonomy' => 'category',
'hide_empty' => true,
'number' => 20,
) );
$categories_data = array_map( function( $term ) {
return array(
'id' => $term->term_id,
'name' => $term->name,
'slug' => $term->slug,
'count' => $term->count,
);
}, $categories );
$data = array(
'recent_posts' => $posts_data,
'categories' => $categories_data,
'site_info' => array(
'name' => get_bloginfo( 'name' ),
'description' => get_bloginfo( 'description' ),
),
);
wp_cache_set( $cache_key, $data, 'wpkite_composite', 300 );
return rest_ensure_response( $data );
}
Using _prime_post_caches() is a key optimization here. Instead of letting WordPress run individual queries for each post’s metadata and terms, this function fetches all metadata and terms for the given post IDs in bulk queries. For 6 posts, this reduces what would be 18+ queries (post + meta + terms for each) down to 3 queries (posts + all meta + all terms).
WPGraphQL vs REST API: Performance Comparison for Headless WordPress
The choice between WPGraphQL and the REST API for headless WordPress projects often comes down to developer preference and ecosystem compatibility. But there are real, measurable performance differences that matter at scale.
Over-fetching and Under-fetching
The REST API returns a fixed shape per resource type. A GET request to /wp/v2/posts/123 returns the complete post object with all fields, embedded author data, terms, and link relations. If your frontend only needs the title and featured image, you are transferring and parsing kilobytes of unused data. The _fields parameter helps with response size but does not eliminate server-side computation for unused fields.
GraphQL solves this at the protocol level. The client specifies exactly which fields it needs, and the server only resolves those fields. A WPGraphQL query for title and featured image skips resolving content, excerpt, author details, and everything else.
# GraphQL: Fetch exactly what you need
query GetPostCard($id: ID!) {
post(id: $id, idType: DATABASE_ID) {
title
slug
date
featuredImage {
node {
sourceUrl(size: MEDIUM_LARGE)
altText
}
}
}
}
# Equivalent REST API call requires fetching everything:
# GET /wp-json/wp/v2/posts/123?_embed&_fields=id,title,slug,date,featured_media
# Plus a second request for the media URL if not using _embed
Query Complexity and N+1 Problems
WPGraphQL uses DataLoader patterns internally to batch database queries and avoid the N+1 problem. When you query 10 posts with their authors and categories, WPGraphQL batches the author lookups into a single WHERE user_id IN (...) query rather than running 10 individual author queries.
The REST API with _embed suffers from N+1 more visibly. Embedding author data means WordPress resolves each author individually within the serialization loop. For a collection of 50 posts by 5 different authors, the REST API may run 50 separate user lookups (with object caching mitigating repeat lookups for the same author), while WPGraphQL runs 1 batched query for the 5 unique authors.
Benchmark Results
Testing against a WordPress site with 10,000 posts, 500 categories, and 50 authors, using ApacheBench with 100 concurrent connections over 10 seconds:
Fetching 20 posts with title, slug, date, author name, and category names:
REST API (/wp/v2/posts?per_page=20&_embed&_fields=id,title,slug,date): median response time 285ms, response size 48KB, 95th percentile 520ms.
WPGraphQL (equivalent query): median response time 145ms, response size 4.2KB, 95th percentile 280ms.
Custom REST endpoint (direct DB queries): median response time 35ms, response size 3.8KB, 95th percentile 65ms.
The WPGraphQL advantage comes from smaller response payloads and better query batching. The custom REST endpoint wins overall because it bypasses both the GraphQL resolver layer and the standard WordPress serialization pipeline. The tradeoff is maintenance cost: every schema change requires updating the custom endpoint manually.
Caching Considerations
REST API responses are inherently more cacheable than GraphQL responses. Each REST URL maps to a deterministic response, making HTTP-level caching straightforward. Varnish, Nginx, and CDNs can cache REST responses with zero application awareness.
GraphQL requests are typically POST requests with the query in the request body. POST requests are not cached by default at the HTTP layer. Some CDNs support GraphQL-aware caching (Stellate, formerly GraphCDN, is purpose-built for this), and WPGraphQL supports persisted queries that map to GET requests. But out of the box, REST API caching is simpler and more widely supported.
WPGraphQL does offer a graphql_response_headers_to_send filter and built-in support for the graphql_request_results cache, and plugins like WPGraphQL Smart Cache add object cache and network cache integration. These work well but require additional setup compared to the REST API’s native HTTP caching compatibility.
When to Choose Which
Choose the REST API when you need HTTP-layer caching without extra infrastructure, when you are integrating with systems that expect REST (webhooks, Zapier, IFTTT), or when your team is more comfortable with REST conventions. Choose WPGraphQL when your frontend has varied data requirements across many views, when reducing response payload size is critical (mobile apps on slow connections), or when you are using a framework like Next.js with built-in GraphQL support via Apollo or URQL. Choose custom endpoints when you have identified specific bottlenecks that neither standard solution addresses efficiently.
Load Testing REST Endpoints and Identifying Bottlenecks
Performance optimizations without load testing are guesswork. You need to simulate realistic traffic patterns against your REST API endpoints and measure response times, throughput, error rates, and resource consumption under pressure.
Tools for Load Testing WordPress REST APIs
k6 (Grafana k6) is the best choice for API load testing. It is scriptable in JavaScript, handles complex scenarios with multiple endpoints, and produces detailed metrics. Here is a k6 script that tests a WordPress REST API under increasing load.
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
const errorRate = new Rate( 'errors' );
const postListDuration = new Trend( 'post_list_duration' );
const singlePostDuration = new Trend( 'single_post_duration' );
export const options = {
stages: [
{ duration: '30s', target: 20 }, // Ramp up to 20 users
{ duration: '1m', target: 50 }, // Ramp up to 50 users
{ duration: '2m', target: 100 }, // Hold at 100 users
{ duration: '1m', target: 200 }, // Spike to 200 users
{ duration: '30s', target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: [ 'p(95)<500' ], // 95th percentile under 500ms
errors: [ 'rate<0.01' ], // Error rate under 1%
},
};
const BASE_URL = 'https://wpkite.com/wp-json';
export default function() {
// Scenario 1: Fetch post list (most common request)
const listResponse = http.get(
`${BASE_URL}/wp/v2/posts?per_page=10&_fields=id,title,slug,date,excerpt`,
{ tags: { endpoint: 'post_list' } }
);
check( listResponse, {
'post list status is 200': ( r ) => r.status === 200,
'post list has items': ( r ) => JSON.parse( r.body ).length > 0,
} );
errorRate.add( listResponse.status !== 200 );
postListDuration.add( listResponse.timings.duration );
// Scenario 2: Fetch single post
const posts = JSON.parse( listResponse.body );
if ( posts.length > 0 ) {
const randomPost = posts[ Math.floor( Math.random() * posts.length ) ];
const singleResponse = http.get(
`${BASE_URL}/wp/v2/posts/${randomPost.id}`,
{ tags: { endpoint: 'single_post' } }
);
check( singleResponse, {
'single post status is 200': ( r ) => r.status === 200,
} );
errorRate.add( singleResponse.status !== 200 );
singlePostDuration.add( singleResponse.timings.duration );
}
// Simulate real user think time
sleep( Math.random() * 3 + 1 );
}
Run this with k6 run load-test.js and watch for the 95th percentile crossing your threshold. The ramp-up stages let you see exactly where performance starts degrading. At 50 concurrent users, response times might be fine at 120ms. At 100 users, they might jump to 350ms. At 200 users, they might hit 1.2 seconds. That inflection point tells you where your bottleneck lives.
Identifying Common Bottlenecks
Database queries are the most common bottleneck. Install the Query Monitor plugin during testing and look for slow queries, duplicate queries, and queries without indexes. The wp_posts table needs indexes on (post_type, post_status, post_date) for the default REST API post queries. If you are filtering by custom meta, you need a meta_key index on wp_postmeta.
PHP-FPM worker exhaustion happens when all PHP workers are busy processing slow requests, causing new requests to queue. Check your pm.max_children setting in php-fpm.conf. A common starting point is 2 times the number of CPU cores for CPU-bound workloads, or higher for I/O-bound workloads where workers spend time waiting on database responses.
Object cache miss rate affects performance when you are relying on caching. Monitor your Redis or Memcached hit rate. A hit rate below 80% suggests your cache is either too small (keys are being evicted) or your TTLs are too short. Use redis-cli INFO stats to check keyspace_hits versus keyspace_misses.
Serialization time becomes the bottleneck after you have optimized queries and caching. Profile with microtime(true) around wp_json_encode() calls to confirm. If JSON encoding is taking more than 50ms, you are serializing too much data. Reduce the number of fields or the collection size.
Using WordPress Debug Tools
Enable SAVEQUERIES in wp-config.php to capture all database queries with their execution time and call stack:
define( 'SAVEQUERIES', true );
// Then in your endpoint or via rest_post_dispatch:
add_filter( 'rest_post_dispatch', function( $response, $server, $request ) {
if ( current_user_can( 'manage_options' ) && $request->get_param( '_debug' ) ) {
global $wpdb;
$slow_queries = array_filter( $wpdb->queries, function( $query ) {
return $query[1] > 0.01; // Queries over 10ms
} );
$response->header( 'X-DB-Queries', count( $wpdb->queries ) );
$response->header( 'X-Slow-Queries', count( $slow_queries ) );
$response->header( 'X-DB-Time', array_sum( array_column( $wpdb->queries, 1 ) ) );
}
return $response;
}, 10, 3 );
The _debug parameter check ensures this only runs when explicitly requested by an admin, preventing performance data from leaking to regular users. Never leave SAVEQUERIES enabled in production without this kind of gating; it adds overhead to every query.
Authentication Strategies: Application Passwords, JWT, and OAuth
Authentication for the REST API directly impacts both security and performance. Each method has different overhead characteristics, different security profiles, and different use cases.
Cookie Authentication (Built-in)
Cookie authentication is the default for logged-in users interacting with the REST API from the same domain. WordPress validates the cookie and nonce on each request using wp_validate_auth_cookie() and wp_verify_nonce(). The performance overhead is minimal because these functions use in-memory lookups after the initial database check.
The limitation is that cookie auth only works for same-origin requests. Cross-domain requests, mobile apps, and server-to-server integrations cannot use cookies. The nonce system also means tokens expire (default 24-hour lifecycle), which complicates long-running SPA sessions.
Application Passwords (WordPress 5.6+)
Application Passwords were added in WordPress 5.6 as a built-in authentication method for external API consumers. Each user can generate multiple application passwords, each with a human-readable name for identification.
# Using Application Passwords with Basic Auth
curl -u "david-okonkwo:xxxx xxxx xxxx xxxx xxxx xxxx"
https://wpkite.com/wp-json/wp/v2/posts
# Or with the Authorization header directly
curl -H "Authorization: Basic ZGF2aWQtb2tvbmt3bzp4eHh4IHh4eHggeHh4eCB4eHh4IHh4eHggeHh4eA=="
https://wpkite.com/wp-json/wp/v2/posts
Performance-wise, Application Passwords add a database query per request to validate the password hash using wp_check_password(). The password is hashed with wp_hash_password() (bcrypt by default), which is intentionally slow (to resist brute force attacks). On a modern server, bcrypt verification takes 3-5ms. For high-throughput API endpoints, this overhead adds up.
You can mitigate this by caching the authentication result in the object cache for the duration of the request, though you need to be careful not to cache authentication across different requests for security reasons. A better approach is to use Application Passwords for low-frequency operations (content publishing, admin tasks) and a faster method for high-frequency reads.
JWT (JSON Web Tokens)
JWT authentication is not built into WordPress core but is available through plugins like “JWT Authentication for WP REST API” or custom implementations. The flow works as follows: the client sends credentials to an auth endpoint, receives a signed JWT, and includes that token in the Authorization: Bearer header on subsequent requests.
// Custom JWT validation in rest_authentication_errors filter
add_filter( 'rest_authentication_errors', 'wpkite_jwt_authenticate' );
function wpkite_jwt_authenticate( $result ) {
// Don't override existing auth or non-REST requests
if ( ! empty( $result ) || ! defined( 'REST_REQUEST' ) ) {
return $result;
}
$auth_header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if ( ! preg_match( '/^Bearers+(.+)$/i', $auth_header, $matches ) ) {
return $result; // No JWT token, let other auth methods try
}
$token = $matches[1];
try {
// Decode and verify the token
$parts = explode( '.', $token );
if ( count( $parts ) !== 3 ) {
return new WP_Error( 'jwt_invalid', 'Malformed token.', array( 'status' => 401 ) );
}
list( $header_b64, $payload_b64, $signature_b64 ) = $parts;
// Verify signature
$secret = defined( 'WPKITE_JWT_SECRET' ) ? WPKITE_JWT_SECRET : AUTH_KEY;
$expected_sig = hash_hmac( 'sha256', "$header_b64.$payload_b64", $secret, true );
$actual_sig = base64_decode( strtr( $signature_b64, '-_', '+/' ) );
if ( ! hash_equals( $expected_sig, $actual_sig ) ) {
return new WP_Error( 'jwt_invalid_signature', 'Invalid token signature.', array( 'status' => 401 ) );
}
// Decode payload
$payload = json_decode( base64_decode( strtr( $payload_b64, '-_', '+/' ) ), true );
// Check expiration
if ( isset( $payload['exp'] ) && $payload['exp'] < time() ) {
return new WP_Error( 'jwt_expired', 'Token has expired.', array( 'status' => 401 ) );
}
// Set the current user
$user_id = $payload['user_id'] ?? 0;
if ( $user_id && get_userdata( $user_id ) ) {
wp_set_current_user( $user_id );
return true;
}
return new WP_Error( 'jwt_invalid_user', 'Invalid user in token.', array( 'status' => 401 ) );
} catch ( Exception $e ) {
return new WP_Error( 'jwt_error', $e->getMessage(), array( 'status' => 401 ) );
}
}
JWT’s performance advantage is significant for read-heavy APIs. Token validation requires no database query. The server verifies the HMAC signature using a shared secret (a CPU operation taking microseconds) and reads the user ID from the token payload. Compare this to Application Passwords, which require a database lookup and bcrypt comparison on every request.
The security tradeoff is that JWTs cannot be revoked individually. If a token is compromised, it remains valid until expiration. You can mitigate this with short expiration times (15-30 minutes) and a refresh token mechanism, or by maintaining a token blocklist checked on each request (which reintroduces a database lookup but only for the blocklist check).
OAuth 2.0
OAuth 2.0 is the most appropriate choice when third-party applications need to act on behalf of WordPress users. The WordPress OAuth Server plugin or a custom implementation using the rest_authentication_errors filter can support the Authorization Code flow, which is the most secure OAuth grant type for web applications.
Performance-wise, OAuth access tokens require a database lookup per request (similar to Application Passwords), but the token validation is a simple string comparison rather than a bcrypt hash check. This makes OAuth token validation faster than Application Password validation while maintaining the ability to revoke tokens.
Authentication Performance Comparison
Cookie + Nonce: 0.5ms overhead. Best for same-domain browser requests. No extra database query after initial session establishment.
Application Passwords: 3-5ms overhead (bcrypt). Best for simple server-to-server integrations and development. Built into core, no plugins needed.
JWT: 0.1ms overhead (HMAC verification). Best for high-frequency API access from SPAs and mobile apps. Requires a plugin or custom code. Tokens cannot be individually revoked without a blocklist.
OAuth 2.0: 1-2ms overhead (database token lookup). Best for third-party integrations needing delegated access. Most complex to implement. Supports token revocation and scoped permissions.
For a headless WordPress setup serving 1,000 authenticated requests per second, the choice between Application Passwords (5 seconds of cumulative auth overhead) and JWT (0.1 seconds) is meaningful. At lower volumes, the difference is negligible and you should choose based on security requirements rather than performance.
Building Production-Ready Custom Endpoints with Proper Error Handling
The difference between a quick custom endpoint and a production-ready one comes down to error handling, input validation, proper HTTP status codes, and graceful degradation. Too many WordPress REST API tutorials show the happy path and ignore everything that can go wrong in production.
Structured Endpoint Registration
Use WP_REST_Controller as your base class for anything beyond trivial endpoints. It provides a structured pattern for registration, permission checks, argument validation, and response preparation.
class WPKite_Tickets_Controller extends WP_REST_Controller {
protected $namespace = 'wpkite/v1';
protected $rest_base = 'tickets';
public function register_routes() {
register_rest_route( $this->namespace, '/' . $this->rest_base, array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
'args' => $this->get_collection_params(),
),
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'create_item' ),
'permission_callback' => array( $this, 'create_item_permissions_check' ),
'args' => $this->get_create_params(),
),
'schema' => array( $this, 'get_public_item_schema' ),
) );
register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P[d]+)', array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( $this, 'get_item_permissions_check' ),
'args' => array(
'id' => array(
'validate_callback' => function( $value ) {
return is_numeric( $value ) && $value > 0;
},
),
),
),
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'update_item' ),
'permission_callback' => array( $this, 'update_item_permissions_check' ),
'args' => $this->get_update_params(),
),
) );
}
public function get_items_permissions_check( $request ) {
if ( ! is_user_logged_in() ) {
return new WP_Error(
'rest_not_logged_in',
'You must be logged in to view tickets.',
array( 'status' => 401 )
);
}
return true;
}
public function get_items( $request ) {
global $wpdb;
$table = $wpdb->prefix . 'wpkite_tickets';
$user_id = get_current_user_id();
$per_page = $request->get_param( 'per_page' ) ?: 10;
$page = $request->get_param( 'page' ) ?: 1;
$status = $request->get_param( 'status' );
$offset = ( $page - 1 ) * $per_page;
// Build query with optional status filter
$where = $wpdb->prepare( 'WHERE user_id = %d', $user_id );
if ( $status && in_array( $status, array( 'open', 'in_progress', 'resolved', 'closed' ), true ) ) {
$where .= $wpdb->prepare( ' AND status = %s', $status );
}
$tickets = $wpdb->get_results( $wpdb->prepare(
"SELECT * FROM {$table} {$where} ORDER BY created_at DESC LIMIT %d OFFSET %d",
$per_page,
$offset
) );
if ( null === $tickets ) {
return new WP_Error(
'rest_db_error',
'Failed to retrieve tickets. Please try again.',
array( 'status' => 500 )
);
}
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} {$where}" );
$data = array();
foreach ( $tickets as $ticket ) {
$data[] = $this->prepare_item_for_response( $ticket, $request );
}
$response = rest_ensure_response( $data );
$response->header( 'X-WP-Total', $total );
$response->header( 'X-WP-TotalPages', ceil( $total / $per_page ) );
return $response;
}
public function create_item_permissions_check( $request ) {
if ( ! is_user_logged_in() ) {
return new WP_Error(
'rest_not_logged_in',
'You must be logged in to create tickets.',
array( 'status' => 401 )
);
}
// Check if user has an active subscription
$has_subscription = wpkite_user_has_active_subscription( get_current_user_id() );
if ( ! $has_subscription ) {
return new WP_Error(
'rest_no_subscription',
'An active subscription is required to submit tickets.',
array( 'status' => 403 )
);
}
return true;
}
public function create_item( $request ) {
global $wpdb;
$table = $wpdb->prefix . 'wpkite_tickets';
$subject = sanitize_text_field( $request->get_param( 'subject' ) );
$description = sanitize_textarea_field( $request->get_param( 'description' ) );
$priority = sanitize_text_field( $request->get_param( 'priority' ) );
// Validate required fields
if ( empty( $subject ) ) {
return new WP_Error(
'rest_missing_subject',
'Ticket subject is required.',
array( 'status' => 400 )
);
}
if ( strlen( $subject ) > 200 ) {
return new WP_Error(
'rest_subject_too_long',
'Ticket subject must be under 200 characters.',
array( 'status' => 400 )
);
}
if ( empty( $description ) ) {
return new WP_Error(
'rest_missing_description',
'Ticket description is required.',
array( 'status' => 400 )
);
}
$inserted = $wpdb->insert( $table, array(
'user_id' => get_current_user_id(),
'subject' => $subject,
'description' => $description,
'priority' => in_array( $priority, array( 'low', 'medium', 'high' ), true ) ? $priority : 'medium',
'status' => 'open',
'created_at' => current_time( 'mysql' ),
), array( '%d', '%s', '%s', '%s', '%s', '%s' ) );
if ( false === $inserted ) {
return new WP_Error(
'rest_db_error',
'Failed to create ticket. Please try again.',
array( 'status' => 500 )
);
}
$ticket_id = $wpdb->insert_id;
$ticket = $wpdb->get_row( $wpdb->prepare(
"SELECT * FROM {$table} WHERE id = %d",
$ticket_id
) );
// Fire action for notifications
do_action( 'wpkite_ticket_created', $ticket );
$response = rest_ensure_response( $this->prepare_item_for_response( $ticket, $request ) );
$response->set_status( 201 );
$response->header(
'Location',
rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $ticket_id ) )
);
return $response;
}
public function prepare_item_for_response( $ticket, $request ) {
return array(
'id' => (int) $ticket->id,
'subject' => $ticket->subject,
'description' => $ticket->description,
'status' => $ticket->status,
'priority' => $ticket->priority,
'created_at' => mysql2date( 'c', $ticket->created_at ),
);
}
private function get_create_params() {
return array(
'subject' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => function( $value ) {
return ! empty( $value ) && strlen( $value ) <= 200;
},
'description' => 'The ticket subject line.',
),
'description' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_textarea_field',
'description' => 'Detailed description of the issue.',
),
'priority' => array(
'default' => 'medium',
'type' => 'string',
'enum' => array( 'low', 'medium', 'high' ),
'description' => 'Ticket priority level.',
),
);
}
}
Error Response Standards
WordPress REST API error responses follow a consistent format through WP_Error. Stick to these conventions so consumers can handle errors predictably.
// Good: Specific error codes, helpful messages, correct HTTP status
return new WP_Error(
'wpkite_ticket_not_found',
sprintf( 'Ticket with ID %d was not found.', $ticket_id ),
array( 'status' => 404 )
);
// Good: Validation error with field-level details
return new WP_Error(
'wpkite_validation_failed',
'One or more fields failed validation.',
array(
'status' => 400,
'errors' => array(
'subject' => 'Subject is required and must be under 200 characters.',
'description' => 'Description cannot be empty.',
),
)
);
// Bad: Generic error that tells the consumer nothing useful
return new WP_Error( 'error', 'Something went wrong.', array( 'status' => 500 ) );
Use specific, namespaced error codes (wpkite_ticket_not_found rather than not_found) so that consumers can distinguish between your custom errors and WordPress core errors. Include the HTTP status code in the error data array; WordPress uses this to set the response status code automatically.
Request Validation at the Schema Level
Let WordPress handle validation through the args array in your route registration. When you define validate_callback and sanitize_callback for each argument, WordPress validates and sanitizes before your callback runs. If validation fails, WordPress returns a 400 error automatically with details about which parameter failed.
'args' => array(
'email' => array(
'required' => true,
'type' => 'string',
'format' => 'email',
'sanitize_callback' => 'sanitize_email',
'validate_callback' => function( $value ) {
if ( ! is_email( $value ) ) {
return new WP_Error(
'invalid_email',
'Please provide a valid email address.'
);
}
return true;
},
),
'site_url' => array(
'required' => true,
'type' => 'string',
'format' => 'uri',
'sanitize_callback' => 'esc_url_raw',
'validate_callback' => function( $value ) {
if ( ! wp_http_validate_url( $value ) ) {
return new WP_Error(
'invalid_url',
'Please provide a valid, accessible URL.'
);
}
return true;
},
),
'plan' => array(
'required' => true,
'type' => 'string',
'enum' => array( 'solo', 'business', 'agency', 'enterprise' ),
),
),
The enum constraint is particularly useful. WordPress automatically rejects any value not in the allowed list and returns a clear error message. This is cleaner than writing manual in_array() checks in your callback.
Handling Database Errors Gracefully
Database operations can fail for many reasons: table does not exist, connection dropped, constraint violation, disk full. Always check the return value of $wpdb methods.
function wpkite_safe_db_insert( $table, $data, $format ) {
global $wpdb;
$wpdb->suppress_errors( true );
$result = $wpdb->insert( $table, $data, $format );
$wpdb->suppress_errors( false );
if ( false === $result ) {
$error_message = $wpdb->last_error;
// Log the full error for debugging
error_log( sprintf(
'WPKite DB Error on insert to %s: %s | Data: %s',
$table,
$error_message,
wp_json_encode( $data )
) );
// Return a safe error to the client
if ( strpos( $error_message, 'Duplicate entry' ) !== false ) {
return new WP_Error(
'wpkite_duplicate_entry',
'A record with this information already exists.',
array( 'status' => 409 )
);
}
return new WP_Error(
'wpkite_db_error',
'Unable to save your data. Our team has been notified.',
array( 'status' => 500 )
);
}
return $wpdb->insert_id;
}
Note the use of $wpdb->suppress_errors() to prevent PHP warnings from leaking into the JSON response. The function logs detailed error information server-side while returning a generic message to the client. Never expose raw database error messages to API consumers. They can reveal table structures, column names, and other information useful to attackers.
Advanced Patterns: Conditional Requests and ETags
Conditional requests let clients avoid downloading data they already have. This saves bandwidth and server processing time. The pattern uses ETags (entity tags) and the If-None-Match request header.
When the server returns a response, it includes an ETag header with a hash of the response content. The client stores this ETag alongside the cached response. On subsequent requests, the client sends the stored ETag in an If-None-Match header. If the content has not changed, the server returns a 304 Not Modified with no body, saving the bandwidth of retransmitting the full response.
add_filter( 'rest_post_dispatch', 'wpkite_rest_conditional_response', 10, 3 );
function wpkite_rest_conditional_response( $response, $server, $request ) {
if ( 'GET' !== $request->get_method() || $response->is_error() ) {
return $response;
}
$data = $response->get_data();
// Generate ETag from content hash
$etag = '"' . md5( serialize( $data ) . $response->get_status() ) . '"';
$response->header( 'ETag', $etag );
// Generate Last-Modified from the most recent item
$last_modified = wpkite_get_last_modified_from_response( $data );
if ( $last_modified ) {
$response->header( 'Last-Modified', gmdate( 'D, d M Y H:i:s', $last_modified ) . ' GMT' );
}
// Check If-None-Match
$client_etag = $request->get_header( 'if_none_match' );
if ( $client_etag && trim( $client_etag ) === $etag ) {
return new WP_REST_Response( null, 304 );
}
// Check If-Modified-Since
$if_modified = $request->get_header( 'if_modified_since' );
if ( $if_modified && $last_modified ) {
$client_time = strtotime( $if_modified );
if ( $client_time >= $last_modified ) {
return new WP_REST_Response( null, 304 );
}
}
return $response;
}
function wpkite_get_last_modified_from_response( $data ) {
if ( ! is_array( $data ) ) {
return null;
}
$timestamps = array();
// Handle collections
foreach ( $data as $item ) {
if ( is_array( $item ) && isset( $item['modified'] ) ) {
$timestamps[] = strtotime( $item['modified'] );
} elseif ( is_array( $item ) && isset( $item['date'] ) ) {
$timestamps[] = strtotime( $item['date'] );
}
}
// Handle single items
if ( isset( $data['modified'] ) ) {
$timestamps[] = strtotime( $data['modified'] );
}
return ! empty( $timestamps ) ? max( $timestamps ) : null;
}
For a mobile app that polls a “recent posts” endpoint every 30 seconds, conditional requests can reduce bandwidth usage by 90% or more during periods when no new content is published. The server still runs the query and builds the response to compute the ETag, but it avoids transmitting the response body. To eliminate the server-side work as well, combine conditional requests with object caching so the ETag can be checked against a cached value before running any queries.
Connection Pooling and Persistent Database Connections
Each WordPress REST API request opens a new database connection by default. The TCP handshake and MySQL authentication take 1-3ms per connection. Under high concurrency, connection setup overhead becomes significant, and you can also exhaust the MySQL max_connections limit.
Enable persistent connections in wp-config.php by prepending p: to the database host:
define( 'DB_HOST', 'p:localhost' );
Persistent connections reuse existing MySQL connections across PHP-FPM requests. This eliminates the per-request connection overhead but requires careful monitoring of connection count. Each PHP-FPM worker can hold a persistent connection, so your MySQL max_connections must be at least equal to your pm.max_children setting.
An alternative for high-traffic sites is ProxySQL or MySQL Router, which provides connection pooling at the infrastructure level. These tools maintain a pool of database connections and multiplex PHP requests onto them, supporting far more concurrent PHP workers than database connections.
Putting It All Together: A Performance Checklist
After implementing the techniques in this article, here is how to verify everything is working correctly and measure the actual impact on your REST API performance.
Step 1: Establish a baseline. Run load tests against your unoptimized endpoints and record the median and 95th percentile response times, requests per second throughput, and error rate. Use k6 or a similar tool with a realistic traffic pattern.
Step 2: Enable object caching. Install Redis or Memcached and the corresponding WordPress object cache drop-in. Run the same load test. You should see immediate improvements in database query count per request and response time for repeated requests.
Step 3: Add HTTP cache headers. Implement the rest_post_dispatch cache header filter. Verify headers with curl -I. CDNs and reverse proxies will start caching responses automatically.
Step 4: Implement rate limiting. Add the rest_pre_dispatch rate limiter. Test that it correctly returns 429 status codes when limits are exceeded. Verify that rate limit headers appear on all responses.
Step 5: Optimize high-traffic endpoints. Identify your most-requested endpoints from access logs. For these endpoints, consider custom lightweight controllers, the _fields parameter, or composite endpoints that combine multiple data fetches.
Step 6: Choose the right authentication. Match your auth method to your use case. Use JWT for high-frequency SPA requests, Application Passwords for server-to-server integrations, and cookie auth for same-domain WordPress admin interactions.
Step 7: Monitor continuously. Set up logging in your middleware to track response times, error rates, and cache hit rates over time. Use the X-Request-ID header to correlate requests across your logging infrastructure. Watch for gradual performance regression as content volume grows.
The performance gains from these optimizations compound. Rate limiting prevents resource abuse. Caching eliminates redundant computation. Field filtering reduces serialization and transfer overhead. Proper authentication minimizes per-request cryptographic overhead. Load testing validates that your optimizations hold under realistic conditions. Together, these techniques can take a WordPress REST API from handling 50 requests per second to handling 2,000 or more, using the same hardware.
The WordPress REST API was designed for extensibility, and these filter hooks give you fine-grained control over every stage of request processing. The key is treating your REST API like the production infrastructure it is: instrumented, cached, rate-limited, and tested under load before your users find the limits for you.
David Okonkwo
Application security engineer focused on WordPress. OWASP contributor and former penetration tester. Writes about REST API security, authentication, and hardening.