Skip to content

Dashboard cost model

This documents the D1 read/write footprint of the dashboard tables in the current fresh schema baseline, measured against an in-memory SQLite database built from the same dashboard DDL and a synthetic 30-day fixture, plus a per-write-statement count read directly from apps/ingest-worker/src/storage/d1-repositories.ts. D1 is Cloudflare's managed SQLite, so the query planner's index choice (what EXPLAIN QUERY PLAN reports below) transfers directly; absolute latency does not - D1 adds network round-trip time this benchmark cannot reproduce locally. Treat the row counts and query plans as evidence, and the latency numbers as a same-index-vs-no-index sanity check, not a production SLA.

Method

Reproduce this report's numbers directly: node scripts/dashboard-performance-fixture.mjs (requires Node >= 22; node:sqlite is experimental and prints a harmless warning to stderr). The dashboard portion of deploy/cloudflare/migrations/0001_initial.sql is reproduced in a node:sqlite in-memory database (Node 22's built-in driver). The fixture models 2 active sources in 1 project, 30 days of continuous traffic at minute granularity, with 1-5 page views/minute, 1-2 distinct values per classification dimension/minute, and 1-2 distinct visitors/minute - deliberately not a sparse or bursty traffic shape, so the row counts below are a reasonable upper-middle estimate for a small-to-medium site rather than a best case.

Resulting fixture size:

TableRows
dashboard_minute_totals86,400
dashboard_minute_dimensions1,036,800
dashboard_minute_visitors129,600

(dashboard_seen_events and dashboard_aggregate_watermarks were not populated in this fixture - the former is retention-pruned daily and bounded by the last 32 days of distinct accepted event IDs, not by minute granularity; the latter has exactly one row per source.)

Writes per accepted page-view event

Read directly from recordDashboardRollups in d1-repositories.ts, one accepted com.vizoalica.page_view.v1 event, worst case (a project-supplied identity present, so both the source-local legacy visitor table and the new minute-visitors table get a row):

StatementRows written
dashboard_rollups daily upsert1
dashboard_hourly_page_views upsert1
dashboard_hourly_visitors insert-or-ignore1
dashboard_seen_events insert-or-ignore (idempotency ledger)1
dashboard_minute_totals upsert1
dashboard_minute_dimensions upsert × 8 fixed dimension kinds8
dashboard_minute_visitors insert-or-ignore1
dashboard_aggregate_watermarks upsert1 (coalesced per project/source per ingested batch, not per event)
dashboard_seen_events nonce-clear update1 (once per ingested batch, not per event)
Total, single-event batch16

The last two rows are coalesced across an entire ingested batch (an ingest request can carry multiple events): a batch of 25 page-view events writes roughly 14 × 25 + 2 = 352 rows, not 16 × 25 = 400 - the watermark and nonce-clear statements do not scale with batch size.

Reads per dashboard request

getAnalyticsOverview issues 15 D1 statement executions per request, regardless of range length or all-sites vs. one-site scope: 2 total queries (page views, unique users), 2 trend queries (page-view and visitor buckets), 8 dimension queries (4 rankings + 4 distributions, one query per dimension kind), 1 watermark query, 1 taxonomy-version query, 1 identity-kind query. Every one of them, measured against the fixture above:

QueryRows returnedTime (in-memory, no network RTT)Plan
All-sites total, 30d range (touches both sources)17 msSEARCH ... USING INDEX dashboard_minute_totals_source_range
One-site total, 30d range13 msSEARCH ... USING INDEX dashboard_minute_totals_source_range
All-sites distinct visitors, 30d (full fixture scan of the visitor index)163 msSEARCH ... USING COVERING INDEX dashboard_minute_visitors_source_range + temp B-tree for COUNT(DISTINCT ...)
Daily trend, all-sites, 30d3041 msindexed search + temp B-tree for GROUP BY
One dimension ranking (browser), all-sites, 30d257 msindexed search + temp B-trees for GROUP BY/ORDER BY
One-site total, 24h (the default range)10 msindexed search
Hourly trend, one-site, 24h240 msindexed search

