没有不值得去解决的问题,也没有不值得去学习的技术!

Why Post Views on the English Subsite Stay at 0: A Troubleshooting Record for Post Views Counter and Page Cache Compatibility

[Figure 3: rest_cookie_invalid_nonce in the response]

作者:

在

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]
[Figure 1: Recent article views showing as 0 in the English article list]

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:

Plaintext
/wp-json/post-views-counter/view-post/{post_id}

Under normal circumstances, this request should return:

Plaintext
HTTP 200

But what I actually saw was:

Plaintext
HTTP 403
[Figure 2: Post Views Counter REST request returning 403]
[Figure 2: Post Views Counter REST request returning 403]

Continuing to check the response body, WordPress returned:

JSON
{
    "code": "rest_cookie_invalid_nonce",
    "message": "Cookie 检查失败",
    "data": {
        "status": 403
    }
}
[Figure 3: rest_cookie_invalid_nonce in the response]
[Figure 3: rest_cookie_invalid_nonce in the response]

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]
[Figure 4: www main domain also showing REST Cookie nonce verification failure]

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:

JSON
{
    "storage_type": "cookies",
    "storage_data": ""
}
[Figure 5: storage_type and storage_data in the Post Views Counter request]
[Figure 5: storage_type and storage_data in the Post Views Counter request]

This meant PVC was sending the counting request correctly according to the Cookies storage mode.

Continuing to check the request headers, I could see:

Plaintext
X-WP-Nonce: add9d7782a
[Figure 6: The old X-WP-Nonce in the request headers]
[Figure 6: The old X-WP-Nonce in the request headers]

I then verified this nonce directly on the server:

Bash
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:

Plaintext
cached_nonce=add9d7782a
verify_result=bool(false)
current_nonce=68aeb1c609

That is:

Plaintext
add9d7782a

This 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]
[Figure 7: Overview of current Post Views Counter counting configuration]

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]
[Figure 8: Detailed Post Views Counter counting and Cookie storage configuration]

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]
[Figure 9: Post Views Counter cache and performance related settings]

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:

Plaintext
Page cache: approx. 4 days

WordPress REST nonce:
Actual validity is usually only about 12–24 hours

These 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:

PHP
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:

Plaintext
Generate HTML
↓
Generate a wp_rest nonce
↓
Nonce is written into HTML / JavaScript
↓
The entire page enters W3 Total Cache

Then, after some time passes:

Plaintext
wp_rest nonce has expired

However:

Plaintext
W3TC page cache is still valid

So visitors continue to receive the original cached page.

The JavaScript within the page still carries the old nonce:

Plaintext
Old cached page
↓
Old X-WP-Nonce
↓
POST /wp-json/post-views-counter/view-post/...
↓
WordPress REST Authentication
↓
403 rest_cookie_invalid_nonce

The 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:

Bash
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:

Plaintext
X-WP-Nonce

The endpoint directly returned:

Plaintext
HTTP 200

And:

JSON
{
    "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-post request 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:

Plaintext
wp-content/mu-plugins/post-views-counter-rest-cache-compat.php

The code is as follows:

PHP
<?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:

Plaintext
rest_authentication_errors

with the 99 priority, because WordPress Core’s:

Plaintext
rest_cookie_check_errors

executes 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:

Plaintext
POST
+
Post Views Counter
+
REST API counting mode
+
view-post Endpoint
+
Request contains X-WP-Nonce
+
Nonce has expired

If 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]
[Figure 10: English article views start changing after the fix]

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:

Plaintext
HTTP 200
[Figure 11: REST request returns HTTP 200 while retaining the old page cache]
[Figure 11: REST request returns HTTP 200 while retaining the old page cache]

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]
[Figure 12: Browser still sends the old X-WP-Nonce after the fix]

That is:

Plaintext
Page cache not changed
Old nonce not changed

But the server was already able to accept the request normally.

Subsequently, the response body showed:

JSON
{
    "post_id": 27585,
    "counted": true,
    "type": "post"
}
[Figure 13: Post Views Counter returns counted:true after the fix]
[Figure 13: Post Views Counter returns counted:true after the fix]

This step basically formed a complete closed loop:

Plaintext
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:true

Therefore, 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:

Plaintext
Cloudflare Error 520
[Figure 14: An occasional Cloudflare 520 during testing]
[Figure 14: An occasional Cloudflare 520 during testing]

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:

Plaintext
HTTP 200
counted:true

Furthermore, 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:

Plaintext
HTTP 200
counted:true
[Figure 15: Another English article also successfully counted]
[Figure 15: Another English article also successfully counted]

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:

Plaintext
HTTP 200
counted:true
[Figure 16: Post Views Counter counting for Chinese articles still normal]
[Figure 16: Post Views Counter counting for Chinese articles still normal]

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]
[Figure 17: View counts in the backend article list resume growth after the fix]

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:

