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

/wp-admin/admin-ajax.php, one URL, the handler chosen by the action field

One URL per resource, such as /wp-json/lumen/v1/books/4/read, with HTTP methods

admin-ajax.php with action=heartbeat, on a timer

Since

2.1 (wp_ajax_nopriv_* 2.8)

4.7 (content endpoints; infrastructure 4.4)

3.6

Registered with

wp_ajax_{$action} and wp_ajax_nopriv_{$action} actions

register_rest_route() on rest_api_init

heartbeat_received filter (logged in), heartbeat_nopriv_received (logged out)

CSRF protection

A nonce the handler must check with check_ajax_referer()

The wp_rest nonce in X-WP-Nonce, checked by core for cookie authentication

A heartbeat-nonce checked by core

Response

Whatever the handler prints; by convention wp_send_json_success() / wp_send_json_error()

JSON with the right status code, discoverable under /wp-json/

One merged JSON object for every plugin that answered

Loads

The full wp-admin bootstrap and fires admin_init

The front-end bootstrap; no admin_init

Same as admin-ajax.php

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();
}

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' ) )
	);
}

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' );
}

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 ) );

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 );
}

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;
}

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 );

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. A heartbeat_received filter can also return heartbeat_interval to 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 suspension to disable) 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 $ at the top level of a plugin file (as in Pro WordPress)

WordPress’s jQuery runs in noConflict mode, so $ is undefined or belongs to another library. Wrap the code in ( function ( $ ) { ... } )( jQuery ) or use jQuery explicitly

An admin-ajax.php handler that reads $_POST and writes data with no nonce and no capability check (also Pro WordPress)

Any page on any site can make a logged-in user’s browser post to it (CSRF). Check the nonce with check_ajax_referer() and a capability with current_user_can(), then sanitize the input

End a handler with echo json_encode( ... ); die();

wp_send_json_success() / wp_send_json_error() set the JSON content type and status code and end with wp_die(), which tests can intercept

Use the global ajaxurl in front-end scripts, and wp_localize_script() for any data

ajaxurl exists only on admin screens; pass the URL, or use wp-api-fetch. Pass typed data with wp_add_inline_script() and wp_json_encode()

Print <script> tags in wp_head, or load a newer jQuery from a CDN

Enqueue with dependencies on core handles; replacing core’s jQuery breaks the admin and other plugins

Disable Heartbeat entirely to save server load

Raise interval or minimalInterval with heartbeat_settings; disabling it removes post locking and nonce refresh

More corrections are collected in Where Older Books and Tutorials Are Outdated.

Common errors and pitfalls

  • -1 or 0 from admin-ajax.php. -1 is a failed nonce (mismatched action or field name, a nonce from another user or one older than 24 hours); 0 means 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 no nopriv handler).

  • REST 401 for a logged-in user. Cookies without X-WP-Nonce run as user 0. Use apiFetch, or send the nonce from wp_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 $deps drops the dependent script (with a notice under WP_DEBUG since 6.9.1; the handle is wp-api-fetch, not api-fetch), and an after inline script cancels defer.

  • Slow AJAX. admin_init runs on every AJAX call and Heartbeat tick; guard heavy work with wp_doing_ajax().

  • Heartbeat data without checks. heartbeat_received takes browser input like any endpoint: sanitize, check capabilities, and return $response unchanged when the key is not yours.