The full, typed signature of every global is at /docs/runtime/api/
(served live at GET /runtime.d.ts, over MCP as the resource substrio://runtime/types).
These pages explain it; the types are the source of truth.
- kv.md: key-value stores of bytes.
- sql.md:
db.sql,db.prepare, parameters,db.transaction. - collections-and-chains.md: documents and the chain.
- chain-reference.md: every chain method with its arguments, answer, errors and an example; the filter operators; how a chain runs.
- records.md: live records and query results.
- services.md: the
substrio.*services. - durable-objects.md: exported classes, one live instance per id, storage, alarms.
- queues.md: durable queues and background jobs,
substrio.queue. - errors-and-logs.md: what a failure records and how to read it.
A function
One file per function (the loader has no resolver): a function that imports
only the builtin modules is sent as is, and anything else
is bundled first by the CLI (substrio deploy, substrio push), not by the server:
see cloud-functions.md. Three
forms are recognised by what the source contains:
// ES module
export default { async fetch(request, env, ctx) { return Response.json({ ok: true }); } };
// CommonJS
module.exports = async function (event) { return { ok: true }; };
// Script: evaluates to a function
(function (event) { return { ok: true }; })
What the server itself resolves, unbundled, is the builtin modules; an npm
package is resolved by the CLI, at bundle time, from the project's own node_modules.
Handler shapes
Each trigger kind has one entry. Cron, document and queue triggers exist; there
is no email trigger, so an email() export is never called.
| Call | Entry |
|---|---|
| HTTP route | fetch(request, env, ctx), or the default-export function |
| cron | scheduled(event, env, ctx), else the default-export function called as handler(event) |
| queue delivery | queue(batch, env, ctx) (see queues.md) |
| document trigger | the default-export function, handler(event) |
substrio.functions.<name>(...), plain call |
the default-export function, handler(...args) |
substrio.functions.<name>.fetch(request) |
fetch(request, env, ctx) |
export default { fetch } is not called for anything but an HTTP route call.
A cron or trigger call on a module without the export it needs fails, and the
error names the export (for cron: "a cron trigger needs a default-export
function or a scheduled(event, env, ctx) export").
export default {
async scheduled(event, env, ctx) { // event.cron, event.scheduledTime (ms)
db.counters.insert({ at: event.scheduledTime });
},
};
A handler may be async. Everything in db is synchronous.
Limits
| Limit | Value |
|---|---|
| CPU time per call | 5,000 ms; over it: HTTP 408 CF_CPU_TIMEOUT |
| JS heap per isolate | 256 MB; over it: HTTP 413 CF_MEMORY_EXCEEDED; native memory is not counted |
| Memory across all of the app's isolates | over it, a call that needs a new isolate gets HTTP 503 CF_TENANT_MEMORY, retryable |
| Wall clock per call | 30 s; over it: "The function's promise never settled" |
| Concurrent calls of one app | 512 in flight; over it: HTTP 429 |
| Server busy | HTTP 503, retryable |
| Cron and trigger runs when the server is busy | refused: a cron run is recorded as refused and retried with backoff (cron.max_attempts), then dead-lettered |
| Request body | 16 MiB |
| Response body | 64 MiB; over it the call fails with an error naming the limit |
| One kv value, SQL parameter, fetch request or response body | 16 MiB; over it the call throws an error naming the limit |
Direct call nesting (substrio.functions) |
64 deep; the next call throws call_depth_exceeded naming the limit |
| fetch | 30 s per request; loopback and private addresses refused |
An app can run under its own CPU, memory, concurrency and wall-clock limits;
tenant.usage.self reads the ones in force. No app is unlimited. A run's own
timeout_ms and memory_mb override the app's.
Nothing is truncated: a value over a limit is an error, never a cut. The message is the size and the limit in bytes, with the place in front when the call has one:
| Over the limit | Error message |
|---|---|
| Response body (64 MiB) | response body of 70000000 bytes exceeds the limit of 67108864 bytes (HTTP 413) |
| A SQL parameter | SQL param 2: single value of N bytes exceeds the limit of 16777216 bytes |
| A fetch request body | fetch request single value of N bytes exceeds the limit of 16777216 bytes |
| A fetch response body read in full | fetch response single value of N bytes exceeds the limit of 16777216 bytes |
| A kv value | kv.put: single value of N bytes exceeds the limit of 16777216 bytes |
State between calls
Your app runs in several isolates at once, and each keeps its global context
between calls: globalThis, module-level variables, caches and compiled code
persist inside one isolate and differ between isolates. An isolate is replaced
between calls (never during one), for example after a limit ended a call or
when its heap stays high. Treat module state as a cache that may be empty on
any call; persist data in db, kv or records.
Differences from Workers
fetchbuffers a response whose body is known and at most 1 MiB. A larger or chunked body is streamed:fetchresolves at the headers,response.bodyis aReadableStream(Uint8Array chunks) that holds at most 1 MiB undelivered and pauses the transfer past it, andtext(),json()andarrayBuffer()read the rest (16 MiB, over it they throw an error naming the limit; stream to go beyond). A body is read once.Responsehasstatus,ok,url,headers,body,text(),json(),arrayBuffer()andbytes()(exact for binary bodies, buffered or streamed); noblob()on a fetched response, noclone(). A body you never read is cancelled when the call ends (its transfer is dropped).- Returning
fetch(url), ornew Response(upstream.body, init)with the body untouched, sends the upstream bytes to the client as they arrive, with no JS reading them: memory stays near the 1 MiB mark whatever the size. A client that disconnects cancels the upstream transfer; an upstream that fails mid-body closes the client connection (one log line). - Returning a
Responsebuilt on aReadableStream(one you made, or an upstream body run through aTransformStream) from an HTTP route streams it to the client chunk by chunk: the call stays running (wall and CPU limits apply), the stream is pulled only as fast as the client reads (1 MiB undelivered), a client that leaves cancels the reader, and an error in the stream closes the connection. A body you read yourself and return as text or bytes is a buffered response (64 MiB limit); a streamed one has no size limit. A stream that moves no chunk for 30 s ends; the time to a fetch's headers is still 30 s. Callers that are not HTTP routes (cron, triggers, MCP) get the whole body, up to 64 MiB, then an error naming the limit. ReadableStream,WritableStreamandTransformStreamare full web streams with backpressure,tee,pipeTo,pipeThroughandfor await; no BYOB readers.- A request body with a Content-Length up to 1 MiB is buffered:
request.bodyis its text (or null), as before. A larger upload, or a chunked one (no Content-Length), is streamed: the call starts at once,request.bodyis aReadableStream(Uint8Array chunks, read once) that holds at most 1 MiB undelivered, and the server stops reading the socket while it is full, so the client is paced by the function. A streamed upload has no 16 MiB total limit (the wall limit and a 30 s idle rule bound it); an upload that stalls for 30 s, or a client that disconnects, fails the reader with an error.request.text(),json(),arrayBuffer()andbytes()work on both forms and read at most 16 MiB (over it they throw an error naming the limit; readrequest.bodyas a stream to go beyond). A function that returns without reading the upload gets its reply sent; the rest of a small upload (up to 1 MiB) is drained so the connection is reused, a larger one closes the connection after the reply (Connection: close). Type the body asReadableStream | string | null. fetchinit takesmethod,headers, a stringbodyand asignalonly. An abort cancels the transfer and rejects with the signal's reason (AbortError, orTimeoutErrorforAbortSignal.timeout).cryptohasgetRandomValues(typedArray)(at most 65536 bytes, as the spec says),randomUUID()and the full Web Cryptosubtle, on OpenSSL 3:digest(SHA-1, SHA-256, SHA-384, SHA-512);signandverifywith HMAC, RSASSA-PKCS1-v1_5, RSA-PSS, ECDSA (P-256, P-384, P-521, signaturer||s) and Ed25519;encryptanddecryptwith AES-GCM, AES-CBC, AES-CTR and RSA-OAEP;generateKey;importKeyandexportKeyinraw,pkcs8,spkiandjwk;deriveBitsandderiveKeywith PBKDF2, HKDF, ECDH and X25519;wrapKeyandunwrapKeywith AES-GCM, AES-CBC, AES-CTR, AES-KW and RSA-OAEP. The calls answer already settled promises and reject with an Error named as Web Crypto names it (TypeError,NotSupportedError,InvalidAccessError,DataError,OperationError,SyntaxError). The work is synchronous native CPU inside the call and counts against its CPU budget: generating a 2048 bit RSA key takes tens to hundreds of milliseconds, the other operations microseconds to a millisecond. ACryptoKeyholds its key in native memory (secret bytes are cleansed when it is collected);algorithm,type,extractableandusagesare read-only properties. AES-CTR wraps only the lowlengthbits of its counter, as the spec defines.TextDecoderis UTF-8 only (another label throwsUnsupported encoding). Invalid UTF-8 becomes U+FFFD, indecode, inrequest.text()and in a fetchedresponse.text(); NUL bytes and the bytes of a subarray are kept.new TextDecoder("utf-8", { fatal: true })throws an Error (The encoded data was not valid for encoding utf-8) instead.structuredCloneis the standard algorithm (cycles, Map, Set, Date, RegExp, typed arrays, Errors; a function or Symbol throwsDataCloneError).queueMicrotaskruns on the engine's microtask queue, as in a browser.caches.defaultis best effort: per isolate (not shared between worker threads, gone when the isolate is retired), at most 16 MiB with least-recently-used eviction. An entry expires at itsCache-Controls-maxageormax-age, else itsExpires;no-storeandprivateare not stored.deleteis supported.- CommonJS
moduleandexportsbelong to each function's own wrapper; there is no globalmodule. setImmediateanswers an id andclearImmediatecancels it; the callback runs as a microtask.ctx.waitUntilpromises are awaited before the response is sent.env.<KV>andenv.<D1>are shims overdb.kvand SQL;D1.batchis not a transaction.dbis synchronous and in process; there is no network hop to the database.
Globals
Everything a function can call is typed in GET /runtime.d.ts. Beyond the pages
above:
URL(url, base?):href,protocol,host,hostname,port,pathname,search,hash,origin,searchParams,toString().URLSearchParams(init?):get,getAll,has,set,append,delete,forEach,entries,keys,values,toString.atob(data),btoa(data): base64 of a string.Blob(parts?, { type? }):text(),arrayBuffer(),slice(start?, end?, type?).FormData:append,get,has,deleteonly.Event(type)withpreventDefault()andstopPropagation();EventTargetwithaddEventListener,removeEventListener,dispatchEvent(no listener options).AbortControllerwithsignalandabort(reason?);AbortSignalwithaborted,reason,onabort,addEventListener/removeEventListener("abort"),throwIfAborted()and the staticsabort(reason?),timeout(ms)andany(signals).DOMException(message?, name?): an Error with a standard name (AbortError,TimeoutError,DataCloneError) andcode.performance.now(): milliseconds with sub-microsecond precision; the only member.Buffer: aUint8ArraywithBuffer.from(input, encoding?),alloc(size),concat(list, totalLength?),isBuffer(value)andtoString(encoding?); encodings are"hex","base64", else UTF-8; noreadUInt*,equalsorcopy.process.env(the same asenv),process.stdout.write(message)andprocess.stderr.write(message).setTimeout(fn, ms?, ...args)andsetInterval(fn, ms?, ...args)answer a numeric id forclearTimeout(id)andclearInterval(id); the call's timers run on its budget and are cleared when the call ends.setImmediate/clearImmediateandqueueMicrotaskare above.
Builtin modules
A function may import or require only these modules; every other specifier fails, naming it (an import
when the function loads, require with code: "MODULE_NOT_FOUND"). Which modules an ES module imports is
decided by parsing it (the loader's own module analysis), never by searching its text. A module is loaded
once per isolate context, on first use, and import and require give the same exports. Dynamic import()
is not available.
Per-function env in an ES module: import it. import { env } from "substrio:env" is the function's
own env in every continuation. A module that reads a bare global env without that import reads the global,
which is the env of whichever function the isolate ran last: after an await or a direct call that can be
another function's. A CommonJS function and a script that is one function expression get their own env
as a parameter and need no import.
Specifier (the node: form and the bare Node name both work) |
What it is |
|---|---|
substrio:env |
env: in an ES module, the importing function's own env (each function that imports it gets its own instance), so it stays that function's env after an await and during direct calls; through require, the running call's env. An env is a plain object of read-only values (a write or a delete throws in strict code and changes nothing otherwise; nothing can be added), updated in place when the function's secrets, vars or bindings change, so env.X is one property read. env.ASSETS is the function's static assets (not enumerated) |
node:buffer |
Buffer, Blob, atob, btoa, INSPECT_MAX_BYTES (the runtime's own Buffer) |
node:crypto |
createHash, createHmac, hash, randomBytes, randomFillSync, randomInt, randomUUID, getRandomValues, timingSafeEqual, createSign, createVerify, sign, verify (PEM keys: PKCS#8, SPKI, PKCS#1, SEC1; padding: constants.RSA_PKCS1_PSS_PADDING; dsaEncoding: "ieee-p1363"), pbkdf2, pbkdf2Sync, getHashes, constants, webcrypto, subtle |
node:events |
EventEmitter (and once, getEventListeners, setMaxListeners, errorMonitor) |
node:util |
format, inspect (the common cases), promisify, callbackify, inherits, deprecate, isDeepStrictEqual, types, TextEncoder, TextDecoder |
node:url |
URL, URLSearchParams, legacy parse, format, resolve, fileURLToPath, pathToFileURL, urlToHttpOptions, domainToASCII |
node:path, node:path/posix |
the POSIX functions; resolve has no working directory and uses / |
node:querystring |
parse, stringify, escape, unescape |
node:string_decoder |
StringDecoder for utf8, utf16le, latin1, ascii, hex, base64 and base64url |
node:assert, node:assert/strict |
assert, ok, equal, strictEqual, deepEqual, deepStrictEqual, their not forms, throws, doesNotThrow, rejects, doesNotReject, match, ifError, fail, AssertionError |
node:crypto hashes are SHA-1, SHA-256, SHA-384 and SHA-512 (also spelled RSA-SHA256, sha-256, ...); another
digest such as MD5 throws Digest method not supported. Its signing, hashing and HMAC run on the same native core as
crypto.subtle. createSign and createVerify take PEM keys, not KeyObjects (there is no createPrivateKey or
generateKeyPair; use crypto.subtle for those). PSS signs with the maximum salt and verifies any salt.
require is a read-only, non-deletable global function that your code cannot replace; a CommonJS function and an
ES module see the same one. require.cache is an array of the loaded modules' exports, indexed by module.