Counters sound like one of those features that should be too small to write about. You increment a value, read it back, and maybe show it somewhere. A database table and a couple of stored procedures should be enough. So when I decided to start collecting basic telemetry across my websites, I figured I would just build it myself. I wanted something cheap, simple, and unlikely to fall over with traffic.
Hosting
The first thing I had to decide was where to host it. I already had spare compute through my homelab and a free Oracle server, but I didn't want this to become another service I had to babysit. If I spun up my own database, I would have to care about updates, networking, backups, security, and whatever weird failure showed up six months later when I had already forgotten how I set it up. Supabase felt like the right compromise. I had seen it used in school projects, forums, YouTube videos, and general developer chatter, mostly because people kept talking about the free tier. For this project, that was enough to get me interested. It had Postgres, RPC functions, a JavaScript client, and limits that looked more than comfortable.
Initial Implementation
Most of my websites are static, so I wanted the counters to be something the browser could increment and read directly. Supabase made that easy enough. I could expose a few RPC functions and call them from the browser with a JavaScript client. I wrote a small browser client around Supabase's client with helper functions for incrementing and reading counters. Each site only had to call something like incrementCounterById, and the client handled the actual request. That abstraction ended up mattering later. At the time, it was just the cleanest way to avoid duplicating counter logic across several websites. But it also meant I could replace the backend later without digging through every place that recorded a hit.
On the database side, I had two types of counters. The first type was “broad” counters. These were site-level totals and values I wanted to keep around indefinitely. The second type was “versioned” telemetry. Those were counters tied to a specific content version, mostly for things I expected to replace or discard after an update cycle. I created this system specifically for VLViewer since game data can all move around between updates, and I did not want every old telemetry value to live forever as if it still mapped cleanly to the current site.
To prevent people from flooding the database with random counter names, I seeded valid counter IDs during deployment through GitHub Actions. For VLViewer, the scripts knew which voice lines, conversations, and characters were valid for the current data version. They called database procedures to create those rows ahead of time, and the client could only increment counters that already existed.
This setup worked for a while. It survived when VLViewer reached around 20,000 monthly visitors, and I assumed the user base probably would not get much larger than that. Then the January 2026 Old Gods New Blood Deadlock update happened. VLViewer peaked at around 150,000 visitors, with millions of voice lines played. The database itself was still simple enough that most of the usage stayed below the free limits. The problem was not the one I expected.
Egress
I hit the free plan's egress limit.
Supabase gave me a grace period before anything user-facing broke, so at first I hoped I could ride out the spike. The problem was that traffic did not return to the old baseline. VLViewer settled closer to 100,000 visitors a month, which was still far higher than before. Even after some optimizations, I was slightly over the limit.
At that point, I had two choices. I could pay for Supabase Pro, or I could move the counters somewhere else. Supabase Pro gives you a lot for the price, but I did not have enough other projects using the database to make it feel worth it yet. VLViewer also does not make money, and I do not currently plan to monetize it. I wanted the hosting cost to stay as close to zero as I could, so I would need to switch to something usage-based. So, I started looking at Cloudflare.
Why Cloudflare fit
Cloudflare was already part of my stack. I use it for DNS, hosting, caching, and parts of VLViewer's asset delivery. I had also been using Workers for some smaller tools, so it was not a completely new environment for me. That made it a good fit for this specific problem. I did not want to manage a server just to count voice line plays, and I did not need a full database platform for what had become a very narrow write-heavy workload.
The pricing model also matched the way I wanted to think about the project. Instead of paying a fixed monthly cost before I really needed it, I could design around usage and keep the system cheap as long as the workload stayed simple. That changed the design goal, though. With Supabase, I was mostly thinking about staying under free-tier limits. With Cloudflare, I was thinking about reducing the number of billable operations.
Swapping clients
Because I had already hidden the raw Supabase calls behind helper functions, the migration was mostly a client swap. The rest of the sites still called the same counter helpers. It also gave me a place to add optimizations without touching every call site.
Since I am billed per Worker invocation, I did not want every single voice line play to immediately turn into its own request. I changed the client to collect hits for a short window and then send them in batch. This matters on VLViewer, since people often play several voice lines on the same page. The client also tries to flush pending hits on pagehide with keepalive, so plays are more likely to be sent before someone closes the tab or navigates away. It is not perfect, it was acceptable for this use case.
The Worker API
The Worker has four public endpoints:
POST /v1/hit/:name
POST /v1/hits
GET /v1/count/:name
GET /v1/counts
POST /v1/hit/:name records a single hit. POST /v1/hits accepts a batch of deltas. The two GET routes read one counter or all counters.
On a write, the Worker validates the counter name, rejects invalid input, ignores virtual totals, applies rate limiting, and adds the delta to a small buffer in Cloudflare's Cache API. That buffer is local to where the request is handled, so the same counter can temporarily have pending deltas in more than one location. A cron trigger runs every five minutes and flushes those buffered deltas into a Durable Object, which acts as an authoritative store. The Durable Object uses SQLite and keeps a simple counter table with a name and count, similar to the old Postgres table.
Reads are also designed around caching. When the browser asks for a count, the Worker reads the persisted value from the Durable Object and adds any pending local delta from the closest colocation’s buffer. That way, if someone plays a line and the page immediately refreshes the count, the number does not look stale and includes their plays. However, requests routed somewhere else might not see another colocation’s buffered hits until they flush. For this use case, that is fine. These counters are for display, and I am okay with them being slightly inaccurate in a 5-minute window.
An obvious optimization
The migration also exposed one design mistake I should have caught earlier. On mcallbos.co, I show the total number of voice lines played across VLViewer. In order to achieve this, playing a voice line on VLViewer used to write to a per-game counter and a VLViewer total counter. However, I should have treated the total as derived data from the beginning. I fixed that during the transition to Workers. The ‘total-voiceline-plays’ counter is now virtual, with the Worker computing the total from the game counters when needed.
What I took away
The transition was probably more work than it was worth. I could have paid for Supabase Pro and moved on. At some point, I will likely end up needing a paid database tier anyway. But I still think it was worth doing. Best case, I have a counter system that is better prepared for another traffic spike, especially if Deadlock grows after release. Worst case, I got practical experience designing around cost, batching writes and caching reads.
VLViewer is the first personal project I have had where usage patterns forced me to care about infrastructure in a more serious way. Not enterprise serious, but serious enough that "just throw it in a database" stopped being the obvious answer. That's the part I find interesting. The counter itself is not complicated. The hard part was figuring out where the cost actually came from, then changing the design around that. Small features can have real architecture hiding underneath them. You do not always need to build for that on day one, but once the traffic shows up, the shortcuts become visible pretty quickly.