Rebuilding a live train departures board in WordPress
By Rolando Escobar, WordPress developer for agencies · · 6 min read
A departures board has one job: show the next trains right now, even when the data feed behind it is having a bad day. If it goes blank or shows old times as if they were current, people stop trusting it.
I rebuilt the live train tool on the Moynihan Train Hall website because the previous version wasn’t working reliably. The tool shows Amtrak departures and arrivals, the next Long Island Rail Road trains with their stops, and LIRR service status, and it refreshes without reloading the page. This article walks through the pattern behind a board like that. The code is a tutorial version, not the client’s code.
How the pieces fit
The board has five parts, and each one handles a specific failure:
- Server-side fetch. WordPress calls the transit data feed with
wp_remote_get()and a short timeout. The browser never talks to the feed, so keys stay on the server and you control how often the feed is called. - Two caches. A short-lived transient absorbs traffic. A longer-lived “last good” copy keeps the board useful when the feed fails.
- HTML fragments. PHP renders the table, so the first page load and every refresh share the same markup and the same escaping.
- A refresh endpoint. An
admin-ajax.phpaction returns the fragment and a status line as JSON. - An accessible front end. A real refresh button, a visible “Last updated” time and a polite live region.
The public script of the Moynihan tool follows the same shape. Two actions, ajax_refresh and ajax_amtrak_refresh, return JSON with HTML for departures, arrivals, next trains and service status, plus a lastUpdate value that fills the “Last updated” line.
Fetch the feed with a timeout and two caches
Keep the feed URL and any API key in wp-config.php as constants, never in the plugin. Then wrap the request in a function that always returns something the board can render:
// wp-config.php: define( 'RW_TRANSIT_FEED_URL', '...' ); define( 'RW_TRANSIT_FEED_KEY', '...' );
function rw_board_get_departures(): array {
$cached = get_transient( 'rw_board_departures' );
if ( is_array( $cached ) ) {
return $cached;
}
$response = wp_remote_get(
RW_TRANSIT_FEED_URL,
array(
'timeout' => 5,
'headers' => array(
'Accept' => 'application/json',
'x-api-key' => RW_TRANSIT_FEED_KEY,
),
)
);
$code = (int) wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( 200 === $code && is_array( $body ) ) {
$data = array(
'trains' => rw_board_normalize( $body ), // Keep only the fields you render.
'fetched_at' => time(),
'stale' => false,
);
set_transient( 'rw_board_departures', $data, 30 );
update_option( 'rw_board_last_good', $data, false );
return $data;
}
error_log( 'Transit feed failed: ' . ( is_wp_error( $response ) ? $response->get_error_message() : 'HTTP ' . $code ) );
// Serve the last good copy, flagged as stale, and try the feed again in a minute.
$fallback = get_option( 'rw_board_last_good' );
if ( ! is_array( $fallback ) ) {
$fallback = array( 'trains' => array(), 'fetched_at' => 0 );
}
$fallback['stale'] = true;
set_transient( 'rw_board_departures', $fallback, MINUTE_IN_SECONDS );
return $fallback;
}
A few decisions in there matter more than they look:
- The 30-second transient means the feed gets at most two calls a minute, no matter how many people are watching the board.
- The last good copy lives in an option, not a transient. Transients can disappear at any time, especially with a persistent object cache that evicts keys under memory pressure. An option stays until you replace it. Autoload is off because only this code needs it.
- Failures are cached too. When the feed is down, the fallback is stored for a minute so every visitor doesn’t trigger another slow, failing request.
fetched_atrecords when the data came from the feed, not when it was served. That’s the time the board must show, or a stale board will look fresh.- The timeout is explicit. A request that waits 30 seconds for a dead feed ties up a PHP worker for 30 seconds.
rw_board_normalize() maps the feed’s fields into your own shape (a Unix timestamp, a destination, a track and a status). It depends entirely on the feed, so it isn’t shown here. Keeping your own shape also means the templates don’t break when the feed renames a field.
Render a fragment and expose a refresh action
One function renders the table, one builds the status line, and one AJAX handler returns both. Everything that came from the feed is escaped here, once, so the browser can insert the result as-is.
function rw_board_render( array $data ): string {
if ( empty( $data['trains'] ) ) {
return '<p>' . esc_html__( 'Departures are not available right now.', 'rw-board' ) . '</p>';
}
$rows = '';
foreach ( $data['trains'] as $train ) {
$rows .= sprintf(
'<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>',
esc_html( wp_date( get_option( 'time_format' ), (int) $train['time'] ) ),
esc_html( $train['destination'] ),
esc_html( $train['track'] ?: __( 'TBA', 'rw-board' ) ),
esc_html( $train['status'] )
);
}
$head = '';
foreach ( array( __( 'Time', 'rw-board' ), __( 'To', 'rw-board' ), __( 'Track', 'rw-board' ), __( 'Status', 'rw-board' ) ) as $label ) {
$head .= '<th scope="col">' . esc_html( $label ) . '</th>';
}
return '<table><caption>' . esc_html__( 'Next departures', 'rw-board' ) . '</caption><thead><tr>'
. $head . '</tr></thead><tbody>' . $rows . '</tbody></table>';
}
function rw_board_status_text( array $data ): string {
if ( ! $data['fetched_at'] ) {
return __( 'Live departures are unavailable right now.', 'rw-board' );
}
$time = wp_date( get_option( 'time_format' ), $data['fetched_at'] );
/* translators: %s: time of the last successful update. */
$text = $data['stale'] ? __( 'Live data is delayed. Showing departures from %s.', 'rw-board' ) : __( 'Last updated: %s', 'rw-board' );
return sprintf( $text, $time );
}
add_action( 'wp_ajax_rw_board_refresh', 'rw_board_ajax_refresh' );
add_action( 'wp_ajax_nopriv_rw_board_refresh', 'rw_board_ajax_refresh' );
function rw_board_ajax_refresh(): void {
check_ajax_referer( 'rw_board_refresh', 'nonce' );
$data = rw_board_get_departures();
nocache_headers();
wp_send_json_success(
array(
'departures' => rw_board_render( $data ),
'status' => rw_board_status_text( $data ),
)
);
}
The wp_ajax_nopriv_ hook is there because visitors aren’t logged in. nocache_headers() keeps proxies and browsers from storing a response that is only valid for a few seconds. Set the time zone under Settings > General to the station’s time zone, because wp_date() uses it.
A REST route with 'permission_callback' => '__return_true' works just as well as admin-ajax (see the AJAX chapter of the Plugin Handbook for the admin-ajax side). Either way, the handler reads from the cache and only calls the feed after the transient expires, so a burst of refresh clicks costs almost nothing.
Markup that works before JavaScript loads
A shortcode prints the first version of the board on the server, so the page shows departures even if the script fails. It also passes the endpoint, a nonce and the error message to the script.
add_shortcode( 'rw_departures_board', 'rw_board_shortcode' );
function rw_board_shortcode(): string {
$data = rw_board_get_departures();
wp_enqueue_script( 'rw-board', plugins_url( 'board.js', __FILE__ ), array(), '1.0.0', array( 'in_footer' => true ) );
wp_add_inline_script(
'rw-board',
'window.rwBoard = ' . wp_json_encode(
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'rw_board_refresh' ),
'errorText' => __( 'Could not refresh. The times shown may be out of date.', 'rw-board' ),
)
) . ';',
'before'
);
ob_start();
?>
<section class="rw-board" aria-labelledby="rw-board-title">
<h2 id="rw-board-title"><?php esc_html_e( 'Departures', 'rw-board' ); ?></h2>
<p role="status" data-board-status><?php echo esc_html( rw_board_status_text( $data ) ); ?></p>
<button type="button" data-board-refresh>
<svg aria-hidden="true" focusable="false" width="16" height="16"><use href="#rw-icon-refresh"></use></svg>
<?php esc_html_e( 'Refresh departures', 'rw-board' ); ?>
</button>
<div data-board-target aria-busy="false">
<?php echo rw_board_render( $data ); // Escaped inside rw_board_render(). ?>
</div>
</section>
<?php
return (string) ob_get_clean();
}
Two accessibility details are doing the heavy lifting. The status line has role="status", which makes it a polite live region: when its text changes, screen readers announce it once the user is idle. The live region is the short status line, not the table. If you put aria-live on the table, every refresh would read out every row. The button has visible text, and its icon is decorative, so it’s hidden from assistive technology. If you spin the icon while loading, stop the animation under prefers-reduced-motion.
Refresh with fetch and announce politely
The script posts to the endpoint, swaps the fragment and updates the status line. It also handles “View all stops” toggles, like the ones the Moynihan tool uses to show each LIRR train’s stops.
const board = document.querySelector('.rw-board');
if (board) {
const button = board.querySelector('[data-board-refresh]');
const target = board.querySelector('[data-board-target]');
const status = board.querySelector('[data-board-status]');
let busy = false;
button.addEventListener('click', async () => {
if (busy) return;
busy = true;
button.setAttribute('aria-disabled', 'true');
target.setAttribute('aria-busy', 'true');
try {
const response = await fetch(window.rwBoard.ajaxUrl, {
method: 'POST',
body: new URLSearchParams({ action: 'rw_board_refresh', nonce: window.rwBoard.nonce }),
signal: AbortSignal.timeout(8000),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const json = await response.json();
if (!json.success) throw new Error('Refresh failed');
// Remember open "View all stops" panels, swap the HTML, then reopen them.
const open = [...target.querySelectorAll('button[aria-controls][aria-expanded="true"]')]
.map((el) => el.getAttribute('aria-controls'));
target.innerHTML = json.data.departures; // Escaped on the server.
open.forEach((id) => target.querySelector(`[aria-controls="${CSS.escape(id)}"]`)?.click());
status.textContent = json.data.status;
} catch (error) {
status.textContent = window.rwBoard.errorText;
} finally {
busy = false;
button.removeAttribute('aria-disabled');
target.setAttribute('aria-busy', 'false');
}
});
// Delegated, so toggles keep working after each refresh.
target.addEventListener('click', (event) => {
const toggle = event.target.closest('button[aria-controls][aria-expanded]');
const panel = toggle && document.getElementById(toggle.getAttribute('aria-controls'));
if (!panel) return;
const expanded = toggle.getAttribute('aria-expanded') === 'true';
toggle.setAttribute('aria-expanded', String(!expanded));
panel.hidden = expanded;
});
}
Notes on the choices:
aria-disabledinstead ofdisabled. Disabling the button while it has focus can drop keyboard focus to the top of the page in some browsers.aria-disabledplus thebusyflag blocks double clicks and leaves focus where it was.- Focus is never moved. The button sits outside the replaced region, so pressing it doesn’t lose your place, and the status line reports the result.
AbortSignal.timeout()stops waiting after eight seconds and shows the error text instead of a spinner that never ends.innerHTMLis safe here only because every value in the fragment was escaped in PHP. Never insert raw feed data in the browser.- The stops toggle follows the disclosure pattern. Render each one as
<button type="button" aria-expanded="false" aria-controls="stops-123">with ahiddenlist of stops that has the matching ID. Keep the IDs stable across refreshes so open panels can be restored.
If you add automatic refresh on a timer, pause it while the tab is hidden and give users a way to stop it. WCAG 2.2.2, Pause, Stop, Hide covers content that updates on its own, and a status line announced every minute gets tiring fast.
What to watch out for
- Nonces and page caching. Nonces for logged-out visitors are valid for 12 to 24 hours. If a page cache keeps the HTML for more than 12 hours, refreshes can start failing with a 403. Keep the page’s cache lifetime shorter, or, because this endpoint is public and read-only, drop the nonce check and rely on the transient to protect the feed. A nonce is not authentication.
- Cache stampedes. On a busy page, several requests can miss the cache at the same moment and all call the feed. A short lock, or refreshing the cache from a scheduled task instead of on demand, avoids that.
- Say when data is stale. Old times shown as current are worse than an error. The status line should always say how old the data is.
- Log carefully. Log the HTTP code or error message, never the full request URL if the key travels in it.
- Test the failure path. Point the feed constant at a URL that returns a 500 and confirm that the board keeps the last good data and shows the delay message.
This is the kind of work I take on for clients: Plugins and integrations