Recently, I noticed an anomaly on my WordPress site.
Both my main Chinese site and the English subsite use Post Views Counter to track article views. The Chinese site seemed to be working fine, but the view counts for several recently published articles on the English subsite remained stuck at 0 for a long time.
At first, I thought it was just low traffic, or that the WordPress dashboard numbers hadn’t refreshed in time.
But after observing several articles consecutively, something felt wrong: I had clearly opened these pages myself, yet the view counts still hadn’t changed at all.
So I started investigating further.
1. Initial symptom: Recent English article views stuck at 0
In the backend article list, I could see that the view counts for recent English articles were not increasing.
![[Figure 1: Recent article views showing as 0 in the English article list]](https://media.shuijingwanwq.com/2026/09/01-english-article-list-initial-zero-views-1024x390.png)
Initially, I considered a few possibilities:
- The articles weren’t actually generating valid traffic;
- Post Views Counter itself was not working properly;
- Cloudflare or W3 Total Cache was affecting the counting;
- The REST API was being blocked by security rules;
- The WordPress dashboard values were out of sync with the actual database values.
To avoid further guessing, the most direct approach was to open the browser developer tools and inspect a real counting request.
2. The real anomaly: Post Views Counter REST API returns 403
Post Views Counter is currently using the REST API counting mode.
When visiting an article, the browser sends a POST request similar to the following:
/wp-json/post-views-counter/view-post/{post_id}Under normal circumstances, this request should return:
HTTP 200But what I actually saw was:
HTTP 403![[Figure 2: Post Views Counter REST request returning 403]](https://media.shuijingwanwq.com/2026/09/02-en-domain-403-response-headers.png)
Continuing to check the response body, WordPress returned:
{
"code": "rest_cookie_invalid_nonce",
"message": "Cookie 检查失败",
"data": {
"status": 403
}
}![[Figure 3: rest_cookie_invalid_nonce in the response]](https://media.shuijingwanwq.com/2026/09/03-en-domain-rest-cookie-invalid-nonce.png)
Later, I could see the same type of 403 REST response on the www main domain as well.
![[Figure 4: www main domain also showing REST Cookie nonce verification failure]](https://media.shuijingwanwq.com/2026/09/04-www-domain-403-response-json.png)
At this point, the possibility of “Cloudflare WAF simply blocking the request” was largely ruled out.
The request had actually reached WordPress, and it was the WordPress REST API itself that returned 403.
The problem began to focus on one point:
The REST nonce sent by the browser had already expired.
3. Request content is normal; the real anomaly is X-WP-Nonce
First, I checked the actual request content sent by Post Views Counter.
The request included:
{
"storage_type": "cookies",
"storage_data": ""
}![[Figure 5: storage_type and storage_data in the Post Views Counter request]](https://media.shuijingwanwq.com/2026/09/05-en-domain-request-body-storage-data.png)
This meant PVC was sending the counting request correctly according to the Cookies storage mode.
Continuing to check the request headers, I could see:
X-WP-Nonce: add9d7782a![[Figure 6: The old X-WP-Nonce in the request headers]](https://media.shuijingwanwq.com/2026/09/06-en-domain-request-header-stale-x-wp-nonce.png)
I then verified this nonce directly on the server:
wp eval --allow-root '
$nonce = "add9d7782a";
echo "cached_nonce={$nonce}\n";
$result = wp_verify_nonce( $nonce, "wp_rest" );
echo "verify_result=";
var_dump( $result );
echo "current_nonce=" . wp_create_nonce( "wp_rest" ) . "\n";
'The result was:
cached_nonce=add9d7782a
verify_result=bool(false)
current_nonce=68aeb1c609That is:
add9d7782aThis nonce had indeed expired.
The next question became:
Why was the frontend page still using this expired nonce?
4. Checking the current Post Views Counter configuration
To rule out an issue with the plugin settings themselves, I re-checked Post Views Counter.
The current main configuration was:
- Counting Mode: REST API;
- Data Storage: Cookies;
- Count Interval: 1 hour;
- Tracking for posts, pages, etc., was normally enabled.
![[Figure 7: Overview of current Post Views Counter counting configuration]](https://media.shuijingwanwq.com/2026/09/07-pvc-settings-counting-overview-1024x707.png)
Continuing to check the counting and visitor storage related options, I found no obvious errors.
![[Figure 8: Detailed Post Views Counter counting and Cookie storage configuration]](https://media.shuijingwanwq.com/2026/09/08-pvc-settings-counting-detail-1024x707.png)
In the cache-related settings, I could also see that the plugin’s own cache compatibility feature was not actually participating in the current counting process.
![[Figure 9: Post Views Counter cache and performance related settings]](https://media.shuijingwanwq.com/2026/09/09-pvc-settings-performance-cache-1024x707.png)
Meanwhile, my website uses W3 Total Cache, with a page cache lifecycle of about 4 days.
At this point, a very important time discrepancy emerged:
Page cache: approx. 4 days
WordPress REST nonce:
Actual validity is usually only about 12–24 hoursThese two clearly do not share the same lifecycle.
5. Root cause: A shorter-lived nonce saved in a cached page
Continuing to check the Post Views Counter source code, I saw that REST API mode generates:
wp_create_nonce( 'wp_rest' )It then outputs this nonce to the browser via the page’s JavaScript parameters.
This is where the problem lies.
When the page is first generated:
Generate HTML
↓
Generate a wp_rest nonce
↓
Nonce is written into HTML / JavaScript
↓
The entire page enters W3 Total CacheThen, after some time passes:
wp_rest nonce has expiredHowever:
W3TC page cache is still validSo visitors continue to receive the original cached page.
The JavaScript within the page still carries the old nonce:
Old cached page
↓
Old X-WP-Nonce
↓
POST /wp-json/post-views-counter/view-post/...
↓
WordPress REST Authentication
↓
403 rest_cookie_invalid_nonceThe final manifestation is:
Page access works fine, but Post Views Counter views stop increasing.
A WordPress nonce is also not “valid for a fixed 24 hours from the time of generation.”
It uses a time-tick mechanism, so the actual validity period usually falls between 12–24 hours.
Therefore, even if an article appears to have been cached for less than a day, it is entirely possible that it already carries an old nonce that cannot pass verification.
6. Without sending the old nonce, the REST counting endpoint itself works fine
To confirm whether the Post Views Counter REST Endpoint itself was broken, I tested it directly:
curl -i -sS \
-X POST \
'https://en.shuijingwanwq.com/wp-json/post-views-counter/view-post/27585' \
-H 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' \
--data 'storage_type=cookies&storage_data='This time, I did not send the old one from the page:
X-WP-NonceThe endpoint directly returned:
HTTP 200And:
{
"post_id": 27585,
"counted": true,
"type": "post"
}This shows that:
The Post Views Counter public counting endpoint itself is not broken.
The problem is the expired X-WP-Nonce carried by the cached page.
7. No switch in counting mode; instead, adding a very narrow compatibility layer
Post Views Counter also offers PHP and JavaScript counting modes.
But after comprehensive consideration, I did not switch.
Because my website uses full-page caching, REST API is inherently one of the counting methods better suited for this architecture.
Switching counting modes to fix this issue might actually introduce new cache compatibility problems.
The final solution adopted was:
Keep REST API, keep page caching, and only when a Post Views Counter
view-postrequest carries an expired nonce, replace it with a currently valid nonce before WordPress Core officially performs REST Cookie Authentication.
I did not modify the Post Views Counter plugin source code; instead, I added an MU-plugin:
wp-content/mu-plugins/post-views-counter-rest-cache-compat.phpThe code is as follows:
<?php
/**
* Plugin Name: Post Views Counter REST Cache Compatibility
* Description: Refresh stale cached REST nonces for Post Views Counter public view requests.
*/
defined( 'ABSPATH' ) || exit;
function shuijingwan_pvc_refresh_stale_rest_nonce( $result ) {
if ( null !== $result ) {
return $result;
}
if (
! isset( $_SERVER['REQUEST_METHOD'] )
|| strtoupper( (string) $_SERVER['REQUEST_METHOD'] ) !== 'POST'
) {
return $result;
}
if ( ! function_exists( 'post_views_counter' ) ) {
return $result;
}
$pvc = post_views_counter();
if (
! is_object( $pvc )
|| ! isset( $pvc->options['general']['counter_mode'] )
|| $pvc->options['general']['counter_mode'] !== 'rest_api'
) {
return $result;
}
$route = '';
if (
isset( $GLOBALS['wp'] )
&& isset( $GLOBALS['wp']->query_vars['rest_route'] )
) {
$route = (string) $GLOBALS['wp']->query_vars['rest_route'];
}
if ( $route === '' ) {
$request_uri = isset( $_SERVER['REQUEST_URI'] )
? (string) $_SERVER['REQUEST_URI']
: '';
$path = wp_parse_url( $request_uri, PHP_URL_PATH );
if ( is_string( $path ) ) {
$prefix = '/' . trim( rest_get_url_prefix(), '/' ) . '/';
$position = strpos( $path, $prefix );
if ( false !== $position ) {
$route = '/' . ltrim(
substr( $path, $position + strlen( $prefix ) ),
'/'
);
}
}
}
if (
! preg_match(
'#^/?post-views-counter/view-post(?:/\d+)?/?$#',
$route
)
) {
return $result;
}
if (
! isset( $_SERVER['HTTP_X_WP_NONCE'] )
|| $_SERVER['HTTP_X_WP_NONCE'] === ''
) {
return $result;
}
$nonce = (string) $_SERVER['HTTP_X_WP_NONCE'];
if ( wp_verify_nonce( $nonce, 'wp_rest' ) ) {
return $result;
}
$_SERVER['HTTP_X_WP_NONCE'] = wp_create_nonce( 'wp_rest' );
return $result;
}
add_filter(
'rest_authentication_errors',
'shuijingwan_pvc_refresh_stale_rest_nonce',
99
);Here, it uses:
rest_authentication_errorswith the 99 priority, because WordPress Core’s:
rest_cookie_check_errorsexecutes by default at the 100 priority.
Therefore, the compatibility code can complete its processing before Core officially verifies the REST nonce.
Its scope of action is also very limited:
POST
+
Post Views Counter
+
REST API counting mode
+
view-post Endpoint
+
Request contains X-WP-Nonce
+
Nonce has expiredIf the nonce itself is valid, it makes no modifications.
If REST API mode is no longer used in the future, it will automatically stop intervening.
8. After the fix, English view counts started to change
After deploying the compatibility code, I could soon see in the dashboard that the view counts for English articles started to change.
![[Figure 10: English article views start changing after the fix]](https://media.shuijingwanwq.com/2026/09/10-english-article-list-views-start-changing-1024x446.png)
However, this alone was not enough to prove that the MU-plugin was definitely effective.
Because there was another possibility:
The page cache happened to regenerate, so the page naturally obtained a new nonce.
So stricter verification was still needed.
9. Key verification: Deliberately not clearing the page cache
After deploying the MU-plugin, I did not immediately clear the W3 Total Cache page cache.
This was intentional.
Because if I cleared the cache, visiting the page again would generate a new nonce, making it impossible to determine whether the compatibility layer fixed it successfully or if it was simply due to a cache refresh.
After continuing to access the original cached page, the REST request had become:
HTTP 200![[Figure 11: REST request returns HTTP 200 while retaining the old page cache]](https://media.shuijingwanwq.com/2026/09/11-pvc-request-http-200-after-fix.png)
More importantly, after checking the request headers, I found:
The browser was still sending that same old nonce.
![[Figure 12: Browser still sends the old X-WP-Nonce after the fix]](https://media.shuijingwanwq.com/2026/09/12-request-still-sends-old-x-wp-nonce.png)
That is:
Page cache not changed
Old nonce not changedBut the server was already able to accept the request normally.
Subsequently, the response body showed:
{
"post_id": 27585,
"counted": true,
"type": "post"
}![[Figure 13: Post Views Counter returns counted:true after the fix]](https://media.shuijingwanwq.com/2026/09/13-counted-true-after-fix.png)
This step basically formed a complete closed loop:
Old cached page still exists
+
Browser still sends expired nonce
+
MU-plugin replaces it with a currently valid nonce
+
WordPress REST Authentication passes
+
PVC counted:trueTherefore, it can be confirmed that:
It was not that the page cache happened to refresh, but rather that the server-side compatibility code actually solved the problem.
10. A Cloudflare 520 occurred during this period, but it was a separate issue
During testing, I also encountered a:
Cloudflare Error 520![[Figure 14: An occasional Cloudflare 520 during testing]](https://media.shuijingwanwq.com/2026/09/14-occasional-cloudflare-520.png)
At first, I was worried whether it was related to the newly deployed MU-plugin.
However, after immediately retesting the same article, the request returned to normal:
HTTP 200
counted:trueFurthermore, it did not recur subsequently.
Therefore, I currently treat this 520 as an occasional origin/CDN request anomaly, and have not directly linked it to the nonce fix.
11. Continuing to test other English articles
Next, I selected another English article to continue verification.
I could similarly see:
HTTP 200
counted:true![[Figure 15: Another English article also successfully counted]](https://media.shuijingwanwq.com/2026/09/15-another-post-counted-true-27574.png)
This shows that the fix is not only effective for one specific Post ID.
12. Chinese article counting also remains normal
To confirm that the compatibility layer had no side effects on the main Chinese site, I also tested a Chinese article.
The result was similarly:
HTTP 200
counted:true![[Figure 16: Post Views Counter counting for Chinese articles still normal]](https://media.shuijingwanwq.com/2026/09/16-chinese-post-counted-true.png)
Thus, I found no evidence that the compatibility code broke other normal counting paths.
13. View counts in the backend article list have recovered
After consecutive tests on several articles, I returned to the WordPress backend article list.
The previously abnormal view counts had started to increase normally.
![[Figure 17: View counts in the backend article list resume growth after the fix]](https://media.shuijingwanwq.com/2026/09/17-article-list-views-restored-1024x452.png)
At this point, the initial stale nonce issue was essentially resolved.
A second, low-frequency issue discovered along the way
While continuing to observe, I encountered another somewhat strange situation.
A newly published Chinese article, which I remembered having visited several times, still showed a view count of:
0![[Figure 18: Another visited article's backend view count still showing as 0]](https://media.shuijingwanwq.com/2026/09/18-admin-list-second-post-still-zero-1024x334.png)
This made me momentarily suspect:
Was there an omission in the previous fix?
So I continued investigating.
14. This time it was not the nonce, but Cookie deduplication
I opened the article and checked the REST request.
This time:
HTTP 200It no longer showed:
rest_cookie_invalid_nonceBut the response was:
{
"post_id": 27587,
"counted": false,
"storage": [],
"type": "post"
}![[Figure 19: Article REST request returns counted:false]](https://media.shuijingwanwq.com/2026/09/19-post-27587-counted-false-due-to-cookie-dedup-1024x334.png)
Continuing to check the storage data sent by the browser:
{
"version": 1,
"session_id": "71075757-e541-4cb8-8f90-7c268cab8768",
"started_at": 1789646679,
"expires_at": 1789650279,
"visited": {
"post": [
27587
]
}
}Where:
started_at
→ 20:04:39
expires_at
→ 21:04:39The difference was exactly 1 hour.
This matches my Post Views Counter setting:
Count Interval = 1 hourExactly.
So this current occurrence:
counted:falseWas not a bug in itself.
It simply meant:
The current browser had already successfully counted this article within the past hour, so repeated visits would not increase the view count again.
15. But why, after having visited it before, was the backend still showing 0?
The truly strange part was:
If the browser already considered this article counted once, why had the backend previously shown 0?
So I continued checking the nginx access log:
grep -RniE \
'post-views-counter/view-post/27587' \
/data/wwwlogs /var/log/nginx \
2>/dev/nullI could see:
18:58:06 POST /view-post/27587 → 200
18:59:48 POST /view-post/27587 → 200
19:05:11 POST /view-post/27587 → 200
19:10:29 POST /view-post/27587 → 200
19:20:07 POST /view-post/27587 → 200
19:20:28 POST /view-post/27587 → 200
20:04:39 POST /view-post/27587 → 200
20:04:59 POST /view-post/27587 → 200Based on the response sizes and subsequent actual request contents, a clear pattern emerged:
215 / 216 bytes
≈ counted:true + storage
85 bytes
≈ counted:falseIn other words, a view count had actually been successfully generated around 18:58.
The problem was not that visits hadn’t occurred.
16. The database exposed the real anomaly
After directly querying the table used by Post Views Counter:
wp_post_viewsI found that the status of 27587 was:
day = 3
week = 3
month = 3
year = 3
total = 1A normal visit would simultaneously increment:
day
week
month
year
totalSo normally it should look like:
3 / 3 / 3 / 3 / 3But now it was:
3 / 3 / 3 / 3 / 1This indicated:
At some point, an operation had only modified
total, but had not modified day/week/month/year.
After further inspecting the plugin source code, I found exactly this code path:
pvc_update_post_views()It only directly modifies:
type = 4
period = totalAnd does not sync day/week/month/year.
17. Ultimately traced to old view count overwrites in the Gutenberg editor
Continuing to check Post Views Counter’s Block Editor code.
When the Gutenberg editor opens, the plugin reads the view counts at that time:
$count = pvc_get_post_views( $id );And saves them to:
pvcEditorArgs.postViewsAssuming that right after publication:
total = 0Then the already-open Gutenberg editor would continuously hold:
postViews = 0Subsequently, two new real visits occurred on the frontend:
0 → 1 → 2The database had become:
2But the unrefreshed Gutenberg page still held:
postViews = 0And the plugin’s block-editor.js would call:
/post-views-counter/update-post-views/when the article was saved, and submit the current editor state’s:
post_viewsThus, it could result in:
Just published:
0 / 0 / 0 / 0 / 0
Two real visits:
2 / 2 / 2 / 2 / 2
Editor still holds old value:
postViews = 0
Save article again:
2 / 2 / 2 / 2 / 0
Another real visit occurs later:
3 / 3 / 3 / 3 / 1This exactly matched the actual database state.
18. Corresponding requests also found in the admin subdomain logs
Because the WordPress backend uses:
admin.shuijingwanwq.comI continued checking the admin subdomain’s nginx logs.
Sure enough, I found:
18:57:51
POST /wp-json/post-views-counter/update-post-views/?id=27587
18:57:55
POST /wp-json/post-views-counter/update-post-views/?id=27587
19:04:57
POST /wp-json/post-views-counter/update-post-views/?id=27587And the article’s:
post_modifiedwas exactly:
2026-09-17 19:05:00The two times matched perfectly.
So this low-frequency issue was also basically explained:
After the Gutenberg editor is opened, if the frontend view count changes while the editor is not reloaded, saving the article again may cause Post Views Counter to overwrite the latest
totalwith the old view count originally read by the editor.
19. Why hasn’t this issue been further fixed for now?
Because the trigger conditions for this issue are quite specific:
Open the Gutenberg editor
↓
Keep the editing page open
↓
New view counts are generated on the frontend during this time
↓
Save the article again subsequentlyAnd my own frequency of modifying articles after publication is not high.
Even if I occasionally lose one or two view counts, the impact on overall statistics is quite limited.
In contrast, the previous stale nonce issue continuously affects the counting of normal visitors, so that one had to be fixed.
This Gutenberg edge case has only been documented for now, without adding new production compatibility code.
Later, after new valid visits were generated, the backend view count also recovered to 1.
![[Figure 20: Backend view count recovers to 1 after subsequent new valid visits]](https://media.shuijingwanwq.com/2026/09/20-admin-list-refreshed-to-one-1024x334.png)
20. Reconfirming normal changes in English view counts the next day
I did not end my observation immediately after the fix.
When checking the English article list again the next day, I could see that the view counts for recently published articles had continued to change.
For example, the most recent few respectively showed:
5
5
7
8
4
4
2
9
……The previous situation where multiple consecutive articles remained at:
0for a long time no longer existed.
![[Figure 21: Reconfirming continuous normal changes in recent English article views the next day]](https://media.shuijingwanwq.com/2026/09/21-english-views-final-confirmation-1024x516.png)
This step was also quite important.
Because it proved:
The earlier success was not just an occasional count or two during manual testing, but rather continuous normal operation during subsequent real visits.
Therefore, it can now be confirmed that the primary Post Views Counter stale nonce issue has been resolved.
21. Final conclusion
The main issue this time can be summarized as a lifecycle mismatch among:
Post Views Counter REST API
+
WordPress wp_rest nonce
+
Long-lifecycle page cacheThe page cache can persist for days, but the REST nonce embedded in the page will expire much earlier.
Consequently:
Cached page
↓
Carries old nonce
↓
PVC REST request
↓
WordPress REST Cookie Authentication
↓
403 rest_cookie_invalid_nonce
↓
Article view counts stop growingAfter adding the MU-plugin compatibility layer:
Cached page
↓
Browser still sends old nonce
↓
Confirm this is a PVC view-post request
↓
Confirm nonce has expired
↓
Generate a currently valid wp_rest nonce
↓
WordPress Core successfully completes Authentication
↓
Post Views Counter executes counting normally
↓
HTTP 200
↓
counted:trueThroughout this process, I did not:
- Disable W3 Total Cache;
- Shorten the page cache duration for the entire website;
- Modify the Post Views Counter plugin source code;
- Switch the Post Views Counter counting mode;
- Modify the Cloudflare configuration.
I only added a compatibility layer with a very limited scope of action.
22. A few lessons worth recording from this troubleshooting
The first is:
An HTTP 403 does not necessarily mean CDN or WAF interception.
This time, Cloudflare was working fine, and the request had reached WordPress.
What actually returned 403 was:
WordPress REST Cookie AuthenticationSo when encountering a REST API 403, the first step should still be to check the specific response content.
If you see:
rest_cookie_invalid_nonceThe troubleshooting direction is completely different from a standard WAF block.
The second is:
Be extra careful when dynamic authentication data enters long-term static caches.
The real problem this time was not:
Page cache brokenNor was it:
REST API brokenBut rather:
A shorter-lifecycle noncewas placed into:
A cached page with a significantly longer lifecycleThe mismatch between the two lifecycles will naturally lead to problems.
The third is the Gutenberg edge case discovered along the way:
Old state cached in the backend editor can also overwrite real-time data that has already changed on the frontend.
Its impact is minimal, so I haven’t specifically fixed it for now, but at least if I encounter similar phenomena in the future, I’ll know where to start troubleshooting.
For now, this issue is documented here.
The main view counting failure has been resolved.
After continued observation, the view counts for recent articles on the English subsite are all changing normally, and the previous situation where multiple consecutive new articles remained at 0 for a long time has not reoccurred.
