Back to Tips & Tricks

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

Atif JanPublished on Updated
Abstract illustration of a website with glowing dots for live visitors flowing toward a floating counter badge

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

  • 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_options capability) 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

  1. Copy the full snippet below.
  2. 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.
  3. Paste the code and give the snippet a name, for example "Visitor tracker".
  4. Set the snippet to run everywhere. Do not pick a front-end-only option such as Code Snippets' "Only run on site front-end".
  5. Save and activate.
  6. 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

  1. Enqueue: on wp_enqueue_scripts it registers an empty script handle and attaches the tracker as an inline script. The front end has no ajaxurl variable, so the snippet passes admin_url( 'admin-ajax.php' ), the action name and a nonce in its own window.bmcVT object.
  2. 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.
  3. 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.
  4. Handler: one function is hooked to both wp_ajax_bmc_vt_ping (logged-in users) and wp_ajax_nopriv_bmc_vt_ping (guests). It checks the nonce, sanitizes the ID, reads the list with get_transient() using a strict false === check, removes anyone silent for more than 30 seconds, updates the current visitor and saves the list with a 60-second expiry.
  5. Admin data: only users with manage_options get 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.
  6. 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

  1. Log in as an admin in one browser and open any front-end page. You should see "Online: 1".
  2. Open a private window in a different browser (or the same browser's private mode) and load the site logged out.
  3. 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.
  4. Close the private window. After roughly 30 to 35 seconds the count drops back to 1.
  5. 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 0 with 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 blocks admin-ajax.php for guests causes the same symptom.
  • "ajaxurl is not defined" in the console: that error comes from other code that assumes the admin-only ajaxurl variable. This snippet uses its own bmcVT.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-tracker from 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() and set_transient() in bmc_vt_ping) to get_site_transient() and set_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?
Each visible tab sends one small admin-ajax.php request every 5 seconds, and each request writes a transient. On small and medium sites this is light. On busy sites or shared hosting, raise the interval to 10-15 seconds, or use a persistent object cache so the writes do not hit the options table.
Does the visitor tracker work with caching plugins?
Yes. The count is fetched by AJAX after the page loads, so cached HTML still reports live data. The one catch is the nonce baked into the cached page: if the cache lives longer than about 12 hours, pings can fail with a 403 error until you purge the cache.
Can non-admin visitors see the online count?
No. The server returns the count only to users with the manage_options capability, and the button and pop-up are only built when that data arrives. Guests and other roles send a silent ping and get nothing back.
Can I show the number of online users publicly as social proof?
You can, but it takes changes to the capability check and the UI code, and a low number can work against you on a quiet site. If you want a public counter without custom code, WP-UserOnline is built for that purpose.
Why does my online users count drop to zero or reset randomly?
Transient expiry is a maximum, not a guarantee, so WordPress or an object cache can remove the data early. The snippet treats a missing transient as an empty list, and active visitors reappear on their next ping a few seconds later.

Sources

  1. An introduction to the Transients API (developer.wordpress.org)
  2. How to Show Real Time Online Users in WordPress (Easy Way) (wpbeginner.com)
  3. Scope of “Only run on site front-end” (wordpress.org)
  4. wp_ajax_nopriv_{$action} (developer.wordpress.org)

Related tips