Contents
Why double counting happens and an overview
When you measure post/page views on a WordPress site, the same visitor can easily be counted multiple times. Causes include page reloads, multiple tabs, fast repeated requests before the browser writes a cookie, bots and crawlers, incorrectly implemented server-side counters that run on every cached page render, or race conditions in database updates.
A common practical approach is to use a client-side check (cookies or localStorage) to mark that the visitor already viewed a specific post and then only send a single increment request to the server. This tutorial explains why problems happen, design options, a robust implementation using JavaScript cookies a WordPress REST endpoint, and advanced topics (localStorage, BroadcastChannel, server-side deduplication, privacy, caching and scaling).
Design considerations and trade-offs
- Counting via client-side JS vs server-side
Client-side (JS) counting avoids counting bots that do not execute JS and avoids double counting from cached HTML being served repeatedly. Server-side counting is simpler but can be double-counted by caches or bots.
- Cookie vs localStorage
Cookies are sent automatically with same-origin requests and can be used server-side for deduplication. localStorage is only client-side and cannot be sent to the server automatically (you would have to send the token manually). Cookies are subject to browser cookie rules (SameSite, Secure) and cannot be HttpOnly when created by JS.
- Duration and granularity
Decide whether unique means per session, per day, per week, or indefinite. A common choice: one unique view per visitor per post per 30 days.
- Privacy/GDPR
Cookies may need consent. Keep tokens minimal and avoid storing PII. Hash or salt IPs if you record them.
- Race conditions atomic updates
Avoid non-atomic updates such as reading a value and then saving an incremented value without locking use SQL atomic increment or UPSERT.
- On page load run JS that checks for a cookie (or localStorage) specific to that post ID (example key: viewed_post_123).
- If the cookie exists and is still valid, do nothing (no server request).
- If absent, set the cookie immediately (to reduce race across tabs) and asynchronously call a REST endpoint to increment the post view count.
- The server increments a counter using an atomic DB operation. Optionally deduplicate with cookie token, hashed IP and/or time window.
- Optionally synchronize across tabs with BroadcastChannel so multiple tabs opened almost-simultaneously do not both send increments.
Step-by-step implementation (detailed)
1) Create a small DB structure for counters
You can use postmeta, but for performance and concurrent-safe increments its better to use a simple table with an atomic UPSERT (INSERT … ON DUPLICATE KEY UPDATE). Example SQL:
CREATE TABLE IF NOT EXISTS wp_post_views ( post_id BIGINT UNSIGNED NOT NULL, views BIGINT UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (post_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
This keeps a single row per post with an atomic increment option.
2) Register a REST API route in WordPress
Well register a public POST endpoint which increments the views. You can choose to verify a nonce for added protection. This example uses an UPSERT to atomically increment.
add_action(rest_api_init, function() { register_rest_route(wp/v2, /post-views/(?Pd ), array( methods => POST, callback => my_increment_post_view, permission_callback => __return_true )) }) function my_increment_post_view( request ) { post_id = (int) request[id] if ( ! post_id get_post_status( post_id ) !== publish ) { return new WP_Error(invalid_post, Invalid post, array(status=>400)) } // Optional: verify a nonce header if you pass one from the client nonce = request->get_header(x-wp-nonce) if ( nonce ! wp_verify_nonce( nonce, wp_rest ) ) { return new WP_Error(invalid_nonce, Invalid nonce, array(status=>403)) } global wpdb table = wpdb->prefix . post_views // Use prepared query to run an atomic upsert: sql = wpdb->prepare( INSERT INTO {table} (post_id, views) VALUES (%d, 1) ON DUPLICATE KEY UPDATE views = views 1, post_id ) res = wpdb->query( sql ) if ( res === false ) { return new WP_Error(db_error, Database error, array(status=>500)) } return rest_ensure_response( array(success => true, post_id => post_id) ) }
3) Enqueue the JS and pass data from PHP to JS
Enqueue a script that will run in the footer on single posts pass the post ID, rest_url and optionally a nonce.
function enqueue_post_views_script() { if ( ! is_singular() ) return post = get_queried_object() wp_enqueue_script(post-views, get_stylesheet_directory_uri() . /js/post-views.js, array(), 1.0, true) wp_localize_script(post-views, wpPostViews, array( postId => post->ID, restUrl => esc_url_raw( rest_url() ), nonce => wp_create_nonce(wp_rest) // optional verify on server )) } add_action(wp_enqueue_scripts, enqueue_post_views_script)
Utility functions to set/read cookies with standard attributes. Note: JS cannot set HttpOnly cookies Secure and SameSite can be added in the cookie string.
function setCookie(name, value, days) { var expires = if (days) { var d = new Date() d.setTime(d.getTime() days 24 60 60 1000) expires = expires= d.toUTCString() } var cookieStr = name = encodeURIComponent(value) expires path=/ SameSite=Lax if (location.protocol === https:) cookieStr = Secure document.cookie = cookieStr } function getCookie(name) { var match = document.cookie.match(new RegExp((^ ) name =([^] ))) return match ? decodeURIComponent(match[2]) : null }
Set the cookie immediately before sending the request so other tabs or fast reloads dont trigger another increment. Use fetch with credentials: same-origin so cookies are included (if you rely on cookies on the server) and include the nonce header for optional server verification.
(function() { if (!window.wpPostViews) return var postId = window.wpPostViews.postId var restBase = window.wpPostViews.restUrl.replace(///, ) /wp/v2/post-views/ var nonce = window.wpPostViews.nonce if (!postId) return var cookieName = viewed_post_ postId // If cookie exists do nothing if (getCookie(cookieName)) return // Immediately set cookie to reduce duplicate counts from other tabs setCookie(cookieName, 1, 30) // e.g. 30 days // Optionally broadcast to other tabs (see later BroadcastChannel section) try { if (BroadcastChannel in window) { var bc = new BroadcastChannel(post-views) bc.postMessage({ postId: postId, counted: true }) } } catch (e) {} // Fire-and-forget POST to REST endpoint. Include nonce header for server verification. fetch(restBase postId, { method: POST, headers: { Content-Type: application/json, X-WP-Nonce: nonce }, body: JSON.stringify({ post_id: postId }), credentials: same-origin // ensure same-origin cookies are sent if needed }).catch(function(err){ // Optionally: if you want the count to be resilient to network failures, // you could remove the cookie here so a later reload attempts again. console.warn(Post view increment failed, err) }) })()
6) Preventing multiple counts across tabs: BroadcastChannel and localStorage
If multiple tabs open the same post nearly simultaneously, both might not see the cookie and both will send increments. Two mitigations:
- Set cookie immediately — reduces race window.
- BroadcastChannel — notify other tabs as soon as you decide to count so they set the cookie too.
BroadcastChannel example:
// when counting: if (BroadcastChannel in window) { var bc = new BroadcastChannel(post-views) bc.postMessage({ postId: postId, counted: true }) bc.onmessage = function(e) { if (e.data e.data.postId === postId e.data.counted) { setCookie(cookieName, 1, 30) } } }
7) Server-side deduplication and advanced storage
If you want stricter deduplication server-side (e.g. prevent clients from deliberately firing many increments or to dedupe across devices if they share a cookie token), consider storing a per-visitor token or hashed cookie token with a timestamp. Typical table structure:
CREATE TABLE IF NOT EXISTS wp_post_view_logs ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL, token_hash CHAR(64) NULL, -- hash of cookie token or local token ip_hash CHAR(64) NULL, -- hashed IP if you capture it (privacy-conscious) viewed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY post_id (post_id), KEY token_post (post_id, token_hash(32)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
Important: store hashes, not raw IPs or raw tokens, and obey privacy rules / consent. To avoid duplicates, enforce a unique constraint on (post_id, token_hash, DATE(viewed_at)) or simply check for an existing row within the last N hours before inserting. Note: MySQL cannot index DATE(viewed_at) directly in a unique constraint without a generated column — but you can add a DATE column or a day_key INT and enforce uniqueness on (post_id, token_hash, day_key).
8) Handling caches and CDNs
If HTML is cached by a plugin (WP Super Cache, WP Rocket) or a CDN, server-side counting on page generation wont execute for each view. Using a JS approach that runs in the client and calls a REST endpoint prevents counting from being suppressed by caches. Also ensure your REST endpoint isnt cached by your CDN — configure caching rules so the endpoint is not cached, or set appropriate headers.
- Cookies blocked or cleared — users who block cookies or clear them will be counted again.
- SameSite cross-site contexts — if your site is embedded, SameSite may block cookies. Set SameSite=Lax or None (with Secure) as appropriate. JS-created cookies default to SameSite=Lax if you dont specify for cross-site you need SameSite=None Secure is required for None.
- HttpOnly — you cannot set HttpOnly via JS. Cookies set by JS are readable by JS if you require HttpOnly you must set cookies server-side in Set-Cookie headers (but then JS cant read them to dedupe locally).
- Safari ITP and modern privacy features — third-party cookie blocking and Intelligent Tracking Prevention may cause cookies to disappear or be partitioned.
- Bots and crawlers — many bots dont run JS, so this approach avoids counting them. Some advanced crawlers do run JS use additional heuristics if necessary.
10) Security rate limiting
- Dont rely purely on the client combine cookie/local dedupe with server-side basic throttles (e.g. allow one increment per IP token per minute/hour).
- Validate post existence and permissions on the REST endpoint.
- Optionally verify a nonce (wp_create_nonce X-WP-Nonce) to reduce abuse from third-party sites.
- Consider logging failed attempts or repeated POSTs for monitoring.
11) Privacy and GDPR considerations
- Treat view-count cookies as tracking for some jurisdictions. Include them in your cookie policy and respect consent preferences.
- Minimize stored PII. If you store IPs for dedupe or analytics, hash and salt them and keep retention short.
- Offer opt-out if required by law or policy and do not set the cookie if the user has not consented.
Alternative implementations and notes
- Using postmeta (simpler but race-prone)
update_post_meta(post_id, views, intval(get_post_meta(post_id,views,true)) 1) works but is prone to race conditions under concurrency. Avoid if you have many concurrent increments.
- Using object cache (Redis) and batching
For high traffic sites, increment counters in Redis for speed and batch-persist to MySQL periodically (e.g. every few minutes). This reduces DB writes but increases complexity and risk of data loss on crashes.
- LocalStorage-only approach
If you prefer not to use cookies, store a simple token in localStorage (e.g. viewed_post_
) and send that token via fetch body. Server-side you can hash the token and dedupe. Note localStorage is bound to the origin and not sent with requests automatically.
Troubleshooting checklist
- Is your JS loaded? Check that post-views.js is enqueued on single posts and runs (use console.log).
- Is the cookie being set? Inspect document.cookie and check attributes (path, domain, SameSite).
- Is fetch POST succeeding? Check network tab for status 200 and response. Ensure credentials and CORS are correct.
- Does the REST endpoint exist and is not cached by CDN? Make sure rest_url is correct and your CDN isnt caching POST responses.
- If using nonce verification, ensure the nonce is sent and valid. Use wp_create_nonce(wp_rest) and send it as X-WP-Nonce header.
- If multiple tabs increment, implement BroadcastChannel or stronger server-side dedupe.
Complete minimal example summary
Files and pieces you need:
- DB table (wp_post_views) created once.
- WordPress PHP code: REST route callback enqueue wp_localize_script.
- Client JS (post-views.js): cookie helpers, immediate cookie set, fetch to REST endpoint, optional BroadcastChannel sync.
Sample links (documentation)
Closing notes
A cookie-based client-side deduplication strategy for WordPress view counting is effective and lightweight: set the cookie on first view, synchronise across tabs, and make an atomic increment on the server. Account for caching, privacy requirements and browser cookie policies, and add server-side protections (rate limits or token logs) if you need stronger anti-abuse measures. The code examples above give a practical, production-ready starting point and also point out where to harden for high traffic or privacy-sensitive sites.
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |