Functions and “use server”
Server code is JavaScript or TypeScript that runs in a sandbox as the calling user, so every rule still applies.
“use server”
Mark a function in a page with "use server" and call it from the browser like any other function. In the code sent to the browser, its body is replaced with a call to /api/rpc.
export async function addNote(title: string, at: Date) {
"use server";
// who may call it (default: anyone)
"allow: @request.auth.staff = true";
const made = await server.collection("notes").create({ title });
return { made, next: new Date(at.getTime() + 86_400_000), tags: new Set(["new"]) };
}
// in the browser
// Date and Set arrive as real Date and Set
const { next, tags } = await addNote("Milk", new Date());- Arguments and return values travel as devalue, so Dates, Maps, Sets, BigInts,
undefinedand repeated objects arrive intact. - Without
"allow: …", anyone can call it. It runs as the caller, so collection rules still apply. - Called from a server component during rendering, it runs directly, with no HTTP request.
- The body is extracted and run on its own, so top-level imports of the page aren’t available inside it.
Endpoints
Each file in functions/ becomes an endpoint at /api/fn/<name>, versioned and rolled back with the app.
// the app's own modules
import { average } from "../lib/marks.ts";
import terms from "../data/terms.json" with { type: "json" };
export const rule = "@request.auth.id != null";
export const method = "GET";
export default async function (ctx) {
const page = await ctx.collection("grades").list({ filter: `student = "${ctx.auth.id}"` });
return { average: average(page.items.map((g) => g.value)), term: terms.autumn };
}export const runAs = "system" bypasses the caller’s rules, e.g. for aggregates everyone may see even though individual rows are private. The function then is the policy, so return aggregates, never rows.
Hooks and jobs
A file in hooks/ runs before every write to its collection, whatever the source of the write. Its reads bypass rules since it’s policy, not user code, so it can enforce things rules can’t, like counts or cross-row checks.
export async function beforeCreate({ record, db, reject }) {
const held = await db.count("classes", `responsible = "${record.responsible}"`);
if (held >= 2) reject(`already responsible for ${held} classes`);
// the record to write
return { ...record, name: record.name.trim() };
}export const schedule = "0 2 * * *";
export default async function (ctx) { /* … */ }The sandbox
Each call gets a fresh QuickJS context, with 64 MB of memory and 5 seconds of computation. It has ctx, and the web’s standard globals as Deno and Bun have them: fetch, URL, TextEncoder, crypto, timers, AbortController and structuredClone. fetch reaches public addresses only, never this machine or your private network. There’s no file system and no process. Imports of the app’s own files are bundled in ahead of time.
The event loop
Server code runs on an event loop, as it does in Node. fetch starts a request and returns straight away, so requests made together run side by side:
export default async function () {
const [eur, usd] = await Promise.all([
fetch("https://example.com/rates/eur").then((r) => r.json()),
fetch("https://example.com/rates/usd").then((r) => r.json()),
]);
return { eur, usd };
}A response’s body is a stream, read as it arrives. That suits large downloads, server-sent events, and long polls:
export default async function () {
const response = await fetch("https://example.com/events");
let lines = 0;
for await (const chunk of response.body) {
lines += new TextDecoder().decode(chunk).split("\n").length - 1;
if (lines > 100) break;
}
return { lines };
}The 5 seconds count only the time spent running JavaScript. Time spent waiting, on a request or a timer, doesn’t count, so a function may wait on a slow service for minutes. A loop that never ends is still stopped. A function that awaits a promise nothing can settle fails at once, rather than hanging.
While a function waits it holds no thread, so thousands can wait at once. JavaScript runs on its own pool of threads, one per core, apart from the threads that answer requests. A function that computes for a while never slows the rest of the app. AbortController cancels a request. When the client that called a function goes away, the function is stopped.
Hooks and views are meant to be quick. Their fetch waits in place and gives up after 5 seconds.
QuickJS: small, safe, fast enough
Server code runs in QuickJS, a small JavaScript engine built into the binary. It isn’t V8 (Node, Deno, Chrome), JavaScriptCore (Bun, Safari) or SpiderMonkey (Firefox). Those engines compile hot code to machine code as it runs (a JIT); QuickJS interprets it. What that means for you:
- Heavy computation is slower. Tight loops and long calculations are about 2 to 18 times slower than in V8. Keep them out of server code, or let the database do them.
- Each call starts fresh and small. A new sandbox takes about 3 ms, so one call can’t leave state for the next or reach another’s. That’s safer than one long-lived process running everyone’s code.
- Modern JavaScript, not every API. ES2023 syntax works (async and await, classes, modules, optional chaining,
BigInt), but not WebAssembly, threads (Worker,SharedArrayBuffer) or Node’s modules.
For what server functions are for, it’s plenty. They check input, read and write a few records through ctx (and the database work runs in SQLite, in native code), shape the answer, and perhaps call another service. That takes a few milliseconds.
Measured on one Windows laptop, the fastest of three runs, in milliseconds:
| Node 24 | Deno 2.9 | Bun 1.4 | Sluurp (QuickJS) | |
|---|---|---|---|---|
A recursive function, fib(25) | 0.6 | 0.6 | 0.7 | 11 |
| 5,000 records to JSON and back, five times | 9.5 | 7.5 | 7.2 | 52 |
| Sort 100,000 numbers | 28.7 | 28.8 | 15.3 | 50 |
| Build, join, replace and split 50,000 strings | 5.0 | 5.1 | 5.7 | 42 |
| Filter, group and average 2,000 records, 50 times | 1.4 | 1.5 | 1.2 | 14 |
A whole call to a function that returns { hello }, over HTTP | – | – | – | 4.9 |
For a comparison of engines on standard benchmarks, see QuickJS’s own benchmarks. Code that needs a JIT, a thread or a native module belongs in a service of its own. It can use your data through the REST API and send Sluurp events.