NotusBase

4. Limits per function

Numbers taken from the code and measured, with NOTUSBASE_DEV_ERRORS=1 to see the message. The ceilings for documents, bytes, time, memory, scheduler and files, and their texts, come from Convex: a test triggers each one in both systems and compares. Defaults are changed through server environment variables; there is no per-function or per-project limit.

On the dashboard. Home → Limits lists every limit of the server, these and the others, with the ceiling as the server applies it, how close your app got (the highest value per call since the server started, and the function that reached it) and what to do; see Limits and problems.

Limit errors can be caught. try/catch catches the read, write, large-document and scheduler errors, and the function carries on: a mutation commits what got through. The counter does not go back, so whoever caught the error stays at the ceiling. A nested ctx.runMutation that fails rolls back what it wrote, but not what it read or scheduled. Only time and memory cannot be caught.

Per query, mutation and action call

LimitValueVariableWhat happens
Query and mutation time1 s wall clock, from entry to exit. The sweep runs every 25 ms: measured 1,018 to 1,056 msNOTUSBASE_FN_MSFunction execution timed out (maximum duration: 1s). Cannot be caught. The isolate is discarded and recreated
Action time1 s of CPU per stretch between two awaits; network wait does not count. Measured: a 1,500 ms sleep passed, an infinite loop was killed at 1,012 msNOTUSBASE_FN_MSFunction execution timed out (maximum duration: 1s). The culprit is killed, and the other actions in the isolate finish in it while new calls go to the other action isolates; then it restarts in place. If the isolate was already draining, they all die together
Total action time10 min, including the wait in the isolate queuefixedFunction execution timed out (maximum duration: 600s)
Documents read32,000 per call, summing nested runQuery. 32,000 passes, 32,001 failsfixedToo many documents read in a single function execution (limit: 32000). Consider using…
Bytes read16 MiB per call, including the mutation's own pending documentsfixedToo many bytes read in a single function execution (limit: 16777216 bytes). Consider using…
Ranges read4,096 per call (db.get and distinct queries)fixedToo many reads in a single function execution (limit: 4096). Consider using…
Documents written16,000 writes per mutation. Every write counts, even on the same documentfixedToo many writes in a single function execution with the ceiling
Bytes written16 MiB per mutation, summing every writefixedToo many bytes written in a single function execution with the ceiling
Memory (V8 heap)64 MiB per isolateNOTUSBASE_HEAP_MB (0 leaves only V8's own heap limit)Query and mutation: JavaScript execution ran out of memory (maximum memory usage: 64 MB); cannot be caught. In an action, the same text, without killing the other actions in the isolate the first time
ArrayBuffer in a query or mutation1 GiB per isolate, outside the heap ceiling, checked at the end of each call (about 0.2 µs)fixedJavaScript execution ran out of memory (maximum ArrayBuffer and WebAssembly memory: 1024 MB); cannot be caught. The isolate is discarded with what it kept, and a mutation writes nothing
ArrayBuffer in an action1 GiB per isolatefixedJavaScript execution ran out of memory (maximum ArrayBuffer and WebAssembly memory: 1024 MB), checked at each stretch, outside the heap ceiling
Wasm memoryCounts toward the ArrayBuffer ceiling of its function: the memories JS can reach (created by the code, exported by a module, or given to it as an import), at their current size, grown from JS or from inside the module. V8 counts wasm memory in no statistic, so the runtime adds it up itself, with the built-ins it took before the code loaded. It guards against accidents, not against the code's own author: code that sets out to hide a memory from the sum can. A memory JS no longer reaches stays in the sum until V8 collects it, so a call past the ceiling first runs a full collection and sums again; only then is it refused. Each memory also stops at the larger of the two ArrayBuffer ceilings (1 GiB), even one a module keeps to itself. Not counted: a memory a module keeps to itself (neither imported nor exported). Each instance of such a module can hold up to the per-memory ceiling, and N instances hold N times that: a known gap, kept on purpose. V8 gives no way to list or measure such a memory, so closing it means rewriting each module at compile time to export it under a hidden name, which code that lists the exports would see. That waits for a real case; until then the process memory limit is what stops many such instances. Convex has no ceiling for wasm memoryfixedAbove the sum, the ArrayBuffer texts above. Above the per-memory ceiling, new WebAssembly.Memory throws a RangeError and memory.grow inside the module returns -1
Node process memory1 GiBfixedthe Node process stopped in the middle of the action: it fails for every "use node" action, and the next call starts another one
Loading the code5 s for the code at the top of every module, on each isolate that loads itfixedThe deploy fails with loading the code took more than 5s and was stopped: look for a loop or long work at the top of a module, outside the functions, and the code before it goes on serving. A loop at the top used to hold the deploy, and every other deploy, forever
Promise that never resolvesThe function ends as soon as there is nothing left to runfixedReturned promise will never resolve
Document1 MiB summing all fields, measured the way Convex measures: bytes in v.bytes() and 64-bit numbers count as their decoded valuefixedValue is too large with the size and the ceiling
Value nesting64 levelsfixedValue is too nested (nested 65 levels deep > maximum nesting 64)
Field nameCannot start with $fixedField name $x starts with a '$', which is reserved.
Scheduled functions1,000 per mutation, with 16 MiB of summed arguments. Scheduling does not count as a writefixedConvex's text with the ceiling. An invalid time (NaN, Infinity, invalid Date) gives Invalid arguments for \scheduler``
Files per mutation10 stored and 10 read, 16 MiB each counts. A file the mutation stored does not count as read, and re-reading the same one does not count againfixedToo many files stored by this mutation (limit: 10) and the other three from Convex
Console lines256 per call in query and mutation, 1,024 in action, and 1 MiB. The last line that fits becomes [ERROR] Log overflow (maximum 256). Remaining log lines omitted. and the rest is dropped. A line longer than 32,768 characters is cut and ends in (truncated due to length)fixedonly what is left comes back in logLines
Function return16 MiB, measured in UTF-8 (v.bytes() counts the decoded size, not base64)fixedFunction <file> return value is too large (actual: 20.00 MiB, limit: 16 MiB)
Function argumentsNo ceiling of their own: the transport ceilings below apply

What counts as a read. Every row the query looks at, as in Convex: the ones it returns and the ones a filter discards. The filter becomes a WHERE in SQLite, so the discarded row never reaches JS, but it counts as a document read (measured on 2026-11-09: with the read ceiling at 5, a filter over 12 rows that returns 1 fails with Too many documents read in a single function execution (limit: 5)). Only its bytes are left out of the 16 MiB. The 1 s time limit also interrupts SQLite in the middle of a scan. On a large table, always use withIndex.

What accompanies a function. ctx.meta.getTransactionMetrics() returns the usage and what is left of each ceiling above, with Convex's 11 keys.

paginate does not hit the ceiling. Reaching the read ceiling in a page ends it with pageStatus: "SplitRequired" and splitCursor. The error only comes out if not even the first document fits. maximumBytesRead cuts the same way. SplitRecommended warns above 3/4 of the byte or row ceiling (default: 32,000), or with more than 6,144 items. numItems above 32,000 gives Requested too many items.

Concurrency and writing

LimitValueVariable
Query/mutation isolatesOne per core (runtime.NumCPU())NOTUSBASE_VMS
Wait for a free isolate5 s. After that the client call gets 429 TooManyConcurrentRequests, counted in notusbase_calls_refused_total, and the client retries. Scheduled work waits without a deadlinefixed
Reaction isolates (subscriptions)4fixed
Action isolates4, each one alternating between several actionsfixed
Scheduled actions at the same time256fixed
Action isolates of their own at the same time64, as Convex's limit of concurrent V8 actions. An action gets an isolate of its own when the code has module state, or after it took a shared one down. Every origin counts: HTTP, WebSocket, httpAction, runAction and scheduled. Above it a client call waits up to 10 s and then gets 429 Too many concurrent requests. Your backend is limited to 64 concurrent actions., counted in notusbase_calls_refused_total; scheduled work waits without a deadline. Measured with the test app, 150 actions waiting on a timer: 150 isolates and 424 to 467 MB of RSS without the ceiling, 64 and 283 MB with itNOTUSBASE_ACTION_ISOLATES
Scheduled jobs running64NOTUSBASE_JOB_WORKERS
V8 isolates aliveThe sum of the pools (NOTUSBASE_VMS + 4 reaction + 4 shared action isolates + NOTUSBASE_ACTION_ISOLATES) plus a margin of two generations of shared action isolates and one probe: 125 on 44 cores with the defaults. The pools never wait on it. What grows past them is the shared action isolates a deploy, an environment change or an out-of-memory action retires: they stay alive while their actions run, up to 10 min. A deploy, a rollback or an import that finds no room for its probe and a new generation waits up to 10 s for them, then fails with 429 saying how many actions still run on old code. The message: No room for new V8 isolates: N actions started before the last changes (deploy or environment variable) are still running. Try again when they end, at most 10 min after each started. V8 isolates alive: X of Y. An environment change loads the new values into the action isolates and the auth.config.ts providers with a swap in the background. The change waits at most 3 s for it; with no room, the swap waits for one of those actions to end, and calls meanwhile do not wait at all: process.env read inside a function is already the new one, but a value the module read when it loaded (a client built at the top of a file with a key) and the providers stay the old ones until the swap happens. notusbase env set and the dashboard say which: applied: the next call of any function reads the new value, or how many actions are in the way and the time by which they end, at most 10 min + 2 s after the swap found no room; the Limits page shows Environment change not loaded yet while it lasts. For logins this is a property of auth: a provider removed from auth.config.ts through a variable, or a key swapped, keeps letting its tokens in for that long. If the code does not load with the new environment (a module or auth.config.ts throws), actions and providers keep the previous values until the next change or deploy, the notice turns red with the error, and the swap is not tried again on every call; one of the two failing does not hold the other. An action isolate that ran out of memory finishes its neighbors with its ceilings doubled while new calls go to the other action isolates, and then restarts in place; that needs no room. With every shared action isolate draining, new calls wait for the restart within their own deadline (10 min): under steady traffic the doubled ceilings used to stay for good. notusbase_v8_isolates_live and notusbase_v8_isolates_ceiling show both numbers; a quick isolate costs about 2.3 MB, one warmed by queries about 8.8 MB (Linux, item 197)No variable of its own: lower NOTUSBASE_VMS or NOTUSBASE_ACTION_ISOLATES
Writer commit batchup to 256 mutations per transactionfixed
Space of deleted rowsGiven back to the disk while the server runs, by the writer, between batches: once the free pages pass 10% of data.db and 64 MiB, passes of 100 pages every 100 ms until under 5%, looked at once a minute. Measured on a 2.5 GB database under load: 7% to 10% less write rate and p99 at about 18 ms while it runs, about 4 MB/s, and about one byte of WAL to the backup per byte given back. Not while writes are paused, without the write lease, with the backup behind or on an isolated copy. A database made before this version keeps the space for reuse, as before, until notusbase vacuum turns the mode on. Home → Limits and notusbase capacity show the space to give backfixed
Mutation conflict2 attempts in parallel. If the function ran in less than 20 ms in its fastest attempt, it runs serially inside the writer and always finishes. The wait for an isolate does not count, so a loaded machine does not make a short mutation give up. If the function itself is slower, it tries up to 8 times and returns a conflict errorfixed
In-memory execution log12 h window and 64 MiB ceiling, which add about 78 MB to RSS when full. The ceiling beats the window and the oldest goes first. 0 MB turns it off. Lost on a crashNOTUSBASE_LOG_MEMORY_MB; the window is fixed
On-disk history of console.log and errors64 MB ceiling in pb_data/notusbase.log and notusbase.log.1, and the dashboard's log days (5). With a full queue, the line is dropped with a warning. A crash loses up to 1 s. The dashboard's log days at 0 turn it offthe 64 MB is fixed

A slow mutation (more than 20 ms of function) on a heavily contended row is the only case where the client sees a conflict error.

Disk error at commit. With the data disk full, a commit can fail with "disk I/O error" after its frames are already in the WAL file. SQLite takes the write as undone, but a crash or a stop with the disk still full would bring it back as committed at the next start. So before answering, the writer cuts those frames off the WAL file. That frees space and needs none, so the write is refused for good and the client gets the usual error. If the cut fails too:

Transport

LimitValueNote
Client WebSocket message16 MiBAbove that the connection drops
Subscriptions per session4,096
In-flight actions per session256
WebSocket silencePing every 15 s, drops after 45 s of silenceThe proxy's proxy_read_timeout must be greater than 15 s
Body of /api/query, /api/mutation, /api/action, /api/run32 MiB (PocketBase's global ceiling). Measured: 30 MiB got past the body limit, 33 MiB gave 413 Request entity too largeThe server applies no ceiling of its own; the proxy's must be lower (the production guide uses 21 MiB)
http.ts route body20 MiB, otherwise 413The body is buffered
http.ts route responseNo ceiling; when streamed, each chunk has 30 s to be delivered
File upload1,024 MB per fileNOTUSBASE_UPLOAD_MB; the proxy's ceiling must match
Upload linkValid for 1 h, single use
fetch inside an actionA response of up to 1 MiB with a known size arrives whole; the rest in chunks of up to 64 KiB. It does not reach loopback, private network or link-local (allow with NOTUSBASE_FETCH_ALLOW)
Client clockAt most 1 min ahead
ImportUncompressed zip up to 256 GiB; a documents.jsonl line up to 64 MiB--import-limit

A file inside a function. What each path holds in memory, measured on 2026-11-07 on the Mac (the process RSS above the 94 MB at rest, and the isolate's peak in MB; the Mac was loaded, so the memory counts and the time does not):

PathFileBeforeAfter
Action: ctx.storage.store(blob)10 MB+188 MB RSS, heap 40.9+67 MB, heap 10.1
50 MBfails: over the 1 s of CPU (already at +319 MB and heap 53.7)passes: +161 to +194 MB, heap 10.1
200 MBfails: 1 s of CPU (+807 MB)passes: +597 to +606 MB, heap 10.1
Action: store(await (await fetch(url)).blob())10 MB+194 MB, heap 40.9+62 MB, heap 10.2
50 MBfails: 1 s of CPU (+267 MB)passes: +112 MB, heap 10.3
200 MBbody refused (64 MiB)same: the refusal comes from fetch, not from store
Action: ctx.storage.get(id)10 MB+120 MB, heap 27.5+48 MB, heap 14.2
50 and 200 MBrefused after reading 20 MiBrefused before opening, +1 MB
Route GET /api/storage/<id>10, 50 and 200 MB+0 MB+0 MB (it already went out in chunks)
Mutation: ctx.storage.store(blob)10 MB+168 MBsame: the 16 MiB ceiling and the 1 s limit it

For the 50 and 200 MB files, most of the "after" is the Blob the action built: the measurement fixture (notusbase/app/convex/bigfile.ts) creates the file and new Blob([...]) copies it, three ArrayBuffer copies outside the heap (external of 607 MB with a 200 MB file). Building the same Blob without storing it gives RSS +605 MB and external of 600 MB, and storing adds less than 10 MB. The cost of store itself is then the 1 MiB chunk plus what Go holds (one chunk, the temporary file on disk and the hash).

ctx.storage.store(blob) and ctx.storage.get(id) in an action pass the file in 1 MiB chunks. The upload goes straight to a temporary file on disk, with the hash computed along the way, and the read comes out of the file one chunk at a time into a single Uint8Array, which becomes the Blob without copying. The Blob stays whole inside the action, because the API takes and returns it whole; it is the only copy left (1x the file, outside the heap, counting toward the isolate's 1 GiB of ArrayBuffer). The ceilings on the path:

In a mutation, ctx.storage.store writes the file right away, outside the writer, and the _storage row joins the transaction. If the mutation fails or hits a conflict, the file is deleted, and the conflict stores it again on retry. A server crash in the middle leaves a file without a row, which nobody can reach; never the other way around.

Compared with Convex

The Convex numbers are the ones on the official limits page (docs.convex.dev, read on 2026-11-06), Pro plan. They tell you what changes when you migrate.

ItemConvexNotusBase
Query/mutation1 s, user code only (database not counted)1 s wall clock, includes the database
Action30 min (Convex runtime), 10 min (Node)10 min, 1 s of CPU per stretch
Action memory64 MiB (Convex runtime), 512 MiB (Node)64 MiB of heap and 1 GiB of ArrayBuffer per shared isolate; "use node" with 1 GiB per process
Documents read32,000 (a filtered document counts)32,000 (a filtered document counts)
Documents written16,00016,000
Bytes read / written16 MiB / 16 MiB16 MiB / 16 MiB; the bytes of a document the filter discarded do not count
Index ranges read4,0964,096
Document1 MiB1 MiB
Document nesting1664
Scheduled jobs per mutation1,000, 16 MiB summed1,000, 16 MiB summed
Function argument16 MiBno ceiling (transport: 16 MiB on WebSocket, 32 MiB on HTTP)
Return16 MiB16 MiB
Log lines256 (query/mutation), 1,024 (action)256 (query/mutation), 1,024 (action)
Files per mutation10 stored, 10 read, 16 MiB10 stored, 10 read, 16 MiB
Patch with no changedoes not count as a writedoes not count as a write (measured)
HTTP action bodyresponse up to 100 MiB; no request ceilingrequest up to 20 MiB; no response ceiling

How to protect yourself