Visitor Tracker in WordPress: Real-Time Count and Admin Pop-up

You can build a lightweight visitor tracker in WordPress with one PHP snippet. It stores active visitors in a transient, has every visible tab ping admin-ajax.php every 5 seconds, and shows administrators a floating "Online: X" button plus a pop-up when someone new arrives. There is no analytics plugin and no custom database table. The one setting that trips most people up: the snippet must run everywhere, not only on the front end.
- What you'll get
- Requirements
- Step-by-step setup
- How the snippet works
- How to test it
- Accuracy, load and privacy
- Common problems
- When to use a plugin instead
- How to remove it
What you'll get
- A real-time count of online visitors that updates every 5 seconds
- A pop-up for admins when a new visitor arrives
- A persistent "Online: X" button in the bottom-right corner for a quick glance
- No heavy analytics plugin and no third-party script
- Storage in WordPress transients, so no custom tables
- No IP addresses stored. Each browser gets a random ID instead
Requirements
- A snippets plugin such as Code Snippets or WPCode (or a child theme's
functions.php) - The administrator role (the
manage_optionscapability) to see the count and pop-up - A modern browser. The script uses plain
fetch(), so it does not depend on jQuery
Step-by-step setup
- Copy the full snippet below.
- Open your snippets plugin and add a new PHP snippet. In Code Snippets, use Add New. In WPCode, add a custom snippet with the PHP code type.
- Paste the code and give the snippet a name, for example "Visitor tracker".
- Set the snippet to run everywhere. Do not pick a front-end-only option such as Code Snippets' "Only run on site front-end".
- Save and activate.
- Load any front-end page while logged in as an admin. Within about 5 seconds the "Online: 1" button appears in the bottom-right corner. That one visitor is you.
Why step 4 matters: Code Snippets decides between front end and admin with is_admin(), and anything under /wp-admin/ counts as the admin area. The pings go to /wp-admin/admin-ajax.php. A front-end-only snippet therefore never registers its AJAX handler, so the request fails and the counter never updates. If you paste into a child theme's functions.php instead, it already runs everywhere.
The snippet has no opening <?php tag because snippet plugins supply it. In functions.php, paste it below the existing opening tag. Add this as a PHP snippet:
Code
/**
* Visitor Tracker in WordPress: real-time online count + admin pop-up.
* Paste into Code Snippets or WPCode as a PHP snippet.
* IMPORTANT: run it everywhere (front end AND admin). The AJAX handler
* runs through /wp-admin/admin-ajax.php.
*/
function bmc_vt_config() {
return array(
'action' => 'bmc_vt_ping', // AJAX action name.
'key' => 'bmc_vt_visitors', // Transient name.
'interval' => 5, // Seconds between pings (use 10-15 on busy sites).
'timeout' => 30, // Drop a visitor after this many seconds without a ping.
'ttl' => 60, // Transient lifetime in seconds (always set one).
'max' => 2000, // Safety cap on stored visitor IDs.
);
}
add_action( 'wp_enqueue_scripts', 'bmc_vt_enqueue' );
function bmc_vt_enqueue() {
$c = bmc_vt_config();
$is_admin = current_user_can( 'manage_options' );
// ajaxurl is not defined on the front end, so pass our own config.
$data = array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'action' => $c['action'],
'nonce' => wp_create_nonce( 'bmc_vt' ),
'interval' => (int) $c['interval'] * 1000,
'isAdmin' => $is_admin,
'label' => 'Online:',
'newOne' => 'A new visitor just arrived',
'newMany' => '%d new visitors just arrived',
);
$js = <<<'JS'
(function () {
var cfg = window.bmcVT;
if (!cfg || !window.fetch || !window.FormData) { return; }
// Random per-browser ID (shared by all tabs). No IP address is used.
var storeKey = 'bmc_vt_id';
var vid = null;
try { vid = window.localStorage.getItem(storeKey); } catch (e) {}
if (!vid || !/^[a-z0-9]{8,64}$/.test(vid)) {
vid = (window.crypto && window.crypto.randomUUID)
? window.crypto.randomUUID().replace(/-/g, '')
: Math.random().toString(36).slice(2) + Date.now().toString(36);
try { window.localStorage.setItem(storeKey, vid); } catch (e) {}
}
var since = 0, firstPoll = true, busy = false, btn = null, pop = null, hideTimer = null;
function buildUi() {
if (btn) { return; }
btn = document.createElement('button');
btn.type = 'button';
btn.className = 'bmc-vt-btn';
btn.title = 'Refresh online count';
btn.addEventListener('click', ping);
pop = document.createElement('div');
pop.className = 'bmc-vt-pop';
pop.setAttribute('role', 'status');
pop.setAttribute('aria-live', 'polite');
document.body.appendChild(btn);
document.body.appendChild(pop);
}
function showPopup(n) {
pop.textContent = n === 1 ? cfg.newOne : cfg.newMany.replace('%d', n);
pop.classList.add('is-visible');
clearTimeout(hideTimer);
hideTimer = setTimeout(function () { pop.classList.remove('is-visible'); }, 4000);
}
function ping() {
if (busy || document.hidden) { return; }
busy = true;
var body = new FormData();
body.append('action', cfg.action);
body.append('nonce', cfg.nonce);
body.append('vid', vid);
body.append('since', String(since));
fetch(cfg.ajaxUrl, { method: 'POST', body: body, credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (res) {
// Only admins get a count back from the server.
if (!cfg.isAdmin || !res || !res.success || !res.data || typeof res.data.count !== 'number') { return; }
buildUi();
btn.textContent = cfg.label + ' ' + res.data.count;
if (!firstPoll && res.data.new > 0) { showPopup(res.data.new); }
since = res.data.now;
firstPoll = false;
})
.catch(function () {})
.then(function () { busy = false; });
}
ping();
setInterval(ping, cfg.interval);
document.addEventListener('visibilitychange', function () {
if (!document.hidden) { ping(); }
});
})();
JS;
wp_register_script( 'bmc-visitor-tracker', false, array(), null, true );
wp_enqueue_script( 'bmc-visitor-tracker' );
wp_add_inline_script( 'bmc-visitor-tracker', 'window.bmcVT = ' . wp_json_encode( $data ) . ";\n" . $js );
if ( $is_admin ) {
$css = '.bmc-vt-btn{position:fixed;right:20px;bottom:20px;z-index:99999;padding:8px 14px;border:0;border-radius:999px;background:#0f766e;color:#fff;font:600 14px/1.2 system-ui,sans-serif;box-shadow:0 4px 14px rgba(0,0,0,.2);cursor:pointer}'
. '.bmc-vt-pop{position:fixed;right:20px;bottom:70px;z-index:99999;max-width:260px;padding:12px 16px;border-radius:8px;background:#111827;color:#fff;font:14px/1.4 system-ui,sans-serif;box-shadow:0 6px 20px rgba(0,0,0,.25);opacity:0;transform:translateY(10px);transition:opacity .3s,transform .3s;pointer-events:none}'
. '.bmc-vt-pop.is-visible{opacity:1;transform:translateY(0)}';
wp_register_style( 'bmc-visitor-tracker', false, array(), null );
wp_enqueue_style( 'bmc-visitor-tracker' );
wp_add_inline_style( 'bmc-visitor-tracker', $css );
}
}
// One handler for logged-in users and for guests.
add_action( 'wp_ajax_bmc_vt_ping', 'bmc_vt_ping' );
add_action( 'wp_ajax_nopriv_bmc_vt_ping', 'bmc_vt_ping' );
function bmc_vt_ping() {
check_ajax_referer( 'bmc_vt', 'nonce' );
$c = bmc_vt_config();
$vid = isset( $_POST['vid'] ) ? sanitize_key( wp_unslash( $_POST['vid'] ) ) : '';
if ( strlen( $vid ) < 8 || strlen( $vid ) > 64 ) {
wp_send_json_error( null, 400 );
}
$since = isset( $_POST['since'] ) ? absint( $_POST['since'] ) : 0;
$now = time();
// Transients can disappear at any time: treat a missing one as an empty list.
$list = get_transient( $c['key'] );
if ( false === $list || ! is_array( $list ) ) {
$list = array();
}
// Remove visitors who stopped pinging.
foreach ( $list as $id => $row ) {
if ( ! is_array( $row ) || ! isset( $row['first'], $row['last'] ) || $now - (int) $row['last'] > $c['timeout'] ) {
unset( $list[ $id ] );
}
}
if ( isset( $list[ $vid ] ) ) {
$list[ $vid ]['last'] = $now;
} elseif ( count( $list ) < $c['max'] ) {
$list[ $vid ] = array( 'first' => $now, 'last' => $now );
}
set_transient( $c['key'], $list, $c['ttl'] );
// Guests and non-admins get no data back.
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_success();
}
$new = 0;
if ( $since > 0 ) {
foreach ( $list as $id => $row ) {
if ( (string) $id !== $vid && (int) $row['first'] > $since ) {
$new++;
}
}
}
wp_send_json_success(
array(
'count' => count( $list ),
'new' => $new,
'now' => $now,
)
);
}How the snippet works
- Enqueue: on
wp_enqueue_scriptsit registers an empty script handle and attaches the tracker as an inline script. The front end has noajaxurlvariable, so the snippet passesadmin_url( 'admin-ajax.php' ), the action name and a nonce in its ownwindow.bmcVTobject. - Visitor ID: each browser gets a random ID in
localStorage. All tabs in one browser share it, so three open tabs count as one visitor. No IP address is read or stored. - Ping: every 5 seconds each visible tab POSTs its ID to
admin-ajax.php. Hidden tabs skip the ping and send one right away when they become visible again. - Handler: one function is hooked to both
wp_ajax_bmc_vt_ping(logged-in users) andwp_ajax_nopriv_bmc_vt_ping(guests). It checks the nonce, sanitizes the ID, reads the list withget_transient()using a strictfalse ===check, removes anyone silent for more than 30 seconds, updates the current visitor and saves the list with a 60-second expiry. - Admin data: only users with
manage_optionsget the count back. The admin's browser sends the server time of its previous poll, and the handler counts visitors first seen after that time. That number triggers the pop-up. The first poll after page load never shows a pop-up. - Output: the button and pop-up are built with
textContent, never raw HTML, and the CSS loads only for admins.
Expiry on a transient is a maximum, not a guarantee. WordPress or your object cache can drop it early. The handler then starts from an empty list, and the count rebuilds itself within one poll cycle. See the introduction to the Transients API for the details.
How to test it
- Log in as an admin in one browser and open any front-end page. You should see "Online: 1".
- Open a private window in a different browser (or the same browser's private mode) and load the site logged out.
- Within about 5 seconds the admin page should show "Online: 2" and the pop-up "A new visitor just arrived". The guest window shows nothing.
- Close the private window. After roughly 30 to 35 seconds the count drops back to 1.
- Repeat with your page caching plugin on and off. Open DevTools, then the Network tab, and filter for
admin-ajax.php. Each request should return status 200.
Accuracy, load and privacy
The count is approximate. Visitors with JavaScript off are not counted. Neither are most bots, but bots that run JavaScript are. A private window or a second browser counts as a new visitor. Background tabs drop out after 30 seconds. Two pings that arrive at the same moment can overwrite each other, so a visitor may briefly go missing until their next ping. Anyone can also send fake pings, which is why the list is capped at 2,000 IDs. Treat the number as a live indicator, not analytics.
Load: every visible tab sends one admin-ajax.php request per interval, and each request writes the transient. On a default install that write goes to the options table. On busy sites or shared hosting, raise interval to 10 or 15 and timeout to about three times the interval. A persistent object cache such as Redis or Memcached takes these writes off the database, because transients then live in the cache.
Privacy: the snippet stores only a random ID with timestamps, never IPs. If you change it to use IP addresses, hash them, and check whether GDPR or similar rules apply to you. Mention the localStorage ID in your privacy notice if your jurisdiction requires it. In the EU and UK, storing an ID in the visitor's browser can require consent under ePrivacy/PECR rules even without cookies. If that applies to you, only load the tracker script after the visitor consents, for example through your consent tool's script blocking.
Common problems
- Count stuck at 0 or 1, or never updating: the snippet is almost always set to front end only. Switch it to run everywhere. In the Network tab, a response of
0with status 400 means the AJAX handler is not registered. - Requests return 403 or
-1: the nonce check failed. This usually happens when a cached page carries an old nonce. WordPress nonces stay valid for 12 to 24 hours, so purge the page cache and keep the cache lifetime under 12 hours. A security plugin that blocksadmin-ajax.phpfor guests causes the same symptom. - "ajaxurl is not defined" in the console: that error comes from other code that assumes the admin-only
ajaxurlvariable. This snippet uses its ownbmcVT.ajaxUrl, so check for an older copy of the tracker or another script. - Button never appears: confirm your account has the administrator role and look for JavaScript errors earlier on the page. Optimization plugins that delay or combine inline scripts can also stop it from running. Exclude
bmc-visitor-trackerfrom them. - Count resets at random: the transient was dropped early, or the object cache was flushed. This is expected behavior. The count recovers on the next poll.
- Multisite:
set_transient()stores data per site, so each site has its own count. For one network-wide count, switch the two transient calls (get_transient()andset_transient()inbmc_vt_ping) toget_site_transient()andset_site_transient(). - Fatal error on save: the snippet was pasted twice, or another snippet already declares a
bmc_vt_function. Keep only one copy.
When to use a plugin instead
Use WP-UserOnline if you want a ready-made public counter on the front end. If you need visitor locations, devices or traffic sources, use the GA4 real-time report or MonsterInsights, as covered in WPBeginner's overview. The snippet is the right choice when you only want a quick admin-side "who's here now" signal without loading an analytics stack. If you track Crocoblock profiles, our guide to tracking JetEngine Profile Builder profile views with a shortcode follows the same no-plugin approach. For the pop-up styling, see our JetFormBuilder custom message popup.
How to remove it
Deactivate or delete the snippet. The visitor list expires with its 60-second transient. On sites without a persistent object cache, WordPress's daily cleanup of expired transients then removes the two leftover rows (_transient_bmc_vt_visitors and _transient_timeout_bmc_vt_visitors) from the options table. To remove them at once, run wp transient delete bmc_vt_visitors. The random ID left in visitors' browsers is harmless and is not read once the snippet is gone.
That gives you a working live visitor counter for admins in a single snippet, with clear limits on what it can tell you. If you want it extended, for example with a public counter, per-page counts or a dashboard widget, and would rather have someone build it for you, post a request on BoostMyCroco.
Frequently Asked Questions
Does a real-time visitor counter slow down WordPress?
Does the visitor tracker work with caching plugins?
Can non-admin visitors see the online count?
Can I show the number of online users publicly as social proof?
Why does my online users count drop to zero or reset randomly?
Sources
- An introduction to the Transients API (developer.wordpress.org)
- How to Show Real Time Online Users in WordPress (Easy Way) (wpbeginner.com)
- Scope of “Only run on site front-end” (wordpress.org)
- wp_ajax_nopriv_{$action} (developer.wordpress.org)


