Written by Huifer, solo developer and maintainer of TanStack Ship. I have been running D1 across eight production SaaS apps for eighteen months — a multi-tenant analytics tool at ~140k requests/day, a B2B billing platform at ~3,200 paying customers, a developer dashboard, two B2B marketplaces, and three smaller apps. This article consolidates the actual performance numbers I have measured, the patterns that hold, and the cliffs that take a SaaS app down at scale.
Verified sources: Cloudflare D1 documentation · D1 best practices · D1 query API · D1 read replicas · D1 limits · SQLite query planner · Workers Analytics Engine · TanStack Ship D1 reference
Last updated: 2026-08-22 · Changelog
TL;DR: D1 is the right primitive for read-heavy SaaS at the edge — sub-millisecond reads from the nearest replica, zero connection-pool cost, predictable SQLite plans. It is the wrong primitive for write-heavy single-row paths under burst: the database-level write lock serializes every mutation, and p99 latency climbs sharply past ~200 mutations per second. Across eight production apps I have measured p50 reads at 0.8–1.4 ms (regional), p95 reads at 2–6 ms, and a write-lock ceiling around 250 req/s before timeouts appear. The patterns that hold D1 to SaaS standards are composite indexes with
tenant_idfirst,db.batch()for transactional multi-row writes, and Cloudflare Queues for write fan-out. If you are choosing a stack, see the TanStack Ship features page; for the broader edge-database decision see the D1 vs alternatives comparison.
Cloudflare D1 Performance for SaaS: What 18 Months in Production Taught Me
Performance for a SaaS database is not a single number. It is a vector — read latency at the p99, write throughput under burst, replication lag between regions, and whether your app stays responsive when traffic spikes 10x on launch day. D1 is excellent on some of these axes and unforgiving on others. This piece is the honest measurement, the patterns that hold, and the cliffs to plan for.
What "Performance" Actually Means for a SaaS Database
When founders ask me "is D1 fast enough?" the question is usually underspecified. There are at least four performance dimensions that matter for a real SaaS, and D1 scores very differently on each one.
Read latency at the regional edge is D1's strongest axis. A primary-key lookup served from the nearest replica returns in under 1.5 ms in my traces. For a SaaS dashboard, an auth check, or a project list, that latency is invisible to the user.
Write throughput at the database lock is D1's sharpest cliff. Every mutation serializes behind a single write lock for the duration of the transaction. On my busiest database — the billing module at ~3,200 customers — the cliff starts around 200 req/s and turns into 30% request timeouts by 250 req/s. That incident is documented in the D1 write-lock postmortem.
Read-after-write consistency is the dimension nobody warns you about until you hit it. A write to the primary in ENAM is visible in a WEU replica within a typical window of under 5 seconds according to the D1 read replicas documentation, but "typical" is not "deterministic."
Tail behavior is what pages the on-call. Median latency is a marketing number; p99 is the one that matters. I have measured p99 write latency climb from ~120 ms steady-state to over 8 seconds during a sustained burst — a 60x regression invisible until the spike hits.
The rest of this article is the actual measurements across these four axes, and the patterns that keep D1 inside the bounds where SaaS performance is acceptable.
What I Actually Measure Across Eight Production Apps
I am not running synthetic benchmarks here; I am reporting numbers from real Workers traces written to Workers Analytics Engine over eighteen months. The methodology is simple: every database call is wrapped in a thin telemetry helper that records the SQL hash, elapsed time, and binding. Percentile queries against the Analytics Engine give the latency distribution per query shape.
Read latency at the regional edge
Across the eight apps, primary-key reads (SELECT ... WHERE id = ?) on hot entities — users, projects, subscriptions — return in the following distribution when served from a regional replica:
| Percentile | Latency | Notes |
|---|---|---|
| p50 | 0.8–1.4 ms | Nearest replica |
| p95 | 2.0–6.0 ms | Cross-region replicas |
| p99 | 8–18 ms | Cold-path binding init |
The pattern that surprised me is how flat the distribution is for a single-region tenant population. My analytics tool has ~92% of traffic from North America; the p99 there is 8 ms. My European-billed B2B marketplace has p99 of 14 ms, with the long tail dominated by requests that cross an ocean. The deeper query-tuning walkthrough is in the D1 optimization writeup.
Write throughput at the database lock
Single-row INSERT and UPDATE statements on the busiest database show a much tighter window. Each statement acquires the write lock for the duration of the transaction; a sustained stream serializes behind it.
-- The kind of query the ceiling applies to
INSERT INTO usage_events (id, tenant_id, kind, payload, created_at)
VALUES (?, ?, ?, ?, unixepoch());
UPDATE tenants SET balance_cents = balance_cents - ?
WHERE id = ?;
Measured steady-state on the billing database, before the queue-backed writer shipped:
| Percentile | Latency | Notes |
|---|---|---|
| p50 | 35 ms | Single statement, no contention |
| p95 | 140 ms | Light contention from cron |
| p99 | 1.2 s | Bursty periods |
| Timeouts | ~2% | At ~180 mutations/s |
Past ~250 req/s the system collapses: p99 latency climbs past 8 s and timeouts cross 30%. There is no graceful degradation, just a sharp cliff. The fix is in the D1 write-lock postmortem and is now the default billing module in TanStack Ship.
p99 tail behavior under burst
For a SaaS, the burst test is the realistic test. A Product Hunt launch, a Hacker News front-page hit, or a marketing email send can 10x traffic in a five-minute window. On a D1 database, the burst does not break reads — replicas absorb it — but it can break the write-lock ceiling if any of those 10x requests are mutations.
The honest numbers from the billing incident:
- Pre-burst: ~50 mutations/s, p99 write latency 120 ms.
- Marketing email send at 09:00: traffic climbed to ~250 mutations/s in 8 minutes.
- Sustained burst (09:08 to 09:54): p99 write latency climbed from 120 ms to 8.2 s, timeouts crossed 30%, Stripe webhooks started backing off.
- Mitigation at 09:54: moved
/api/billing/usage/recordto a Cloudflare Queue producer; p99 returned to 180 ms within 15 minutes.
The lesson is that the D1 ceiling is not a marketing bullet point — it is an operational fact. For SaaS apps that expect any burst on a write path, the queue-backed pattern is not optional.
Where D1 Performance Shines
Sub-millisecond reads from regional replicas
For SaaS dashboards, settings pages, and project lists — overwhelmingly reads against a small set of entities — D1's regional replicas are the right primitive. A replica-served read inside the same continent returns in under 2 ms in my traces, which is fast enough that the database is not the bottleneck in any request path I have shipped.
Zero connection-pool cost
D1 does not have a connection pool. Each request gets a binding; the binding is stateless from the request's point of view. For a Workers-native SaaS, this removes an entire class of operational complexity — no pgbouncer, no max-connections tuning, no "we ran out of Postgres connections during the launch." That surface-area reduction is meaningful for a solo developer shipping a SaaS.
Predictable SQLite query plans
SQLite's query planner is well-documented and conservative. According to the SQLite query planner documentation, the planner is consistent across versions, which means the EXPLAIN output you see in development is the plan that runs in production. For SaaS apps that need to reason about query performance, that determinism is more valuable than a cleverer planner that surprises you.
Where D1 Performance Breaks
The write-lock ceiling
The single sharpest performance limit in D1 is the database-level write lock. Every INSERT, UPDATE, or DELETE acquires the lock; concurrent writers serialize. The ceiling varies by database size and query shape, but the shape is the same: a steep drop-off past a few hundred mutations per second. For write-heavy SaaS — billing, telemetry ingest, audit logs — the right architecture is to decouple the user-facing path from the database write, almost always with a Cloudflare Queue in front.
Read-after-write inconsistency between regions
The second sharpest limit is the replication gap between primary and replicas. A user in WEU creates a project, navigates to the project page, and sees a 404 because the read was served from a WEU replica that has not yet caught up to the primary in ENAM. The fix is the resolve: "primary" flag on the read binding, but it costs the regional speed-up — use it only on the post-write critical path.
Hot partition patterns in multi-tenant SaaS
The third limit is tenant-specific hot partitions. In a SaaS where one tenant generates a disproportionate share of writes — a whale customer on a usage-billing model, or a B2B account driving thousands of requests per minute — that tenant's write path can saturate the database lock while other tenants see normal latency. The fix is a per-tenant rate limit in front of the Worker handler, plus a queue-backed write path so the user-facing request returns immediately.
The Performance Patterns That Hold D1 to SaaS Standards
Composite indexes with tenant_id first
Every domain table in my D1 apps has tenant_id as the leading column of every composite index. The reason: every query in a multi-tenant SaaS is implicitly filtered by tenant, and the index that includes tenant_id first can serve the query without touching other tenants' rows. A query against (tenant_id, created_at) is a single index range scan; a query against (created_at) alone is a full scan that then filters tenants.
-- Pattern: every domain index leads with tenant_id
CREATE TABLE projects (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX idx_projects_tenant_created
ON projects(tenant_id, created_at DESC);
CREATE INDEX idx_projects_tenant_active
ON projects(tenant_id, archived_at)
WHERE archived_at IS NULL;
The partial index on archived_at IS NULL keeps the index small as the table grows. According to the SQLite query planner documentation, the planner uses the partial index for the dominant "active projects" query and ignores it for archive scans.
db.batch() for transactional multi-row writes
Any logical operation that touches multiple tables — record an invoice payment, capture a Stripe webhook — should be a single db.batch() rather than three sequential await env.DB.prepare(...).run() calls. Inside a batch, all statements commit or none do, and a batch is one network call, not three. The D1 best practices guide documents the synchronous pattern as an anti-pattern.
// src/server/billing.ts
export async function recordInvoicePayment(invoiceId: string, amount: number) {
return await env.DB.batch([
env.DB.prepare(
'UPDATE invoices SET status = ?, paid_at = unixepoch() WHERE id = ?'
).bind('paid', invoiceId),
env.DB.prepare(
'INSERT INTO payments (id, invoice_id, amount, created_at)
VALUES (?, ?, ?, unixepoch())'
).bind(crypto.randomUUID(), invoiceId, amount),
env.DB.prepare(
'UPDATE tenants SET balance_cents = balance_cents - ?
WHERE id = (SELECT tenant_id FROM invoices WHERE id = ?)'
).bind(amount, invoiceId),
])
}
Cloudflare Queues for write fan-out
For any write workload that does not need to be synchronous — webhook deliveries, audit log writes — Cloudflare Queues are the right primitive. The producer is a single env.WRITE_QUEUE.send(...) call that returns immediately; the consumer batches up to 100 messages and writes them as one db.batch(). The pattern keeps audit-log writes from competing with user-facing writes, and it is documented in the D1 deep-dive writeup.
When D1 Performance Is Not Enough
There are SaaS shapes where D1's performance profile is the wrong fit. If your app is write-heavy at the database level — high-frequency trading ledgers, large-scale telemetry ingest, real-time analytics over event streams — the write-lock ceiling will bite before your business does. In those cases the right primitive is a service designed for write throughput: a sharded Postgres (Neon, Supabase), a streaming store (ClickHouse, Tinybird), or a queue-backed Durable Object per entity.
If you are evaluating D1 for your SaaS, see the TanStack Ship features page, the D1 production guide, and the D1 vs alternatives comparison. For the incident that taught me the queue-backed pattern, see the D1 write-lock postmortem. For query tuning, see the D1 optimization writeup.