Plaintext
0
[Figure 18: Another visited article's backend view count still showing as 0]
[Figure 18: Another visited article’s backend view count still showing as 0]

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:

Plaintext
HTTP 200

It no longer showed:

Plaintext
rest_cookie_invalid_nonce

But the response was:

JSON
{
    "post_id": 27587,
    "counted": false,
    "storage": [],
    "type": "post"
}
[Figure 19: Article REST request returns counted:false]
[Figure 19: Article REST request returns counted:false]

Continuing to check the storage data sent by the browser:

JSON
{
    "version": 1,
    "session_id": "71075757-e541-4cb8-8f90-7c268cab8768",
    "started_at": 1789646679,
    "expires_at": 1789650279,
    "visited": {
        "post": [
            27587
        ]
    }
}

Where:

Plaintext
started_at
→ 20:04:39

expires_at
→ 21:04:39

The difference was exactly 1 hour.

This matches my Post Views Counter setting:

Plaintext
Count Interval = 1 hour

Exactly.

So this current occurrence:

Plaintext
counted:false

Was 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:

Bash
grep -RniE \
  'post-views-counter/view-post/27587' \
  /data/wwwlogs /var/log/nginx \
  2>/dev/null

I could see:

Plaintext
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 → 200

Based on the response sizes and subsequent actual request contents, a clear pattern emerged:

Plaintext
215 / 216 bytes
≈ counted:true + storage

85 bytes
≈ counted:false

In 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:

Plaintext
wp_post_views

I found that the status of 27587 was:

Plaintext
day   = 3
week  = 3
month = 3
year  = 3
total = 1

A normal visit would simultaneously increment:

Plaintext
day
week
month
year
total

So normally it should look like:

Plaintext
3 / 3 / 3 / 3 / 3

But now it was:

Plaintext
3 / 3 / 3 / 3 / 1

This 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:

PHP
pvc_update_post_views()

It only directly modifies:

Plaintext
type = 4
period = total

And 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:

PHP
$count = pvc_get_post_views( $id );

And saves them to:

Plaintext
pvcEditorArgs.postViews

Assuming that right after publication:

Plaintext
total = 0

Then the already-open Gutenberg editor would continuously hold:

Plaintext
postViews = 0

Subsequently, two new real visits occurred on the frontend:

Plaintext
0 → 1 → 2

The database had become:

Plaintext
2

But the unrefreshed Gutenberg page still held:

Plaintext
postViews = 0

And the plugin’s block-editor.js would call:

Plaintext
/post-views-counter/update-post-views/

when the article was saved, and submit the current editor state’s:

Plaintext
post_views

Thus, it could result in:

Plaintext
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 / 1

This exactly matched the actual database state.


18. Corresponding requests also found in the admin subdomain logs

Because the WordPress backend uses:

Plaintext
admin.shuijingwanwq.com

I continued checking the admin subdomain’s nginx logs.

Sure enough, I found:

Plaintext
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=27587

And the article’s:

Plaintext
post_modified

was exactly:

Plaintext
2026-09-17 19:05:00

The 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 total with 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:

Plaintext
Open the Gutenberg editor
↓
Keep the editing page open
↓
New view counts are generated on the frontend during this time
↓
Save the article again subsequently

And 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]
[Figure 20: Backend view count recovers to 1 after subsequent new valid visits]

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:

Plaintext
5
5
7
8
4
4
2
9
……

The previous situation where multiple consecutive articles remained at:

Plaintext
0

for a long time no longer existed.

[Figure 21: Reconfirming continuous normal changes in recent English article views the next day]
[Figure 21: Reconfirming continuous normal changes in recent English article views the next day]

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:

Plaintext
Post Views Counter REST API
+
WordPress wp_rest nonce
+
Long-lifecycle page cache

The page cache can persist for days, but the REST nonce embedded in the page will expire much earlier.

Consequently:

Plaintext
Cached page
↓
Carries old nonce
↓
PVC REST request
↓
WordPress REST Cookie Authentication
↓
403 rest_cookie_invalid_nonce
↓
Article view counts stop growing

After adding the MU-plugin compatibility layer:

Plaintext
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:true

Throughout 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:

Plaintext
WordPress REST Cookie Authentication

So when encountering a REST API 403, the first step should still be to check the specific response content.

If you see:

Plaintext
rest_cookie_invalid_nonce

The 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:

Plaintext
Page cache broken

Nor was it:

Plaintext
REST API broken

But rather:

Plaintext
A shorter-lifecycle nonce

was placed into:

Plaintext
A cached page with a significantly longer lifecycle

The 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.

系列导航

关于作者 · 持续学习与技术实践

我是一名拥有 15 年以上开发经验的技术从业者,自 2013 年起持续运营个人技术博客,记录工作与个人项目中遇到的真实问题,以及解决问题过程中的探索、实践和思考。

除了技术写作,我也在持续开发和维护自己的独立项目,探索新技术和新工具在实际场景中的应用。我始终相信,没有不值得去解决的问题,也没有不值得去学习的技术。

如果这篇文章对你有所帮助,欢迎浏览博客中的其他文章,也欢迎交流技术经验与想法。

关于我  ·  GitHub  ·  邮件联系