d1bench: which Cloudflare D1 write strategy wins, and which limit stops you first
Authors/Creators
Description
Cloudflare documents D1's limits one at a time. It does not document how they interact, and they interact in a way that changes which bulk-insert strategy is correct. This harness deploys inside a Worker, because two of the limits under test only exist inside a Worker invocation, and sweeps three write strategies across column count and row size against a live database.
Five measured results, none computed from the documentation.
- The documented limit of 1,000 queries per Worker invocation is exact
and enforced. Tested with one fresh invocation per rung and the cheapest
possible statement, 1,000 queries succeed and the 1,001st is refused with
Too many API requests by single Worker invocation, the first 1,000 having completed. - What the documentation does not state is how a
db.batch()is counted against that budget. Measured in the same invocation shape and both above it: 2,000 queries issued one at a time are refused after exactly 1,000, while 2,000 statements inside a singlebatch()complete in 421 ms. A single call carried 150,000 statements. A batch therefore costs one API request rather than one per statement, so the common advice to chunk a bulk insert under 1,000 statements addresses a limit that does not apply to it. The budget constrains how many times you call D1, not how much SQL you hand it per call. - The ceiling on a single batch is a budget of time, not of statements. 150,000 statements succeeded and 175,000 were refused. Which limit produces that is deliberately not claimed: at least three candidate 30-second ceilings sit within range of one another, and the two refusals returned different error text. Isolating the cause was not attempted, so the result is reported as a shape rather than a number, and it moves with row width.
- The bound-parameter ceiling is exactly
floor(100/C), confirmed by climbing until D1 answerstoo many SQL variablesat every column count. At 51 or more columns that is one row per statement, so chunked bound parameters has silently become one-statement-per-row while still being described as batching by the code that uses it. - Literal SQL is 2.7 times faster at 64 columns, 187 ms against 503 ms, because it still carries 77 rows per statement where bound parameters carry one.
- On the row-size axis the comparison reverses and ends in a wall. Literal SQL's chunk collapses as rows fatten, and once a single literal tuple exceeds the 100,000-byte statement limit no chunk size works. At roughly 131 KB rows literal SQL is refused outright rather than merely slower, while bound parameters are unaffected, because bound values travel beside the SQL rather than inside it.
- The result the other four are consequences of: the two strategies are capped by orthogonal limits. Bound parameters bind on column count and are invariant to row size. Literal SQL binds on row size and is invariant to column count.
The check that makes these findings rather than timings. Row counts were reconciled against every statement sent: 260,487 expected across the batch probes, 260,487 present, exact. A benchmark that reports a time for writes which never happened is worthless.
A methodological result that applies to any Cloudflare Workers
benchmark. In Workers, Date.now() advances only on I/O and is
frozen across pure computation. A clock inside the Worker therefore cannot
observe the CPU spent building a statement, and literal SQL is precisely the
strategy doing that work, so timed only from inside it would appear better
than it is and the headline would be an artifact of the measuring instrument.
Every run was therefore timed from both sides. Measured outside overhead came
to 84 to 98 ms and was the same for both strategies at every column
count, establishing that the invisible CPU did not produce the reported
gap.
Limitations, stated rather than implied. One region (ENAM), one database, one account, one day. No indexes on any table. No sweep of escaping cost, which is the one untested axis that could narrow literal SQL's advantage. Nothing here tests the 30-second query-duration cap, which applies per statement and was never approached; an earlier draft of this work claimed otherwise by conflating a per-statement limit with a per-call total, and that correction is recorded in the findings document. The ratios and the orthogonality are the claims; the absolute timings are evidence for them, not the result.
Files
d1bench-v0.3.1.zip
Additional details
Related works
- Is supplement to
- https://github.com/vince-gonzalez/d1bench (URL)