SQLite for Web Analytics: How I Made It Work

· 4 min read

I like building things to see if they work. Sometimes that means ignoring the standard advice.

Web analytics usually means ClickHouse, or Kafka, or at least Postgres with TimescaleDB. Big tools for big data. But most sites don’t have big data — they have thousands of visits, maybe tens of thousands. Single-tenant analytics isn’t a scale problem.

So I wondered: could a plain relational database handle this? And if so, how simple could we make it?

Aggregation changes everything

Nobody needs millisecond-level granularity in their dashboard. Hourly buckets are fine. And once you aggregate by hour, the numbers get small fast: 24 rows per day per dimension. A year of data fits in megabytes, not gigabytes.

At 100K visits/day, you’re looking at roughly 26 GB/year. Scale to 500K and it’s 130 GB. Even a million daily visits only hits 260 GB/year. This isn’t big data. This is just… data.

So yes, any relational database handles this easily. But that got me thinking: if the data is this small, do we even need a database server?

What if we used SQLite?

SQLite has one limitation that scares people off: concurrent writes. Only one writer at a time. Under heavy load, writes queue up and block each other.

But analytics doesn’t need instant consistency. Nobody’s refreshing dashboards 50 times per second. What matters is never losing an event. So instead of fighting SQLite’s limitation, I leaned into it — with coordination across layers.

The browser buffers

The SDK batches events every 200ms and retries three times with exponential backoff. If the network drops, it caches up to 100 events in localStorage. When you close the tab, sendBeacon() fires off whatever’s left.

The SDK doesn’t fire and forget. It listens for the server’s response.

The server pushes back

Sometimes the server says “slow down.” When it’s overwhelmed, it returns 503 with a Retry-After header. The SDK respects that — waits, then tries again. Events don’t drop, they just arrive later.

This handshake keeps the pace manageable. The browser and server agree on how fast events flow.

The database absorbs writes gradually

Events land in a staging table — dead simple schema, optimized for fast INSERTs. If SQLite is busy, the write retries up to 10 times with backoff. A background job drains the staging table in batches and rolls everything into hourly aggregates.

The dashboard reads from those aggregates. Under high load it might lag a few minutes — but you’re not a stock trader. Who cares?

One process. One file. No Redis queue, no Kafka buffer, no worker cluster. Coordination lives in code, not infrastructure.

This isn’t for everyone

Could SQLite keep up at a million visits/day? Maybe. But if you’re at that scale, you’re not who I built this for. You have a team. You can afford managed Postgres. Go use Plausible or roll your own.

This is for the rest of us. Most sites see 10K-50K visits/day — that’s the reality for most projects, most businesses, most side hustles.

It scales further than you’d think

It’s not 2010 anymore. SQLite on NVMe delivers sub-millisecond writes. The bottleneck everyone worried about a decade ago just isn’t there.

Start with a 512MB server on Hetzner. When you outgrow it, bump to 1GB. Then 2GB. Then 32GB. Vertical scaling is underrated — no network hops, no distributed coordination, no consistency headaches between nodes.

Backups? Litestream streams every change to S3 in near-realtime. Point-in-time recovery, disaster recovery — all handled. Or just snapshot the file and ship it somewhere safe.

Radical simplicity, pragmatic results. One process. One file. If you ever outgrow it, you’ll know exactly when.


Want to try it? Fusionaly is open source on GitHub.

New posts in your inbox. Unsubscribe anytime.