Guides: init and handle

Guides

init and handle

hooks.server.ts has exactly two lifecycle hooks, and they run at completely different times:

HookRunsUse it for
initOnce, at server start, before any requestConnection pools, migrations, config checks
handleEvery requestAuth, 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

hooks.server.ts
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 load and actions to import, and it is created exactly once per process.
  • Checking in init means 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: 10 across three workers is 30 connections, not 10.
  • Run migrations with an advisory lock, or run them outside init entirely — 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.

hooks.server.ts
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:

hooks.server.ts
// ✗ runs at import, in every context, with no error handling
const session = await getSession();  // top-level await, runs on import

This 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 handle or load. Never at module scope.

Choosing

You want toPut it in
Open a connection poolmodule scope, checked in init
Run migrationsinit with an advisory lock, or CI
Fail fast on bad envinit
Look up the current userhandle, into locals
Redirect unauthenticated usershandle
Add or inspect response headershandle, after resolve
Report a 500 to SentryhandleError
Close a pool on shutdownonStop in a plugin

Next

Edit this page