Every framework has a lifecycle, but most leave you to reverse-engineer it from console logs and stack traces. KickJS is unusually explicit about its phases — there are three of them, the firing order inside each is documented, and the framework will throw at boot if you wire up something that violates the contract. That clarity is a feature: it means you can pick the right hook the first time instead of discovering at 2am that your DB pool was constructed before the config adapter had loaded its env vars. This article walks the three phases, the ordering rules inside each, and the contracts you can rely on at each step.
The three phases
A KickJS process has exactly three phases, and they happen in this order:
-
Setup — everything that runs once during
bootstrap(). Config loads, adapters mount, modules register, routes are wired,listen()fires. By the end of this phase, the app is accepting connections. - Runtime — the per-request phase. For every inbound HTTP request, a fresh AsyncLocalStorage frame is opened, middleware and contributors run, the handler resolves, and the frame closes. This phase repeats indefinitely.
-
Shutdown — triggered by
SIGTERM,SIGINT, or an explicitapp.shutdown()call. Every adapter'sshutdown()runs concurrently, the HTTP server closes, and the process exits.
Setup runs once. Runtime runs N times. Shutdown runs once. Knowing which phase you're in tells you which APIs are safe to call.
The setup phase, step by step
The setup phase has a deterministic firing order. Here it is end to end:
bootstrap()
│
├─ 1. Adapter beforeMount hooks (config / env adapters resolve here)
├─ 2. Hardened defaults + ALS frame (request-scope is now installable)
├─ 3. Adapter middleware mount (beforeGlobal → afterGlobal
│ → beforeRoutes → afterRoutes)
├─ 4. Plugin + user middleware (in registration order)
├─ 5. Security defaults (helmet, CORS, body limits)
├─ 6. Module registration (DI registry populated)
├─ 7. Route mounting (per-controller onRouteMount fires)
├─ 8. Adapter beforeStart hooks (every route is known, listen() is not)
├─ 9. server.listen() (socket bound, accepting connections)
└─ 10. Adapter afterStart hooks (announce, register with discovery, etc.)
A few things are worth pinning down at each step.
By the time step 1 finishes, anything an adapter wanted to read from the environment or from a config file is loaded. This is where a config adapter's beforeMount parses .env and exposes typed values. Until this step completes, do not assume any config is present.
By step 2, the AsyncLocalStorage substrate is installed but no request frame exists yet — there's nothing to scope to. Code that calls getRequestContext() here will (correctly) get undefined.
Steps 3–5 layer middleware. The four-phase model — beforeGlobal, afterGlobal, beforeRoutes, afterRoutes — gives adapters a predictable place to inject cross-cutting concerns: a tracing adapter wraps everything in beforeGlobal; an auth adapter slots in at beforeRoutes; a response-shaping adapter cleans up at afterRoutes.
By step 6, the DI registry is fully populated. Every @Service is constructible. Every registerInstance(...) call has run. This is the earliest point where you can reliably resolve dependencies by token.
Step 7 is where each controller's onRouteMount notification fires — useful for adapters that want to introspect or decorate the route table.
Step 8 — beforeStart — is the most useful and most often misused hook. Every module has registered, every route is mounted, but listen() has not yet fired. This is the right slot for "construct + register in DI" code that needs the route table populated: schema generators, OpenAPI emitters, route-conditional health checks. It is the wrong slot to start accepting external traffic — the socket isn't open yet.
By step 10 — afterStart — the server is live. This is where you announce yourself to a service registry, log the bound port, or warm a cache that depends on the app being reachable.
Within-phase ordering
Two ordering rules apply inside setup, and they answer most "why did X run before Y?" questions.
Adapters run in the order they appear in the adapters array. If you put loggingAdapter before authAdapter in your bootstrap config, logging's beforeMount runs first, its beforeGlobal middleware runs first, its beforeStart runs first, and so on. The array is the source of truth — there's no hidden priority.
Contributors are topologically sorted. A contributor is anything that adds behavior to the request pipeline — a middleware, an interceptor, a guard. KickJS sorts them at boot using a fixed precedence — method > class > module > adapter > global — so a method-scoped guard always runs inside a class-scoped one, and both run inside any global middleware. On top of that, contributors can declare an explicit dependsOn list. The framework validates the resulting graph at boot: cycles throw, missing dependencies throw, ambiguous orderings throw. You find out before listen() fires, not under load.
// Synthetic — declares "rate-limit must run after auth"
defineContributor({
name: 'rate-limit',
dependsOn: ['auth'],
scope: 'global',
run: async (ctx, next) => { /* ... */ await next(); },
});
The combination — array order for adapters, topo-sort for contributors — gives you predictable composition without surprise re-ordering.
Runtime: the per-request frame
Once the server is listening, every request goes through the same shape.
A request lands. KickJS opens a new AsyncLocalStorage frame for it — a per-request "bag" that follows every await boundary inside the handler. The middleware chain runs. The contributor pipeline runs in topo-sorted order. The matched route handler runs. The response is written. The frame closes.
There are three layers that share this bag:
-
@Middlewaredecorators — your own middleware, mounted in step 4 of setup. -
The contributor wrapper — adapter and module contributors, sorted by precedence and
dependsOn. - The main handler — the controller method itself.
All three see the same ctx. Anything one layer puts there with ctx.set('userId', id) is visible to the next layer with ctx.get('userId'). The bag is strictly per-request: no other concurrent request can read it, even on the same Node thread, because AsyncLocalStorage isolates it for you. That is what makes "current user", "current tenant", "current trace span" safe to store without threading them through every function signature.
// Synthetic — three layers, one bag
ctx.set('tenantId', resolveTenant(req)); // middleware
const span = ctx.get('span'); // contributor reads it
return service.list(ctx.get('tenantId')); // handler reads it
Shutdown: parallel and best-effort
When SIGTERM or SIGINT arrives — or your code calls app.shutdown() — KickJS runs every adapter's shutdown() method concurrently, wrapped in Promise.allSettled. Two properties fall out of that choice:
- Concurrent. Adapters do not block each other. A slow database adapter draining its connection pool does not delay a fast logging adapter from flushing its buffer.
-
Failure-isolated. If one adapter's
shutdown()rejects, the others still complete.allSettlednever short-circuits. You will see the rejection in the framework's shutdown report, but it cannot starve siblings.
This matters for resource ownership. Each adapter is responsible for the resources it opened: the DB adapter closes its pool, the queue adapter drains its consumers, the cache adapter flushes pending writes. Nobody else cleans up for you, and nobody else's failure prevents you from cleaning up. The contract is "every adapter gets a chance."
After all shutdown() promises settle, the HTTP server closes its listener and the process exits. If you need a hard deadline, wrap your individual shutdown() body in its own timeout — the framework intentionally does not impose one.
HMR: the dev-mode caveat
In development, kick dev runs the app under Hot Module Replacement. When you save a file, the running app is torn down and re-bootstrapped — the full shutdown phase fires, then the full setup phase runs again, in the same Node process.
This is where adapters that own connections, sockets, file watchers, or background timers earn their keep. If your adapter's shutdown() doesn't actually drain — if it leaves a Postgres pool open, a Redis subscription connected, an interval ticking — you will leak those handles every time you save a file. Ten saves later your dev box has fifty zombie Postgres connections and you're staring at "too many clients" errors that don't exist in production.
The rule: write shutdown() as if it runs every five seconds, because in dev mode it effectively does. See the framework's separate HMR guide for the full reload protocol and the patterns for hot-swappable state.
App-scoped vs request-scoped state
The mental model is simple, and it's the single thing most worth internalizing.
App-scoped state lives in the DI container. It's registered once, during setup. A registerInstance(DbPoolToken, pool) call survives every request the process ever serves. Singletons, config objects, connection pools, cache clients, metric emitters — all app-scoped. They outlive any one request.
Request-scoped state lives in the AsyncLocalStorage bag. Anything you put there with ctx.set('key', value) exists only for that request and is invisible to any other concurrent request. Current user, current tenant, current trace span, current locale — all request-scoped. They die when the response is written.
If you find yourself reaching for a global mutable variable to share state between layers, you almost certainly want the bag. If you find yourself reconstructing the same object for every request, you almost certainly want DI. Get this distinction right and most of your "where does this go?" questions answer themselves.