Skip to content
DocsDocumentation

Limits

Cloudflare ceilings you should keep in your head.

Last updated:

Lunora's design choices are driven by Cloudflare's per-primitive limits. Knowing these numbers helps you predict when to opt into .shardBy() or .global().

Durable Objects

LimitValueImplication
SQLite size / DO10 GBPlan to shard before you cross 1 GB
Sustained req/s / DO~1 000Hot DO = sharding signal
CPU per request30 sLong tasks → action + scheduler, not mutation
Alarm handler15 minWall-clock, per invocation
WebSockets / DO~32 000Hibernated, this is generous; pre-hibernate, less so
WS message size32 MiBReceived messages
Outgoing connections6Simultaneous, per request
DOs / accountUnlimitedSharding is free of administrative overhead

Statement limits (Durable Objects and D1)

These are the ones that surprise people, because they are set by the Workers runtime rather than by SQLite, and they are far below SQLite's own defaults. D1 runs the same build, so both engines share them. Cross one and the statement fails at runtime with SQLITE_ERROR; it does not merely run slower.

LimitValueStock SQLiteWhat it bounds
Terms in a compound SELECT5500UNION / UNION ALL / INTERSECT branches
Bound parameters100500 000? placeholders in one statement
LIKE pattern50 bytes50 000The pattern, not the column
Columns per table1002 000.global() tables take one column per declared field
Row / string / BLOB2 MB1 GBOne document, as stored
SQL statement text100 KB1 GBThe query text itself
Expression depth1001 000Nested AND/OR chains, long + chains
Attached databases010ATTACH is unavailable

Lunora handles most of these for you. Its generated SQL nests compound SELECTs five at a time, binds a long in list as a single JSON parameter, chunks multi-row INSERTs, and compiles contains to a position function rather than a LIKE pattern, so an id probe, a wide where … in, or a 50-character search term does not hit a wall as your schema grows.

Two are yours to respect, because no rewrite can absorb them:

  • A document over 2 MB cannot be stored. Keep large payloads in R2 (@lunora/storage) and put a reference on the row. Note the limit is on the stored bytes, which are UTF-8: multi-byte text (CJK, emoji) costs up to 3× its character count, and v.bigint() / v.bytes() columns are stored twice on a shard-local table.
  • A .global() table over 100 columns cannot be created. Split it, or move the extra fields into one object field.

D1

LimitValueImplication
Database size10 GBUse for global indexes, not bulk data
Databases / account50 000 (1 TB total Paid)Each app gets its own
Queries per Worker invocation1 000Watch your query fan-out in fan-out reads
Bookmark window~24 hSessions API consistency window

R2

LimitValueImplication
Object size5 TBPractically unlimited
EgressFreeCritical: design around direct downloads
Class A opsmeteredBulk-list judiciously

Workers

LimitValueImplication
Bundle size10 MBLazy-load big deps; avoid bundling unused providers
CPU per request30 sSame envelope as DO
Subrequests, external50 (Free) / 10 000 (Paid, configurable to 10 M)fetch() to the internet
Subrequests, internal1 000 (Free) / configured limit (default 10 000)Durable Objects, KV, R2, D1; a cross-shard read spends one per shard
Env var size5 KB totalUse D1 or KV for config above that

Queues

LimitValueImplication
Message size128 KB~100 bytes of that is internal metadata
sendBatch100 msgs / 256 KBWhichever comes first; ctx.queues enforces the count
Consumer batch100 messagesPer consumer invocation
Consumer wall-clock15 minCPU configurable up to 5 min
Retries100Then the message goes to the DLQ
Throughput / queue5 000 msgs/sPast it, sends get Too Many Requests
Backlog / queue25 GBThen Storage Limit Exceeded
delaySeconds24 hLonger schedules → @lunora/scheduler

Pipelines

LimitValueImplication
Payload / ingest call5 MBctx.pipelines.send(records) sends one call; chunk a large batch
Ingest rate / stream5 MB/sSustained; past it the stream throttles
Streams / account20Open-beta ceiling, raisable by request
Pipelines / account20Open-beta ceiling, raisable by request
Sinks / account20Open-beta ceiling, raisable by request

send accepts one record or an array and forwards the array as a single ingestion call, so the 5 MB ceiling is a property of what you pass it. Nothing measures it locally (the byte count would mean serializing every record twice on an egress path), so a batch past the limit is rejected by Cloudflare rather than by Lunora. Split large batches across calls.

Workers AI + Vectorize

LimitValueImplication
Vector metadata10 KiBThe whole object per vector, not just the chunk text
topK with full metadata50Cloudflare's ceiling; defineRag caps at 20, a legacy-V1 holdover
topK otherwise100What a textStore unlocks by keeping text out of metadata

The ceiling covers the whole metadata object (chunk text, Lunora's bookkeeping keys, and any metadata you attach), so defineRag checks it in two places.

At index time it measures the serialized metadata of each chunk and refuses one that would not fit, naming what pushed it over. This is the check that holds: chunkSize counts characters while the ceiling counts bytes, so multibyte text costs up to three bytes each, and your own metadata is not known until a document is indexed.

When the RAG is defined it also rejects a chunkSize that could not fit whatever the ceiling allows, so an unworkable config fails at startup rather than on every upsert. That earlier check applies only where Lunora stores chunk text in the vector's metadata: a textStore keeps the text out entirely, and a custom chunk splitter makes chunkSize inert, so neither is checked.

Workers KV

LimitValueImplication
Key size512 bytesMeasured in UTF-8 bytes, not characters
Value size25 MiBLarge blobs belong in R2
Key metadata1 024 bytesSerialized JSON
Writes / key1 per secondKV is eventually consistent; not a counter
Ops / invocation1 000A bulk request counts as one

Workflows

LimitValueImplication
Steps per workflow10 000 (Paid)1 024 on Free; raisable to 25 000
Step output1 MiBStream large results to R2
Event payload1 MiBPass a reference, not the payload
Persisted state1 GB (Paid)100 MB on Free
Concurrent instances50 000 (Paid)Instances that are waiting don't count
Step sleep365 daysLong waits are free

Containers

Requires the Workers Paid plan. Images must be linux/amd64.

LimitValueImplication
Instance typeslitestandard-4Or custom up to 4 vCPU / 12 GiB / 20 GB disk
DiskephemeralPersist to R2 (@lunora/storage), not local disk
Concurrent / account1 500 vCPU / 6 TiB mem / 30 TB diskRaisable via support
Registry storage50 GB / accountPrune old images with lunora containers images delete
Autoscalingnone yetFixed pools; .any() picks uniformly

Billing is active-CPU based: instances scale to zero on sleepAfter and cost nothing idle. Egress is billed (~$0.025/GB NA+EU, more elsewhere), unlike R2, which is free. See the Containers pricing docs.

When Lunora warns you

The Studio's Advisors pages carry the limit warnings, alongside the rest of the schema and performance lints:

LintFires when
global_table_near_column_limitA .global() table reaches 90 columns, against D1's 100
fan_out_breadthA shard group reaches 500 active shards, wide enough that a cross-shard read over it would strain the internal-subrequest ceiling
hot_shardOne shard takes a dominant share of its group's traffic (a distribution, not a ceiling)

Each fires with runway, because the remediation is a design change (split a table, narrow a read, re-key a shard) and that is not something you can act on in the request that fails.

The statement limits above are different in kind: there is no warning band, because there is no gradual degradation to warn about. A statement either fits or it raises SQLITE_ERROR. Where Lunora can keep you under one it does so silently; where it cannot (the 2 MB document, the 100-column table) it raises an error naming the limit and what to do instead.