Function runtime

Write JavaScript functions with db, substrio, env and fetch in scope, under call limits that carry between calls.

Docs menu

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.

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

  • fetch buffers a response whose body is known and at most 1 MiB. A larger or chunked body is streamed: fetch resolves at the headers, response.body is a ReadableStream (Uint8Array chunks) that holds at most 1 MiB undelivered and pauses the transfer past it, and text(), json() and arrayBuffer() read the rest (16 MiB, over it they throw an error naming the limit; stream to go beyond). A body is read once. Response has status, ok, url, headers, body, text(), json(), arrayBuffer() and bytes() (exact for binary bodies, buffered or streamed); no blob() on a fetched response, no clone(). A body you never read is cancelled when the call ends (its transfer is dropped).
  • Returning fetch(url), or new 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 Response built on a ReadableStream (one you made, or an upstream body run through a TransformStream) 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, WritableStream and TransformStream are full web streams with backpressure, tee, pipeTo, pipeThrough and for await; no BYOB readers.
  • A request body with a Content-Length up to 1 MiB is buffered: request.body is its text (or null), as before. A larger upload, or a chunked one (no Content-Length), is streamed: the call starts at once, request.body is a ReadableStream (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() and bytes() work on both forms and read at most 16 MiB (over it they throw an error naming the limit; read request.body as 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 as ReadableStream | string | null.
  • fetch init takes method, headers, a string body and a signal only. An abort cancels the transfer and rejects with the signal's reason (AbortError, or TimeoutError for AbortSignal.timeout).
  • crypto has getRandomValues(typedArray) (at most 65536 bytes, as the spec says), randomUUID() and the full Web Crypto subtle, on OpenSSL 3: digest (SHA-1, SHA-256, SHA-384, SHA-512); sign and verify with HMAC, RSASSA-PKCS1-v1_5, RSA-PSS, ECDSA (P-256, P-384, P-521, signature r||s) and Ed25519; encrypt and decrypt with AES-GCM, AES-CBC, AES-CTR and RSA-OAEP; generateKey; importKey and exportKey in raw, pkcs8, spki and jwk; deriveBits and deriveKey with PBKDF2, HKDF, ECDH and X25519; wrapKey and unwrapKey with 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. A CryptoKey holds its key in native memory (secret bytes are cleansed when it is collected); algorithm, type, extractable and usages are read-only properties. AES-CTR wraps only the low length bits of its counter, as the spec defines.
  • TextDecoder is UTF-8 only (another label throws Unsupported encoding). Invalid UTF-8 becomes U+FFFD, in decode, in request.text() and in a fetched response.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. structuredClone is the standard algorithm (cycles, Map, Set, Date, RegExp, typed arrays, Errors; a function or Symbol throws DataCloneError).
  • queueMicrotask runs on the engine's microtask queue, as in a browser.
  • caches.default is 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 its Cache-Control s-maxage or max-age, else its Expires; no-store and private are not stored. delete is supported.
  • CommonJS module and exports belong to each function's own wrapper; there is no global module.
  • setImmediate answers an id and clearImmediate cancels it; the callback runs as a microtask.
  • ctx.waitUntil promises are awaited before the response is sent.
  • env.<KV> and env.<D1> are shims over db.kv and SQL; D1.batch is not a transaction.
  • db is 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, delete only.
  • Event(type) with preventDefault() and stopPropagation(); EventTarget with addEventListener, removeEventListener, dispatchEvent (no listener options).
  • AbortController with signal and abort(reason?); AbortSignal with aborted, reason, onabort, addEventListener/removeEventListener("abort"), throwIfAborted() and the statics abort(reason?), timeout(ms) and any(signals).
  • DOMException(message?, name?): an Error with a standard name (AbortError, TimeoutError, DataCloneError) and code.
  • performance.now(): milliseconds with sub-microsecond precision; the only member.
  • Buffer: a Uint8Array with Buffer.from(input, encoding?), alloc(size), concat(list, totalLength?), isBuffer(value) and toString(encoding?); encodings are "hex", "base64", else UTF-8; no readUInt*, equals or copy.
  • process.env (the same as env), process.stdout.write(message) and process.stderr.write(message).
  • setTimeout(fn, ms?, ...args) and setInterval(fn, ms?, ...args) answer a numeric id for clearTimeout(id) and clearInterval(id); the call's timers run on its budget and are cleared when the call ends. setImmediate/clearImmediate and queueMicrotask are 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.