No query plan reports a table SCAN against a dashboard table - every one resolves through an index seek on the composite primary key or one of the six explicit indexes in the fresh baseline. The only SCAN in any plan is against sources (2 rows in this fixture; still a full scan in the query planner's eyes, but bounded by a project's website count, which this product already caps operationally). The COUNT(DISTINCT ...) and GROUP BY/ORDER BY queries need a temporary B-tree because SQLite/D1 cannot serve deduplication or aggregation ordering directly from a B-tree index - that's expected, not a missing-index problem.

Capacity planning

Cloudflare pricing and included usage can change. Compare this report's measured row counts with the current D1 pricing instead of relying on a copied free-tier number. Include ingestion storage and request costs as well as dashboard rollups. For higher traffic, rerun scripts/dashboard-performance-fixture.mjs at the expected scale and configure Cloudflare usage alerts before increasing source quotas.

Retention cost

The daily cleanup Cron Trigger deletes rows older than 32 days from dashboard_minute_totals, dashboard_minute_dimensions, dashboard_minute_visitors, dashboard_seen_events, ingestion_decisions, and quota_windows. Each table is deleted in LIMIT 1000 batches, and the job repeats those batches until every table returns a partial batch, up to 200 repeats per run (about 200,000 rows per table per run; see dashboard-retention.test.ts). Steady-state storage is therefore bounded by roughly 32 days of the write volume above, not unbounded growth - a site generating this fixture's traffic shape indefinitely settles at roughly the 30-day row counts shown above, plus two days of margin, rather than growing forever. A backlog beyond the per-run cap (only reachable at well over the fixture's volume) drains over subsequent nights. The same daily run also permanently removes the data of deleted websites and projects (R2 batches and all their D1 rows) in bounded, resumable passes; see the backend guide.

Release 0.5.2 platform review

Reviewed apps/ingest-worker and apps/ingest-api for performance, scale, and cost, without changing the storage design. Findings and resolutions:

AreaFindingResolution
D1 growth (material)The retention job deleted at most 1,000 rows per table per day, while the fixture writes about 34,500 dashboard_minute_dimensions rows/day. The "bounded storage" claim above did not hold. ingestion_decisions and quota_windows were never pruned.Cleanup now repeats until drained and also prunes ingestion_decisions and quota_windows. Fixed and tested.
Unauthenticated request costA request with no or an invalid token still cost three D1 reads (source, project, quota policy) before being rejected.Rejected with 401 before any D1 access unless the unsigned-demo bypass is enabled. Fixed and tested.
Abuse throttlingOnly per-source accepted-event quotas existed.Optional Workers Rate Limiting binding (see the Cloudflare guide). Opt-in so existing deployments are unchanged.
R2 request volumeOne R2 put (a Class A operation) per accepted batch, not per event. Up to 25 events (the batch cap) share one object.No change. The 7-day events/ lifecycle rule bounds storage. Class A operations remain the R2 cost driver.
Per-batch D1 workAn accepted batch costs 3 reads, 2 quota upserts, 1 decision insert, and the rollup batch in the table above.No change; already coalesced per batch.
Retention indexesingestion_decisions.received_at and quota_windows.window_start have no index, so each nightly run scans those tables once.Deliberate: an index adds a D1 write to every ingest batch, which costs more than one nightly scan. Revisit if those tables grow very large.
quota_windows per-second rowsOne row per source per active second (up to 86,400/day/source), now pruned at 32 days.Bounded by retention. A future release could prune second windows after one day to cut storage further.
Dashboard reads15 statements per overview request, all indexed (see above).No change.
Unbounded fetch of an operator-set URLThe console's reachability check read whole response bodies.Capped at 16 KiB. Fixed and tested.

Pricing is intentionally not copied here; compare the row counts and operation counts with current Cloudflare pricing.

Quota assumptions

This model assumes the existing per-source ingestion quotas (maxEventsPerSecond, maxEventsPerDay) remain the actual limiting factor on write volume in practice - the dashboard rollup writes are a fixed multiplier (roughly 14-16x, per the table above) on top of whatever volume already passed those quotas and R2 storage. It does not model concurrent-write contention; D1's transactional batch() (used for every write except the three retained legacy statements) serializes a batch's writes, which bounds worst-case lock contention to one ingest batch at a time per Worker isolate.

Released under the MIT License. Visitor data stays in your own Cloudflare account.