Live blog with polling auto-refresh
A REST endpoint returns entries newer than a timestamp and a small script polls it, pausing while the tab is hidden.
WordPressFeedback and empty stateslive-blogrest-apipollingjavascript
Code
<?php
/**
* Live blog feed endpoint.
*
* @package Acme_Theme
*/
defined( 'ABSPATH' ) || exit;
function acme_live_blog_register_route(): void {
register_rest_route(
'acme/v1',
'/live/(?P<post>\d+)',
array(
'methods' => WP_REST_Server::READABLE,
'permission_callback' => '__return_true', // Public, read-only.
'args' => array(
'post' => array( 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'absint' ),
'since' => array( 'default' => 0, 'sanitize_callback' => 'absint' ),
),
'callback' => 'acme_live_blog_entries',
)
);
}
add_action( 'rest_api_init', 'acme_live_blog_register_route' );
function acme_live_blog_entries( WP_REST_Request $request ): WP_REST_Response {
$post_id = (int) $request['post'];
$since = (int) $request['since'];
if ( 'publish' !== get_post_status( $post_id ) ) {
return new WP_REST_Response( array( 'entries' => array(), 'now' => time() ), 404 );
}
$comments = get_comments(
array(
'post_id' => $post_id,
'status' => 'approve',
'type' => 'live_update',
'date_query' => array( array( 'after' => gmdate( 'Y-m-d H:i:s', $since ), 'inclusive' => false ) ),
'orderby' => 'comment_date_gmt',
'order' => 'DESC',
'number' => 20,
)
);
$entries = array_map(
static fn( WP_Comment $c ): array => array(
'id' => (int) $c->comment_ID,
'time' => mysql2date( get_option( 'time_format' ), $c->comment_date ),
'html' => wp_kses_post( wpautop( $c->comment_content ) ),
),
$comments
);
$response = new WP_REST_Response( array( 'entries' => $entries, 'now' => time() ) );
$response->header( 'Cache-Control', 'public, max-age=10' );
return $response;
}
(() => {
const feed = document.querySelector('[data-live-feed]');
if (!feed) return;
const url = feed.dataset.liveFeed; // e.g. /wp-json/acme/v1/live/42
const every = 15000; // ms between checks
let since = Number(feed.dataset.now) || Math.floor(Date.now() / 1000);
let timer = null;
async function poll() {
try {
const res = await fetch(`${url}?since=${since}`, { headers: { Accept: 'application/json' } });
if (!res.ok) return;
const data = await res.json();
since = data.now;
data.entries.reverse().forEach((entry) => {
const item = document.createElement('article');
item.className = 'acme-live__entry';
item.innerHTML = `<time>${entry.time}</time>${entry.html}`; // html is sanitised server side
feed.prepend(item);
});
} catch (e) { /* try again next tick */ }
}
function start() { if (!timer) timer = setInterval(poll, every); }
function stop() { clearInterval(timer); timer = null; }
document.addEventListener('visibilitychange', () => (document.hidden ? stop() : (poll(), start())));
start();
})();
How it works
What it solves
A match report or breaking story needs new entries to appear without a manual reload, but a full websocket stack is overkill for a WordPress theme. Light polling against a cacheable endpoint is enough and works on any host.
How it works
The route returns approved live_update comments newer than the since timestamp, formatted as safe HTML and capped at 20. The argument is sanitised with absint, the post must be published, and a short Cache-Control header lets a CDN absorb the traffic. The script remembers the server's now value so clock drift on the visitor's device never matters, prepends new entries, and clears its timer when the tab is hidden.
Returning to the tab triggers an immediate refresh.
Adapting it
Store entries however suits you: a custom post type with a parent works as well as comments. Change the interval, or lengthen it automatically after quiet periods. Output the container with data-live-feed set to rest_url( 'acme/v1/live/' . get_the_ID() ) and data-now set to time().
Pitfalls
- The endpoint is public, so never expose private fields or unapproved items.
- Do not insert unsanitised HTML; the server already runs
wp_kses_post. - Page caches may serve a stale
data-now; entries are then fetched from that older point, which is safe. - Authors need a way to create
live_updateitems, for example a meta box or the block editor.
How to use it
Require inc/live-blog.php from functions.php and enqueue the script on single posts.
Render <div data-live-feed="..." data-now="..."></div> where the timeline should appear.
Test the route in a browser at /wp-json/acme/v1/live/<post id>.
Licence: GPL-2.0-or-later. This snippet comes from WordPress code and is GPL-2.0-or-later.