Every number Statable shows you comes out of ClickHouse
Statable is privacy-first web analytics, hosted in the EU. No cookies, no sampling, and no throwing away raw rows once a chart has been drawn. Every pageview, every session, every goal completion is kept as data you can still query later.
That last part is the whole reason this post exists. Keeping everything is an easy promise to make and an expensive one to keep, and the thing that makes it affordable for us is the database underneath.
ClickHouse is the primary database behind Statable. Not a cache in front of something else, not a reporting sidecar. Every chart in the product is a query against it.
Why it fits
Analytics has a lopsided workload. Writes arrive constantly, one small row at a time, from browsers we do not control. Reads are rarer but far heavier: a customer opens a dashboard and asks for a month of traffic across a hundred thousand pageviews, and expects it before they finish blinking. Rows are never updated once they settle, and almost every query filters on the same two things, which site and which period.
Written out like that, the workload is close to a specification for a column store. ClickHouse has been a very good fit for it, and the choice has not needed revisiting since.
The part that keeps surprising us is how much it gives back for how little we ask of it. We are not running an exotic setup. There is no custom storage engine, no query rewriting layer, no cache tier in front. We picked sensible defaults, sorted the data the way people actually query it, and the database did the rest.
Four machines, four copies, no shards
The cluster is one shard and four replicas. There is no sharding at all.
Every node holds a complete copy of the data. A write lands on one replica and the others pull it through ClickHouse's own replication. Any node can answer any question, because any node has everything.
flowchart TD
W["A write arrives"] --> N1["Node 1<br/>complete copy"]
N1 -->|"replication"| N2["Node 2<br/>complete copy"]
N1 -->|"replication"| N3["Node 3<br/>complete copy"]
N1 -->|"replication"| N4["Node 4<br/>complete copy"]That is a deliberate trade. Sharding buys you write throughput and storage beyond one machine, and costs you distributed queries, rebalancing, and a much worse day when a node dies. Replication buys you availability and read capacity, and costs you disk you were going to buy anyway.
At our size the second trade is clearly the right one. Each node runs 64 CPU threads, 250 GB of RAM, and four 3.5 TB NVMe drives for data. We are using under 1% of the capacity, which means the ceiling on this design is a long way above where we are standing.
ClickHouse runs natively on the machines rather than in a container, and coordination uses the Keeper built into the server. That is one fewer distributed system to operate, and it has been quiet.
How a row gets in
The tracker is small on purpose: 504 bytes brotli-compressed for the core build, up to 1,855 with the full feature set. There are no cookies, and the visitor identifier lives in page memory and nowhere else.
On the server the event is enriched and buffered in memory, then written in batches over ClickHouse's native protocol. Batching lives in our own service, which flushes on a ticker and hands the insert off without blocking new events arriving behind it.
Three tables are written on each flush: events, sessions, and bots. Bot traffic goes to its own table rather than being flagged in place, which costs a little storage and buys the ability to reclassify history later.
Two tables, two engines
Events and sessions behave differently, and ClickHouse has an engine for each.
An event is finished the moment it happens. A pageview is never true differently afterwards, so events are append-only, partitioned by month and ordered by site and time. Because every customer query filters on exactly those two things, the primary key and the partition do the work before any data is read.
A session is not finished. It opens on the first pageview and keeps changing: more pages, a goal, a duration that grows while someone is still reading. Column stores do not update rows, and this is the point where a lot of analytics products give up and move sessions into a second database.
We did not have to. A session change is written as two rows instead, one cancelling the previous state and one stating the new one, and background merges collapse the pair down to the survivor.
flowchart TD
A["Session row exists<br/>+1 · 1 page · 0s"] --> B["Second pageview arrives"]
B --> C["−1<br/>cancels the old row"]
B --> D["+1<br/>2 pages · 34s"]
C --> E["Background merge"]
D --> E
E --> F["One surviving row<br/>+1 · 2 pages · 34s"]Mutable records in a column store, without leaving the column store. That single capability is the reason our sessions and our events live in the same database and can be joined without crossing a network.
What it returns
The archive holds 1.15 billion events in 56 GiB on disk, down from 283 GiB uncompressed. That is 5.02x, or 263 bytes per row becoming 52. Sessions sit at 3.04x, a little lower because cancelled rows wait there until a merge clears them.
Read latency across 305,754 queries over seven days: 3 ms at p50, 4 ms at p95, 14 ms at p99.
Per column the compression is the most interesting number in the whole system:
| Column | Compression |
|---|---|
hostname | 34.37x |
referrer | 19.98x |
url | 7.10x |
path | 4.27x |
country_code | 3.64x |
user_id | 1.27x |
session_id | 1.25x |
A hostname compresses 34x because a site has one and it repeats forever. Identifiers sit at the bottom because they are random by design, and random data does not compress. Those two columns take 13.67 GiB between them, against 13.86 GiB for every URL we have ever recorded.
Where we landed
No sharding, four replicas, bare metal in the EU, and a database we have not had to fight. The wins came from a primary key that matches how customers actually query, monthly partitions that keep a date filter from opening files it does not need, and letting the engine do what it is good at.
At a billion rows, on this access pattern, a second shard would solve a problem we do not have. When we eventually do have it, the path is well travelled and documented, which is its own kind of reassurance.
Ready to take control of your web analytics? Try Statable free for 30 days. No credit card required, full feature access, built for GDPR. Start your free trial or view a live demo.

