When I moved this blog off WordPress in August 2026, I built a tiny view counter on a Cloudflare Worker backed by KV. Every test passed. Then I fired ten requests at it at the same time and it recorded one view. This post explains the race condition behind that, why Cloudflare KV can't fix it, and the single SQL statement on D1 that did. As a bonus, there's a second bug that quietly made every Arabic post read zero views.
TL;DR#
- The setup: a static Astro blog on GitHub Pages, plus a Cloudflare Worker that counts views.
- The bug: the KV version did read, add one, write. Concurrent visitors read the same number and overwrote each other: ten simultaneous hits became one view.
- Why tests missed it: every test sent requests one after another, so they never overlapped.
- The fix: move the counter to D1 and increment in one atomic statement:
INSERT … ON CONFLICT DO UPDATE SET count = count + 1 RETURNING count. - Bonus bug: decoding an already-decoded URL parameter a second time broke lookups for percent-encoded Arabic slugs.
What did the original view counter look like?#
The blog is fully static, so view counts live in a separate Cloudflare Worker with a small API:
POST /hit/:slug increment, returns { slug, views }
GET /get/:slug read without incrementing
GET /get?slugs=a,b,c batch read for the index page
The first version stored each post's count in Workers KV. Incrementing meant reading the current value, adding one in JavaScript, and writing it back. It is the obvious code, and it is wrong.
Why does read-then-write lose views?#
Imagine two visitors opening the same post at the same moment:
visitor A: read count -> 41
visitor B: read count -> 41
visitor A: write 41 + 1 -> 42
visitor B: write 41 + 1 -> 42 // A's view is gone
Both requests read before either one wrote, so one increment is lost. With ten visitors arriving together it gets worse: they can all read the same number, and the stored count goes up by one instead of ten. That is a classic lost-update race condition.
Why didn't my tests catch it?#
Every test I wrote sent requests sequentially: hit, check, hit, check. A sequential test can never overlap two requests, so it can never show a race. The counter looked perfect until I tested it the way real traffic behaves, with many requests at once:
for i in $(seq 1 25); do
curl -s -X POST https://views.khaledalam.net/hit/$KEY &
done; wait
If you take one thing from this post, take this: test concurrent code with concurrent requests.
Why can't KV just increment atomically?#
Workers KV is designed for read-heavy data that changes rarely. It has no increment operation and no compare-and-swap, so a counter on KV always ends up doing read-modify-write. It is also eventually consistent: right after a write, a read elsewhere can still return the old value for a while. That is fine for configuration. It is the wrong tool for a counter.
How did D1 fix it?#
D1 is Cloudflare's SQLite-based database. SQLite can do the whole increment inside one statement, so there is no gap between reading and writing for another request to slip into:
INSERT INTO views (slug, count) VALUES (?, 1)
ON CONFLICT(slug) DO UPDATE SET count = count + 1
RETURNING count
The first view of a post inserts a row with a count of 1. Every later view updates the existing row, and RETURNING count hands back the new total in the same round trip. Concurrent requests no longer overwrite each other, because the database applies each increment on its own.
What was the bug that zeroed the Arabic posts?#
Some posts on this blog are in Arabic, and their slugs are stored percent-encoded, exactly as WordPress produced them. The index page asks for all counts at once with GET /get?slugs=a,b,c.
The batch handler called decodeURIComponent on the slugs. But URLSearchParams.get() had already decoded them once. Decoding a second time turned the stored, encoded slugs into different strings that never matched a row, so every Arabic post read back as 0, with no error anywhere. The fix was to delete one function call and leave a comment explaining why it must stay deleted.
What else does the counter do?#
- One count per visit, not per reload: the browser remembers a view for 10 minutes in
localStorage, so refreshing doesn't inflate the number. - Read-only index: the post page increments; the index only reads, with one batch request.
- Its own subdomain: the API is served from
views.khaledalam.netrather than a*.workers.devaddress, which ad blockers commonly block.
Frequently asked questions#
Is Cloudflare KV bad?#
No. It's excellent for what it's designed for: data that is read far more often than it's written, where a short delay before changes show up is acceptable. Counters, balances and inventory need atomic updates, so they belong in a database like D1, or in a Durable Object.
Couldn't you just retry on conflict?#
Not with KV: it has no conditional write, so there is no way to detect that someone else wrote in between. You would need compare-and-swap, which is exactly what a single SQL statement gives you.
How much does this cost to run?#
For a personal blog's traffic, the static site on GitHub Pages and the Worker with D1 both run without paying anything.
Key takeaways#
- Read-modify-write without a lock or an atomic operation loses updates under concurrency.
- Sequential tests can't reveal race conditions; send concurrent requests.
- Use KV for read-heavy data, and a database with atomic updates for counters.
- Never decode a URL parameter twice:
URLSearchParams.get()already decodes it.
Have you been bitten by a lost-update bug that every test missed? I'd love to hear about it. Find me at @khaledalam on GitHub.
Comments
No comments yet — be the first.