Skip to main content
zerotal

Request Lifecycle

A Zerotal app has two lifecycles — one that runs once at startup and one that runs for every request. Keeping them straight is the key to putting logic in the right place.

  • The boot lifecycle runs once, when the server starts. It wires everything together — binds services, loads routes, scans your app/* directories — and then opens the socket. One-time setup belongs here, in a provider hook.
  • The request lifecycle runs for every incoming request. It opens a per-request scope, walks the middleware pipeline to your controller, builds a response, and tears the scope back down. Per-request work belongs here, in middleware, a controller, or an after-response hook.

If you only remember one thing: boot is for the app, request is for the visitor.

Mental model

Two clocks tick at different rates. The boot clock ticks once — everything it sets up (container bindings, routes, the compiled route table) is shared by every request that follows. The request clock ticks on each visit and gets its own isolated scope, so concurrent requests never share state.

boot clock  ──tick── (server starts: bind, load routes, open socket)
                          │
request clock ───────────┼── tick (GET /a)  → scope A → response → flush A
                          ├── tick (GET /b)  → scope B → response → flush B
                          └── tick (POST /c) → scope C → response → flush C

Boot lifecycle

Booting starts from the two files you own — bootstrap/app.ts (where you configure the app, declaratively) and the managed zt.ts entry point (which imports it and calls start()). From there Application.boot() runs a fixed sequence, once, and is idempotent (a second call does nothing):

# boot sequence (overview)
bootstrap/app.ts            ← you configure the app (declarative)
  Application.create({ providers })
    .bind((container) => …)        ← optional: register container bindings
    .routing({ … })                ← declare explicit route files
    .fileBasedRouting({ … })       ← declare route directories
    .use([ …middleware ])          ← register global middleware
  └─ exports `app`
        │
        ▼
zt.ts (managed)         ← imports bootstrap/app.ts, then: app.start(port)
        │
        ▼
app.boot()   ── runs once, idempotent ───────────────────────────────────
   1. bind core singletons        `config`, `events`
   2. load config                 scan `config/*.ts` (unless preloaded)
   3. discover providers          scan `app/providers/*`
   4. run app.bind() callbacks    your bootstrap bindings
   5. onRegister()   each, sync          providers bind their services
   6. onBooting()    each, in order      providers prepare; may use earlier ones
   7. onBooted()     all, in parallel    every binding is now resolvable
   8. discover middleware         scan `app/middleware/*`
   9. load routes                 routing() + fileBasedRouting() files run
  10. convention phase            scan app/{models, observers, policies, listeners,
                                  events, jobs, services, validators}; serve public/
        │
        ▼
app.start()  ── continues ────────────────────────────────────────────────
  11. onStarting()   each               last chance before the socket opens
  12. Bun.serve()                       server binds and starts listening
  13. onStarted()    each               health endpoint, PID file, signal handlers

The three provider phases (steps 5–7) are the part you'll touch most. They're ordered for a reason: onRegister() only binds (nothing is resolved yet), onBooting() runs sequentially so a later provider can depend on an earlier one, and onBooted() runs in parallel once every binding exists — so it's the safe place to resolve services that depend on other providers. See Provider lifecycle hooks below.

TipBind in onRegister(), resolve in onBooted() is the rule that avoids 90% of ordering bugs.

The convention phase (step 10) scans the app/* directories and registers what it finds — models, observers, policies, listeners, jobs, services, validators. Providers declare which directories get scanned via this.app.registerConcern(...). See Conventions.

Request lifecycle

Now the per-request path. Every request gets its own isolated scope — created when it arrives, flushed when it leaves — so concurrent requests never share state. The route table was already compiled at boot, so matching is a fast lookup, not a re-scan:

# per-request flow
Bun.serve() receives Request
        │
        ▼
  compiled route table lookup
  → match path + method → RouteDefinition
        │
        ├── no match → 404 NotFoundError → ExceptionHandler.render()
        │
        ▼
  createRouteHandler(definition, container)
        │
        ▼
  ScopedResolver created       ← request-scoped DI scope
  HttpContext created           ← ctx.requestId, ctx.startedAt, ctx.url, …
  RequestContext.run(ctx, fn)   ← AsyncLocalStorage stores ctx for this async tree
        │
        ▼
  Model bindings resolved       ← the model's resolver / .bind() runs
  ctx._models populated
        │
        ▼
  Pipeline runs (middleware chain):
  [global middleware] → [group middleware] → [route middleware] → controller action
        │
        ├── any middleware can short-circuit by returning a Response (or setting ctx.response)
        │
        ▼
  Controller action executes
  → sets ctx.response (via ctx.json(), ctx.view(), ctx.redirect(), etc.)
        │
        ▼
  Pipeline unwinds (finally blocks in middleware run here — e.g. SessionMiddleware saves)
        │
        ▼
  ctx.response returned to Bun.serve()
        │
        ▼
  afterResponse callbacks fire  ← ctx.afterResponse(() => sendEmail())
        │
        ▼
  ScopedResolver.flush()        ← request-scoped bindings disposed

Middleware execution order

Middleware nests: the outermost layer runs first on the way in and last on the way out. The list below reads top-to-bottom as the order requests enter, then unwinds in reverse as responses leave:

# nesting order (outer → inner)
Global (app.use)
  └── Provider auto-registered (SessionMiddleware, AuthMiddleware, …)
        └── Switch middleware (withCors, withThrottle, withSecureHeaders)
              └── Group middleware (Router.group({ middleware: [...] }))
                    └── Route middleware (Router.get('/...', C, 'a', [M]))
                          └── Controller action
                    ┌── (unwind)
              ┌── (unwind)
        ┌── (unwind — session saved here)
  ┌── (unwind)

Middleware wraps the next step — await next() is where the inner layers run. Code before next runs on the way in; code after runs on the way out. next() resolves to the downstream Response.

// app/middleware/TimingMiddleware.ts
import type { HttpContext } from "zerotal";
import type { Pipe, NextFn } from "zerotal";

export class TimingMiddleware implements Pipe<HttpContext> {
  async handle(ctx: HttpContext, next: NextFn): Promise<Response | void> {
    const start = performance.now();
    const response = await next(); // ← inner layers run here
    console.log(`${ctx.path()} took ${Math.round(performance.now() - start)}ms`);
    return response;
  }
}

Exception handling

If any middleware or controller throws, the pipeline catches the error and calls the exception handler — first to report it, then to turn it into a Response:

# on an unhandled throw
ExceptionHandler.report(err, ctx)   ← log / Sentry / whatever
ExceptionHandler.render(err, ctx)   → Response

The response is sent to the client, and afterResponse callbacks still fire.

In production, raw 500 errors show a minimal "Internal Server Error" page. In dev, unhandled exceptions show a full stack-trace page. See Error Handling for custom handlers.

AsyncLocalStorage context

RequestContext stores the request's HttpContext in an AsyncLocalStorage so that any code running inside the async tree of a request — facades, services, ORM models — can reach the current context without prop-drilling:

// in a service, anywhere in the async tree
import { RequestContext } from "zerotal";

const ctx = RequestContext.tryGet(); // HttpContext | undefined
const user = ctx?.user;

Note — Use RequestContext.tryGet() (returns undefined outside a request) for code that runs in both request and non-request contexts — CLI commands, queue workers, scheduled jobs. Use RequestContext.get() when you want it to throw if there is no active request.

Facades like Auth and Config use this internally. You rarely need to access RequestContext directly.

Provider lifecycle hooks

A provider can hook into any step of the boot and shutdown sequence by overriding these methods. The three most-used ones map to boot steps 5–7 above; the rest bracket the server starting and stopping.

HookWhen it runsTypical use
onRegister()Boot step 5 — sync, nothing resolvedcontainer.singleton(), Router.macro()
onBooting()Boot step 6 — sequential, in orderPrepare a service that a later provider needs
onBooted()Boot step 7 — parallel, all boundResolve cross-provider deps, warm singletons
onStarting()Just before the socket opensFinal pre-flight checks
onStarted()After the server is listeningStart background timers / workers
onStopping()Graceful shutdown, reverse orderRelease resources, flush buffers
onStopped()After shutdown completes, reverse orderFinal cleanup

Providers can also observe every request without registering middleware — onRequestReceived() (before the pipeline runs), onRequestProcessed() (after the pipeline sets ctx.response), and onResponseSent() (after the response is sent). The full phase reference lives in The Application and Service Providers.

// app/providers/PaymentProvider.ts
import { ServiceProvider } from "zerotal";

export class PaymentProvider extends ServiceProvider {
  onRegister(): void {
    this.app.container.singleton(
      PaymentGateway,
      () => new StripeGateway({ key: Bun.env.STRIPE_KEY! }),
    );
  }

  async onBooted(): Promise<void> {
    const gw = await this.app.container.make(PaymentGateway);
    gw.setWebhookSecret(Bun.env.STRIPE_WEBHOOK_SECRET!);
  }
}

Warning — Do not resolve services in onRegister(). Nothing is resolvable yet at that phase — only bind there, and resolve in onBooted() once every provider has registered its bindings.

Which hook should I use?

  • Registering a binding (singleton, macro, alias)onRegister(). It runs first and only binds; nothing is resolved.
  • Preparing a service a later provider depends ononBooting(). It runs sequentially in registration order, so earlier providers are already booting.
  • Resolving a service that spans providers, or warming a singletononBooted(). Every binding exists by now and these run in parallel.
  • Opening a socket / starting timers after the server is uponStarted().
  • Releasing resources on shutdownonStopping() / onStopped() (reverse order).
  • Observing each request without owning a middleware slotonRequestReceived() / onRequestProcessed() / onResponseSent().

Reference

Boot and shutdown phases in order, and the per-request hook surface.

Phase / hookRunsConcurrency
onRegister()Boot — bind servicesSync, sequential
onBooting()Boot — prepare servicesAsync, sequential
onBooted()Boot — every binding readyAsync, parallel
onStarting()Before Bun.serve() bindsAsync, parallel
onStarted()After server is listeningAsync, parallel
onStopping()Graceful shutdownAsync, reverse
onStopped()After shutdown completesAsync/sync, reverse
onRequestReceived()Per request, before pipelineAsync, parallel
onRequestProcessed()Per request, after pipelineAsync, parallel
onResponseSent()Per request, after responseAsync, parallel

What booting cost

app.bootDurationMs is the total. app.providerReport is the breakdown — one ProviderReport per provider, in boot order:

for (const { name, durationMs, bindings } of app.providerReport) {
  console.log(`${name} ${durationMs}ms, bound ${bindings.length}`);
}
FieldIs
nameProvider class name. The array order is the boot order.
durationMsWall-clock across all three boot hooks.
bindingsContainer tokens this provider bound, as names.

Boot order is worth reading on its own: it decides who wins a contested binding. And because onBooted() runs in parallel, these durations overlap — they do not sum to bootDurationMs, and the report says so rather than serialising the boot to produce a tidier number.

DevTools draws this as its Providers and Container tabs.

Next steps