JavaScript, AJAX and Heartbeat
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
Most plugins need some JavaScript that talks back to WordPress. WordPress offers three server endpoints for that
(admin-ajax.php, the REST API and the Heartbeat API) and two ways to load the code (classic scripts and, since 6.5,
script modules). This page, for plugin developers, builds a "Mark as read" button for the book pages of
lumen-catalog, the catalogue plugin of the fictitious publisher Lumen Press, with jQuery and admin-ajax.php, then
with @wordpress/api-fetch and a REST route, plus a live reader count on the book edit screen fed by Heartbeat.
This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the
version to run in production), which bundles jQuery 3.7.1 and jQuery Migrate 3.4.1, PHP 7.4 minimum and 8.3+
recommended, and WP-CLI 2.12.0, and checked on 2026-10-28. WordPress 7.2 is in beta and
due in December 2026; nothing on this page depends on it. Every request and response shown was captured on a local
site (wordpress:7.1.3-php8.4-apache, MariaDB 11.8, the default Twenty Twenty-Five theme) at
https://lumenpress.test, with the users maria.editor (Editor) and tom.author (Author); the stack is the one in
Local Development Environments.
Enqueue parameters and the defer/async strategies are covered in
functions.php, Theme Supports and Assets
(the same functions apply to plugins), nonces and capabilities in
Security for Developers.
Three transports
admin-ajax.php |
REST API | Heartbeat API | |
|---|---|---|---|
Endpoint |
|
One URL per resource, such as |
|
Since |
2.1 ( |
4.7 (content endpoints; infrastructure 4.4) |
3.6 |
Registered with |
|
|
|
CSRF protection |
A nonce the handler must check with |
The |
A |
Response |
Whatever the handler prints; by convention |
JSON with the right status code, discoverable under |
One merged JSON object for every plugin that answered |
Loads |
The full |
The front-end bootstrap; no |
Same as |
Use for |
Existing code, and admin screens that already use it |
New code: anything a script reads or changes |
Periodic, low-priority polling on screens that already run Heartbeat |
The REST API is the preferred transport for new code: routes are self-describing, arguments are validated against a
schema before the callback runs, errors carry HTTP status codes, and the same endpoint serves the block editor, a
headless front end and WP-CLI. admin-ajax.php is not deprecated, and existing code need not be rewritten for that.
The feature and its data
Readers who are logged in can mark a book as read. Each mark is one user meta row with the key lp_read_book and
the book ID as its value, so marking a book twice changes nothing and "how many people read this book" is a user
query. The code lives in includes/reading-list.php, which the main plugin file loads with require_once; the
helpers at its top are shared by all three transports:
<?php
// lumen-catalog/includes/reading-list.php: "Mark as read".
defined( 'ABSPATH' ) || exit;
const LUMEN_CATALOG_READ_META = 'lp_read_book';
function lumen_catalog_is_book( $book_id ) {
return $book_id > 0 && 'lp_book' === get_post_type( $book_id ) && 'publish' === get_post_status( $book_id );
}
function lumen_catalog_has_read( $user_id, $book_id ) {
return in_array( (string) $book_id, get_user_meta( $user_id, LUMEN_CATALOG_READ_META ), true );
}
// One meta row per book; returns how many books the user has read.
function lumen_catalog_mark_read( $user_id, $book_id ) {
if ( ! lumen_catalog_has_read( $user_id, $book_id ) ) {
add_user_meta( $user_id, LUMEN_CATALOG_READ_META, (string) $book_id );
}
return count( get_user_meta( $user_id, LUMEN_CATALOG_READ_META ) );
}
function lumen_catalog_reader_count( $book_id ) {
$query = new WP_User_Query(
array( 'meta_key' => LUMEN_CATALOG_READ_META, 'meta_value' => (string) $book_id, 'fields' => 'ID', 'number' => 1 ) // phpcs:ignore WordPress.DB.SlowDBQuery
);
return (int) $query->get_total();
}
Source: Code Reference: add_user_meta()
get_user_meta() without its third argument returns every value of the key as strings, hence the string comparison.
The reader count is a meta query on wp_usermeta, which is fine for a publisher’s few thousand accounts; a site with
millions of marks would keep them in a custom table. Meta storage itself is explained in
Metadata and Custom Fields.
The button is appended to single book pages (block themes run the_content in the Post Content block); its
role="status" span is a live region, so screen readers announce the result:
add_filter( 'the_content', 'lumen_catalog_read_button' );
function lumen_catalog_read_button( $content ) {
if ( ! is_singular( 'lp_book' ) || ! is_main_query() ) {
return $content;
}
$book_id = get_the_ID();
$done = is_user_logged_in() && lumen_catalog_has_read( get_current_user_id(), $book_id );
return $content . sprintf(
'<p><button type="button" class="lumen-mark-read" data-book="%1$d"%2$s>%3$s</button> <span class="lumen-mark-read-status" role="status"></span></p>',
$book_id,
disabled( $done, true, false ),
esc_html( $done ? __( 'Read', 'lumen-catalog' ) : __( 'Mark as read', 'lumen-catalog' ) )
);
}
Source: Code Reference: the_content
Enqueueing the scripts
Scripts reach the page through wp_enqueue_script() on wp_enqueue_scripts (front end) or admin_enqueue_scripts
(admin screens), never through a <script> tag printed by hand. The dependencies name core handles, and WordPress
prints them first, once: jquery for the first variant, wp-api-fetch and wp-i18n for the second. A filter
selects the variant, so both can be tried on the same site:
add_action( 'wp_enqueue_scripts', 'lumen_catalog_enqueue_mark_read' );
function lumen_catalog_enqueue_mark_read() {
if ( ! is_singular( 'lp_book' ) ) {
return;
}
$args = array( 'strategy' => 'defer', 'in_footer' => true );
/** Filters the transport of the "Mark as read" button: 'rest' (default) or 'ajax'. */
if ( 'ajax' === apply_filters( 'lumen_catalog_mark_read_transport', 'rest' ) ) {
wp_enqueue_script( 'lumen-mark-read-ajax', plugins_url( 'assets/js/mark-read-ajax.js', __DIR__ ), array( 'jquery' ), '0.1.0', $args );
wp_localize_script(
'lumen-mark-read-ajax',
'lumenMarkRead',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'lumen_mark_read' ),
'loggedIn' => is_user_logged_in(),
'readLabel' => __( 'Read', 'lumen-catalog' ),
/* translators: %d: number of books read. */
'countLabel' => __( 'Books read: %d', 'lumen-catalog' ),
'errorLabel' => __( 'Something went wrong.', 'lumen-catalog' ),
)
);
return;
}
wp_enqueue_script( 'lumen-mark-read', plugins_url( 'assets/js/mark-read.js', __DIR__ ), array( 'wp-api-fetch', 'wp-i18n' ), '0.1.0', $args );
wp_set_script_translations( 'lumen-mark-read', 'lumen-catalog' );
wp_add_inline_script( 'lumen-mark-read', 'window.lumenMarkRead = ' . wp_json_encode( array( 'loggedIn' => is_user_logged_in() ) ) . ';', 'before' );
}
Source: Plugin Handbook: Enqueuing
The $args array (6.3) asks for defer, which a script depending on blocking jQuery may still use; the
is_singular( 'lp_book' ) guard keeps the files off every other page. Code built with @wordpress/scripts writes
import apiFetch from '@wordpress/api-fetch' instead, and the generated index.asset.php lists wp-api-fetch and a
content-hash version (see Block Development Environment).
Passing data from PHP to a script
Values that depend on the request (the endpoint URL, a nonce, the user’s state) are printed next to the cached script file as inline JavaScript, by one of two functions.
wp_localize_script()
wp_localize_script( $handle, $object_name, $l10n ) (2.2) prints var <object_name> = {...}; in a -js-extra
block before the handle. Logged in as tom.author, the AJAX variant printed:
<script id="lumen-mark-read-ajax-js-extra">
var lumenMarkRead = {"ajaxUrl":"https://lumenpress.test/wp-admin/admin-ajax.php","nonce":"3e2db77474","loggedIn":"1","readLabel":"Read","countLabel":"Books read: %d","errorLabel":"Something went wrong."};
//# sourceURL=lumen-mark-read-ajax-js-extra
</script>
<script data-wp-strategy="defer" defer id="lumen-mark-read-ajax-js" src="https://lumenpress.test/wp-content/plugins/lumen-catalog/assets/js/mark-read-ajax.js?ver=0.1.0"></script>
Made for translated strings, it casts every top-level scalar to a string through html_entity_decode(): true
became "1" (logged out, false became ""), and only nested arrays keep their types. Since 5.7 a non-array third
argument triggers a "called incorrectly" notice pointing to wp_add_inline_script().
wp_add_inline_script()
wp_add_inline_script( $handle, $data, $position ) (4.5) prints arbitrary JavaScript before or after (the
default) the handle. With wp_json_encode() the types survive: the REST variant printed
window.lumenMarkRead = {"loggedIn":true}; in a <script id="lumen-mark-read-js-before"> block, followed by the
same deferred tag for mark-read.js.
Use before: an after inline script makes a deferred handle load blocking (see
functions.php, Theme Supports and Assets).
Never concatenate raw PHP strings into JavaScript; wp_json_encode() escapes quotes, so a value cannot break out. The
REST variant passes no labels: wp_set_script_translations() loads the handle’s JSON translations and the script
calls __() from wp.i18n (see Internationalization).
jQuery in noConflict mode
WordPress registers jQuery under the handle jquery, an alias that loads jquery-core (3.7.1) and jquery-migrate
(3.4.1). The bundled copy calls jQuery.noConflict(), so the global $ is not jQuery; Prototype-era libraries
and other code may own it. A script that starts with $( '.lumen-mark-read' ) fails with "$ is not a function" (or,
worse, calls something else). Wrap the code in a function that receives jQuery as $:
/* global jQuery, lumenMarkRead */
( function ( $ ) {
'use strict';
$( function () {
$( '.lumen-mark-read' ).on( 'click', function () {
var $button = $( this );
var $status = $button.siblings( '.lumen-mark-read-status' );
$button.prop( 'disabled', true );
$.post( lumenMarkRead.ajaxUrl, { action: 'lumen_mark_read', nonce: lumenMarkRead.nonce, book: $button.data( 'book' ) } )
.done( function ( response ) {
$button.text( lumenMarkRead.readLabel );
$status.text( lumenMarkRead.countLabel.replace( '%d', response.data.count ) );
} )
.fail( function ( xhr ) {
var body = xhr.responseJSON;
$button.prop( 'disabled', false );
$status.text( body && body.data ? body.data.message : lumenMarkRead.errorLabel );
} );
} );
} );
}( jQuery ) );
Source: Plugin Handbook: jQuery
jQuery( function ( $ ) { ... } ) is the other common wrapper. The file is assets/js/mark-read-ajax.js; it writes
with .text(), never .html(), and takes the URL from lumenMarkRead because the global ajaxurl is defined only
on admin screens. New front-end code rarely needs jQuery at all: fetch() and querySelectorAll() cover its uses.
admin-ajax.php handlers
A request to /wp-admin/admin-ajax.php defines DOING_AJAX, loads WordPress with the admin bootstrap (so
admin_init fires), reads $_REQUEST['action'] and fires one of two hooks:
-
wp_ajax_{$action}when the request carries valid login cookies, for users of any role; -
wp_ajax_nopriv_{$action}when it does not.
Neither hook checks anything about the user (a Subscriber reaches wp_ajax_* handlers too), so every handler
checks a nonce and a capability:
add_action( 'wp_ajax_lumen_mark_read', 'lumen_catalog_ajax_mark_read' );
add_action( 'wp_ajax_nopriv_lumen_mark_read', 'lumen_catalog_ajax_mark_read_nopriv' );
function lumen_catalog_ajax_mark_read() {
check_ajax_referer( 'lumen_mark_read', 'nonce' ); // Dies with -1 (HTTP 403) when the nonce is missing or invalid.
if ( ! current_user_can( 'read' ) ) {
wp_send_json_error( array( 'message' => __( 'You cannot keep a reading list.', 'lumen-catalog' ) ), 403 );
}
$book_id = isset( $_POST['book'] ) ? absint( wp_unslash( $_POST['book'] ) ) : 0;
if ( ! lumen_catalog_is_book( $book_id ) ) {
wp_send_json_error( array( 'message' => __( 'Unknown book.', 'lumen-catalog' ) ), 400 );
}
$count = lumen_catalog_mark_read( get_current_user_id(), $book_id );
wp_send_json_success( array( 'book' => $book_id, 'read' => true, 'count' => $count ) );
}
function lumen_catalog_ajax_mark_read_nopriv() {
wp_send_json_error( array( 'message' => __( 'Log in to keep a reading list.', 'lumen-catalog' ) ), 401 );
}
Source: Plugin Handbook: AJAX
check_ajax_referer( $action, $query_arg ) verifies the nonce made by wp_create_nonce( 'lumen_mark_read' );
without a second argument it reads $_REQUEST['_ajax_nonce'], then $_REQUEST['_wpnonce'], and on failure it
calls wp_die( -1, 403 ). A nonce is bound to the user, the session and the action, lives 12 to 24 hours and stops
cross-site request forgery; it authorizes nothing, hence the capability check (read suits a personal list; changing
catalogue data would need edit_post on the book). wp_send_json_success() and wp_send_json_error() print
{"success":true|false,"data":...} with a JSON content type and the given status (a parameter since 4.7), then end
with wp_die(), so no die() follows them. The nopriv handler is optional: without it a logged-out request ends
with 0 and status 400, which a script cannot tell apart from a misspelt action.
What admin-ajax.php answers
AJAX=https://lumenpress.test/wp-admin/admin-ajax.php
curl -s -b tom.jar -w ' %{http_code}\n' "$AJAX" -d action=lumen_mark_read -d book=4 # no nonce
curl -s -b tom.jar -w ' %{http_code}\n' "$AJAX" -d action=lumen_mark_read -d book=4 -d nonce=0123456789 # forged
NONCE=$(curl -s -b tom.jar https://lumenpress.test/books/the-quiet-lighthouse/ | grep 'var lumenMarkRead' | grep -o '"nonce":"[0-9a-f]*"' | cut -d'"' -f4)
curl -s -b tom.jar -w ' %{http_code}\n' "$AJAX" -d action=lumen_mark_read -d book=4 -d nonce=$NONCE # valid
curl -s -b tom.jar -w ' %{http_code}\n' "$AJAX" -d action=lumen_mark_read -d book=1 -d nonce=$NONCE # a post, not a book
curl -s -w ' %{http_code}\n' "$AJAX" -d action=lumen_mark_read -d book=4 # logged out
curl -s -b tom.jar -w ' %{http_code}\n' "$AJAX" -d action=lumen_nope # no such action
curl -s -b maria.jar -w ' %{http_code}\n' "$AJAX" -d action=lumen_mark_read -d book=4 -d nonce=$NONCE # Tom's nonce, Maria's cookies
tom.jar and maria.jar hold login cookies obtained by posting to wp-login.php, as in
Security for Developers. In order:
-1 403
-1 403
{"success":true,"data":{"book":4,"read":true,"count":1}} 200
{"success":false,"data":{"message":"Unknown book."}} 400
{"success":false,"data":{"message":"Log in to keep a reading list."}} 401
0 400
-1 403
A bare -1 always means a failed nonce, a bare 0 means that no handler ran (unknown action, or a logged-out request
without a nopriv handler), and anything else is what the handler printed. The last line shows that a nonce leaked
from one account is useless with another account’s cookies. wp user meta list tom.author --keys=lp_read_book then
lists one row, lp_read_book 4.
The same feature over the REST API
The route
add_action( 'rest_api_init', 'lumen_catalog_register_read_route' );
function lumen_catalog_register_read_route() {
register_rest_route(
'lumen/v1',
'/books/(?P<id>\d+)/read',
array(
'methods' => WP_REST_Server::CREATABLE,
'permission_callback' => function () {
return current_user_can( 'read' );
},
'args' => array( 'id' => array( 'type' => 'integer', 'minimum' => 1 ) ),
'callback' => 'lumen_catalog_rest_mark_read',
)
);
}
function lumen_catalog_rest_mark_read( WP_REST_Request $request ) {
$book_id = (int) $request['id'];
if ( ! lumen_catalog_is_book( $book_id ) ) {
return new WP_Error( 'lp_book_not_found', __( 'Unknown book.', 'lumen-catalog' ), array( 'status' => 404 ) );
}
return array( 'book' => $book_id, 'read' => true, 'count' => lumen_catalog_mark_read( get_current_user_id(), $book_id ) );
}
WP_REST_Server::CREATABLE is POST. There is no nonce code in the handler: for cookie-authenticated requests core
checks the wp_rest nonce before any route runs, and the permission_callback replaces the capability check. The
route sits next to the book routes of the plugin’s REST controller in the lumen/v1 namespace, described in
REST API: Extending It.
The client with @wordpress/api-fetch
@wordpress/api-fetch is a thin wrapper around fetch() that core ships as the wp-api-fetch script (global
wp.apiFetch). Without a build step, the script takes it from window.wp:
( ( { apiFetch, i18n }, settings ) => {
const { __, _n, sprintf } = i18n;
const onClick = async ( event ) => {
const button = event.currentTarget;
const status = button.parentElement.querySelector( '.lumen-mark-read-status' );
if ( ! settings.loggedIn ) {
status.textContent = __( 'Log in to keep a reading list.', 'lumen-catalog' );
return;
}
button.disabled = true;
try {
const result = await apiFetch( { path: `/lumen/v1/books/${ button.dataset.book }/read`, method: 'POST' } );
button.textContent = __( 'Read', 'lumen-catalog' );
status.textContent = sprintf(
/* translators: %d: number of books read. */
_n( '%d book read', '%d books read', result.count, 'lumen-catalog' ),
result.count
);
} catch ( error ) {
button.disabled = false;
status.textContent = error.message || __( 'Something went wrong.', 'lumen-catalog' );
}
};
document.querySelectorAll( '.lumen-mark-read' ).forEach( ( button ) => button.addEventListener( 'click', onClick ) );
} )( window.wp, window.lumenMarkRead );
The file is lumen-catalog/assets/js/mark-read.js. It needs no URL and no nonce from PHP, because enqueueing
wp-api-fetch makes core print its middlewares right after the library:
wp.apiFetch.use( wp.apiFetch.createRootURLMiddleware( "https://lumenpress.test/wp-json/" ) );
wp.apiFetch.nonceMiddleware = wp.apiFetch.createNonceMiddleware( "f3b031b72b" );
wp.apiFetch.use( wp.apiFetch.nonceMiddleware );
wp.apiFetch.use( wp.apiFetch.mediaUploadMiddleware );
wp.apiFetch.nonceEndpoint = "https://lumenpress.test/wp-admin/admin-ajax.php?action=rest-nonce";
So every apiFetch() call resolves path against the REST root (also with plain permalinks), sends the wp_rest
nonce as X-WP-Nonce with the cookies, parses the JSON, and rejects with the error object on a 4xx or 5xx. On
rest_cookie_invalid_nonce it fetches a fresh nonce from nonceEndpoint and retries. PUT, PATCH and DELETE
travel as POST with X-HTTP-Method-Override. Use plain fetch() for other sites, which would reject this nonce.
What the REST route answers
API=https://lumenpress.test/wp-json/lumen/v1/books
curl -s -b maria.jar -w ' %{http_code}\n' -X POST "$API/4/read" # cookies, no nonce
RN=$(curl -s -b maria.jar "https://lumenpress.test/wp-admin/admin-ajax.php?action=rest-nonce")
curl -s -b maria.jar -H "X-WP-Nonce: $RN" -w ' %{http_code}\n' -X POST "$API/4/read" # cookies + nonce
curl -s -b maria.jar -H "X-WP-Nonce: $RN" -w ' %{http_code}\n' -X POST "$API/4/read" # again
curl -s -b maria.jar -H "X-WP-Nonce: $RN" -w ' %{http_code}\n' -X POST "$API/1/read" # not a book
curl -s -b maria.jar -H "X-WP-Nonce: 0123456789" -w ' %{http_code}\n' -X POST "$API/4/read" # bad nonce
{"code":"rest_forbidden","message":"Sorry, you are not allowed to do that.","data":{"status":401}} 401
{"book":4,"read":true,"count":1} 200
{"book":4,"read":true,"count":1} 200
{"code":"lp_book_not_found","message":"Unknown book.","data":{"status":404}} 404
{"code":"rest_cookie_invalid_nonce","message":"Cookie check failed","data":{"status":403}} 403
The first line is the trap of cookie authentication: login cookies without the nonce silently run as user 0, hence
401, while a wrong nonce is rejected with 403 rest_cookie_invalid_nonce. Marking twice is idempotent. Application
passwords need no nonce; they suit scripts and servers, not browsers.
The Heartbeat API
Heartbeat (3.6) is a polling loop on admin screens: each tick, wp.heartbeat posts the screen ID and a data object
any script can add to, with action=heartbeat, and hands the merged response to every listener. Core uses it for post
locking, autosave, the login-expired dialog (wp-auth-check, on every admin screen) and nonce refresh.
Each tick has three steps a plugin can join. Send: the browser fires the jQuery event heartbeat-send and the
wp.hooks action heartbeat.send, and listeners add keys to the outgoing data. Receive: wp_ajax_heartbeat()
checks the heartbeat-nonce, applies the heartbeat_received filter (only when data was sent), then heartbeat_send,
and prints JSON (heartbeat_nopriv_received and heartbeat_nopriv_send for logged-out pages). Tick: the browser
fires heartbeat-tick and heartbeat.tick with the response.
A live reader count on the edit screen
On the book edit screen, a meta box shows the reader count and Heartbeat keeps it current:
add_action( 'add_meta_boxes_lp_book', 'lumen_catalog_add_readers_box' );
function lumen_catalog_add_readers_box( $post ) {
$render = function ( $post ) {
/* translators: %s: number of readers. */
printf( esc_html__( 'Marked as read by %s users.', 'lumen-catalog' ), '<strong id="lumen-readers-count">' . (int) lumen_catalog_reader_count( $post->ID ) . '</strong>' );
};
add_meta_box( 'lumen-readers', __( 'Readers', 'lumen-catalog' ), $render, 'lp_book', 'side' );
}
add_action( 'admin_enqueue_scripts', 'lumen_catalog_enqueue_readers_heartbeat' );
function lumen_catalog_enqueue_readers_heartbeat( $hook_suffix ) {
$screen = get_current_screen();
if ( 'post.php' !== $hook_suffix || ! $screen || 'lp_book' !== $screen->post_type ) {
return;
}
wp_enqueue_script( 'lumen-readers-heartbeat', plugins_url( 'assets/js/readers-heartbeat.js', __DIR__ ), array( 'heartbeat', 'wp-hooks' ), '0.1.0', true );
wp_add_inline_script( 'lumen-readers-heartbeat', 'window.lumenReaders = ' . wp_json_encode( array( 'postId' => get_the_ID() ) ) . ';', 'before' );
}
add_filter( 'heartbeat_received', 'lumen_catalog_heartbeat_readers', 10, 3 );
function lumen_catalog_heartbeat_readers( $response, $data, $screen_id ) {
if ( 'lp_book' !== $screen_id || empty( $data['lumen_readers'] ) ) {
return $response;
}
$book_id = absint( $data['lumen_readers'] );
if ( ! current_user_can( 'edit_post', $book_id ) ) {
return $response;
}
$response['lumen_readers'] = lumen_catalog_reader_count( $book_id );
return $response;
}
Source: Plugin Handbook: Heartbeat API
The filter returns $response untouched unless its own key is present, and checks a capability because the data
comes from the browser; the edit screen’s ID is the post type, lp_book. The script, assets/js/readers-heartbeat.js,
uses the wp.hooks actions instead of the jQuery events (both fire on every tick):
( ( { hooks, heartbeat }, { postId } ) => {
hooks.addAction( 'heartbeat.send', 'lumen-catalog/readers', ( data ) => {
data.lumen_readers = postId;
} );
hooks.addAction( 'heartbeat.tick', 'lumen-catalog/readers', ( response ) => {
const output = document.getElementById( 'lumen-readers-count' );
if ( output && undefined !== response.lumen_readers ) {
output.textContent = String( response.lumen_readers );
}
} );
// Every 15 seconds on this screen only; the rest of the admin keeps its interval.
heartbeat.interval( 15 );
} )( window.wp, window.lumenReaders );
Source: Plugin Handbook: Heartbeat API
The jQuery form from the handbook, jQuery( document ).on( 'heartbeat-send', function ( event, data ) { ... } ),
works the same way. The element is looked up on each tick because the block editor moves meta boxes into its own
panel after the scripts run.
One tick, by hand
The heartbeat nonce is in heartbeatSettings, printed on every admin screen:
PAGE=$(curl -s -b maria.jar "https://lumenpress.test/wp-admin/post.php?post=4&action=edit")
HB=$(echo "$PAGE" | grep 'var heartbeatSettings' | grep -o '"nonce":"[0-9a-f]*"' | cut -d'"' -f4)
curl -s -b maria.jar https://lumenpress.test/wp-admin/admin-ajax.php \
-d action=heartbeat -d _nonce=$HB -d screen_id=lp_book -d interval=15 -d 'data[lumen_readers]=4'
{"lumen_readers":2,"wp-auth-check":true,"server_time":1793198732}
Tom (AJAX) and Maria (REST) both marked the book, hence 2; wp-auth-check is core’s login check on the same tick. With
Tom’s cookies there is no lumen_readers key (an Author cannot edit this book). When the heartbeat nonce is over 12
hours old, the response also carries fresh rest_nonce and heartbeat_nonce values, so long-open editors keep saving.
Tuning the interval
The default interval is 60 seconds. A site tunes Heartbeat for the whole admin by filtering heartbeatSettings:
<?php
// wp-content/mu-plugins/lumen-heartbeat.php
add_filter(
'heartbeat_settings',
function ( $settings ) {
$settings['interval'] = 120; // Default tick, in seconds (1-3600).
$settings['minimalInterval'] = 30; // Floor that no script can go below (up to 600).
return $settings;
}
);
The edit screen then printed var heartbeatSettings = {"nonce":"...","interval":"120","minimalInterval":"30","suspension":"disable"};
(wp_localize_script() again, hence the strings). Other levers, all in heartbeat.js:
-
wp.heartbeat.interval( seconds )changes it for the current page;'fast'means 5 seconds for 30 ticks. With the filter above, the reader count’s 15 seconds is raised to the 30-second floor. Aheartbeat_receivedfilter can also returnheartbeat_intervalto change it from the server. -
In a hidden tab the interval becomes 120 seconds; Heartbeat suspends after 10 minutes of inactivity (except on the post edit screens, where core sets
suspensiontodisable) and always after 60 minutes, which releases post locks.
Every tick is an uncached PHP request with the full admin bootstrap: ten editors with three tabs at 15 seconds make
two requests per second. Raise interval or minimalInterval rather than deregistering heartbeat (an old
performance tip), which removes post locking (two editors overwrite each other), classic-editor autosave, the
login-expired dialog and nonce refresh. Heartbeat runs on the front end only if a script enqueues it there.
Script modules
Since 6.5, wp_register_script_module() and wp_enqueue_script_module() load native ES modules: type="module"
tags plus an import map, deferred by nature, whose dependencies are other modules (@wordpress/interactivity,
@wordpress/a11y), not classic handles. Since 6.7 a module receives data through the
script_module_data_{$module_id} filter instead of wp_localize_script(). WordPress 7.1.3 ships no
@wordpress/api-fetch module, so a module calls the REST API with fetch() and a nonce passed as module data. On the
front end of blocks, the Interactivity API (6.5) builds on script modules and replaces hand-written handlers like the
ones on this page; the lumen/wishlist block in Interactivity API does so.
Where older books and tutorials are outdated
| Older advice or example | Current practice |
|---|---|
jQuery code that uses a bare |
WordPress’s jQuery runs in noConflict mode, so |
An |
Any page on any site can make a logged-in user’s browser post to it (CSRF). Check the nonce with |
End a handler with |
|
Use the global |
|
Print |
Enqueue with dependencies on core handles; replacing core’s jQuery breaks the admin and other plugins |
Disable Heartbeat entirely to save server load |
Raise |
More corrections are collected in Where Older Books and Tutorials Are Outdated.
Common errors and pitfalls
-
-1or0fromadmin-ajax.php.-1is a failed nonce (mismatched action or field name, a nonce from another user or one older than 24 hours);0means no handler ran (misspelt action,add_action()in code that does not load on AJAX requests such as a theme template, or a logged-out user with nonoprivhandler). -
REST 401 for a logged-in user. Cookies without
X-WP-Noncerun as user 0. UseapiFetch, or send the nonce fromwp_create_nonce( 'wp_rest' ). -
Nonces in cached pages. A nonce in cached HTML belongs to whoever filled the cache and expires within a day. Keep logged-in users out of the page cache and fetch nonces at run time where pages must be cached (see Performance and Caching).
-
Scripts that vanish or block. An unregistered handle in
$depsdrops the dependent script (with a notice underWP_DEBUGsince 6.9.1; the handle iswp-api-fetch, notapi-fetch), and anafterinline script cancelsdefer. -
Slow AJAX.
admin_initruns on every AJAX call and Heartbeat tick; guard heavy work withwp_doing_ajax(). -
Heartbeat data without checks.
heartbeat_receivedtakes browser input like any endpoint: sanitize, check capabilities, and return$responseunchanged when the key is not yours.