Six downloads counted as four
I run a small tool with a free tier: five original-quality downloads a day, then a paid plan. The first version kept the daily count in Workers KV. I downloaded six images in a row and the counter said four.
Nothing was broken. KV was doing what its documentation says it does, and I had put it in the one place where that behaviour costs money.
1. What the first version did
In essence:
const n = Number(await env.KV.get(key)) || 0;
if (n >= LIMIT) return rejected();
await env.KV.put(key, String(n + 1));
Read, compare, write. Two things go wrong with it.
The first is that the three steps are not one operation. Two requests can read the same n, both pass the check and both write n + 1. One increment is lost.
The second is that reads are cached. Cloudflare's documentation says a change is usually visible immediately in the location where it was made, that this is not guaranteed, and that other locations can take 60 seconds or more. A read just after a write can return the previous value, so the count falls behind even when the requests arrive one after another. That is how six became four.
The figure shows the worst case of the same race with twelve requests at once.
2. Why it mattered here
A counter that is off by two is harmless almost anywhere. This one is the line between the free plan and the paid one. Every download it fails to count is the product given away, and anyone who clicks fast enough gets more than the limit.
3. One object per visitor
The counter is now a Durable Object. Each one is single-threaded and has its own strongly consistent storage. There is one per visitor, addressed by a truncated hash of the IP, so all requests from the same visitor reach the same object and are handled one at a time.
The core of it:
let s = (await this.state.storage.get("s")) || { day, n: 0 };
if (s.day !== day) s = { day, n: 0 };
let allowed = true;
if (s.n < limit) {
s.n++;
await this.state.storage.put("s", s);
} else {
allowed = false; // a rejected download is not counted
}
It looks like the same read, compare and write, with one difference: while the object waits on its own storage, no other request is delivered to it. The check and the increment are one operation.
Two details. The whole state is a single key that resets when the day changes, so there is nothing to clean up and no expiry to schedule. And a Pages project can use Durable Objects but cannot define them, so the class lives in a separate 43-line Worker bound to the site.
The test: 12 simultaneous requests, 5 allowed, 7 rejected, counter at 5.
4. The same bug with a painting attached
I also sell paintings, and each one exists once. Stock is always 1.
The sold mark lived in KV. I fired five simultaneous checkouts at the same painting and all five succeeded.
The fix is the same idea with more at stake. Before creating a Stripe session, the Worker asks a single Durable Object to reserve every painting in the order, all or none. A second buyer gets a 409 and a message saying the painting is being checked out and when it will be free again. With the lock, the same test gives one success and four 409s.
The reservation lasts seven minutes, and that creates a gap: a Stripe Checkout session cannot expire sooner than 30 minutes after it is created, so the first buyer’s payment page can outlive the reservation. When a second buyer reserves after those seven minutes, the Worker expires the first session in Stripe before opening the new one. If Stripe answers that the session is already complete, the second checkout is refused as sold.
Stripe’s own events close the loop. checkout.session.expired releases the reservation at once. checkout.session.completed turns it into “sold” for 24 hours, which covers the time until the KV mark has propagated and the catalogue is redeployed.
5. Where I kept KV
Not everything needs this.
Subscriptions are still in KV. When someone cancels, the server stops validating the licence about 45 seconds later, and the browser extension finds out at its next revalidation, at most twelve hours after that. It is the price of not asking the server on every download, and I can live with it.
The sold marks of the gallery are still in KV too, as later layers behind the lock. Those KV calls are best-effort: if KV fails, the error is logged and the sale goes ahead on the remaining checks. The lock fails open in the same way. An outage in a safety net must not take the shop offline.
6. How I decide now
Two questions.
Is the value read and then written based on what was read? If so, KV cannot do it safely, however rare the collision looks.
What does one stale read cost? If the answer is money or a thing that exists once, it goes in a Durable Object. If the answer is that someone sees old data for a minute, KV is simpler and its reads are served from cache at the edge.