init and handle
hooks.server.ts has exactly two lifecycle hooks, and they run at completely different times:
| Hook | Runs | Use it for |
|---|---|---|
init | Once, at server start, before any request | Connection pools, migrations, config checks |
handle | Every request | Auth, headers, logging, redirects |
Two names, and between them they cover almost everything a hook file does. The confusion is not that the file is overloaded — it's that init is so rarely used that it looks like it isn't there.
init: once per process
import { Pool } from "pg";
import { env } from "$env/dynamic/private";
export const pool = new Pool({ connectionString: env.DATABASE_URL });
export const init = async () => {
await pool.query("SELECT 1");
// fail the boot if the environment is wrong, rather than the first request
if (!env.SESSION_SECRET) throw new Error("SESSION_SECRET is not set");
};init runs once when the server boots, before it accepts a connection. That makes it the answer to the question every framework eventually asks: "where do I open the database connection?"
The key is that init does not create things. A new Pool() at module scope is what actually opens the connection, and init is where you verify it. That split is intentional:
- Creating at module scope means the object exists for
loadand actions to import, and it is created exactly once per process. - Checking in
initmeans a missing variable stops the process at boot, with the name in the error, instead of surfacing as a confusing 500 on someone's first request.
Without init, a missing secret fails the way any unvalidated secret does: late, and in a place nobody was looking. Recall that $env/* reads a missing variable as "", not undefined — see environment modules.
Multiple workers means multiple inits
init runs per worker process, not once for the whole server. With sigil start --scale api=3, you get three pools, three init runs, and three separate migration attempts.
Two consequences:
- Pool sizing must account for the worker count, or you will exhaust your database's connection limit.
max: 10across three workers is 30 connections, not 10. - Run migrations with an advisory lock, or run them outside
initentirely — a CI step is the honest place for a migration.
import { sql } from "pg";
export const init = async () => {
// one worker migrates; the others wait, then find nothing to do
await pool.query("SELECT pg_advisory_lock(727_1)");
try {
await pool.query("CREATE TABLE IF NOT EXISTS sessions (...)");
} finally {
await pool.query("SELECT pg_advisory_unlock(727_1)");
}
};handle: every request
handle is the per-request path. It runs for every request — pages, API routes, WebSocket upgrades, all of them — and it is where anything about an individual request belongs.
import type { Handle } from "@sigil-dev/grimoire/hooks";
export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = await getUser(event.cookies.get("session"));
const res = await resolve(event);
res.headers.set("x-served-by", "grimoire");
return res;
};The full event surface is in the hooks reference.
The mistake worth naming
Doing work at module scope and hoping a hook picks it up:
// ✗ runs at import, in every context, with no error handling
const session = await getSession(); // top-level await, runs on importThis executes when the module is imported, which may be during a build, during a worker spawn, or in a context with no database available. It fails in a way that points at the wrong place, and it runs again in each worker whether you wanted it to or not.
The rule that avoids the whole category:
- Create at module scope.
const pool = new Pool(...)— cheap, lazy enough, imported by whoever needs it. - Verify and warm at
init. One await, once, at boot. - Per-request reads in
handleorload. Never at module scope.
Choosing
| You want to | Put it in |
|---|---|
| Open a connection pool | module scope, checked in init |
| Run migrations | init with an advisory lock, or CI |
| Fail fast on bad env | init |
| Look up the current user | handle, into locals |
| Redirect unauthenticated users | handle |
| Add or inspect response headers | handle, after resolve |
| Report a 500 to Sentry | handleError |
| Close a pool on shutdown | onStop in a plugin |
Next
- hooks.server.ts reference — the event, cookies,
sequence() - Sessions and auth —
initandhandleworking together