The startup function is 140 lines long. Someone adds a cache warm-up at the bottom, below the line that flips the health check to ready, and the first requests after a deploy answer from an empty cache. Someone else needs migrations to run after the store opens and before the handlers register, and the only way in is to edit the function every caller shares.
What you get
You will end up with an ordered list of named startup steps, each able to stop the boot with a reason, and a test that pins readiness to the store being open. This is for you if other modules add steps to a boot you own.
Short answer
Put each startup step in an ordered list under a name: read config, open the store, register handlers, announce readiness. A step adds to a shared context, and stops the boot by returning a reason or throwing, with the step named in the result. Insert a later step before or after another by name, so a plugin joins the sequence without editing it. Announce readiness only after the last step, and only after each step has awaited its resource.
You will need
Node 22 or later, and a service with more than three startup steps. The sample installs ordu for one row of the comparison and nothing else; the store is a stand-in whose connection comes up after a delay, which is how every real client behaves. Readiness matters because the platform believes it: a Kubernetes pod whose containers report ready receives traffic from that moment.1
Voxgig maintains ordu. This page compares it with NestJS lifecycle hooks and with a hand-written
sequence of await calls.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
A hand-written async startup sequence | Fewer than a dozen steps, all in one module you own | A list you maintain, and an insertion API you write and test yourself, forty lines here | Steps come from other packages, which need hooks, ordering and a log |
| NestJS lifecycle hooks | The service is NestJS, so every provider already has onModuleInit and onApplicationBootstrap | Order comes from the module import graph, not from a name, so placing a step between two others means moving modules | You need a step placed relative to another by name, or the service is not NestJS |
| ordu task pipelines | Steps come from plugins, and you want before, after, a task log and a stop with a reason built in | A dependency with its own task, operator and result vocabulary, for a list you could write | The sequence is short and fixed, where the indirection costs more than it returns |
The trade-off is what you can inspect against what you have to step through. A task list, hand-written or ordu’s, is a thing you can print, insert into by name, and read a log from. It is also one more layer between a stack trace and the line that failed, on the path you read most often when it is broken. NestJS gets its ordering from the module graph and gives up naming: a step goes where its module sits.
Name the steps and insert by name
The list is forty lines. Insertion by name is the part that earns it.
add(step) {
if (!step.name || typeof step.run !== 'function') throw new Error('a step needs a name and a run function')
if (step.before) steps.splice(position(step.before), 0, step)
else if (step.after) steps.splice(position(step.after) + 1, 0, step)
else steps.push(step)
return this
},
position throws when the named step does not exist. A plugin that asks to run after a renamed
step then fails when it is added, with the name in the message. It does not reach boot with its
step appended to the end. That is the one check in the list that pays for itself on the first
refactor.
A step is an object with a name and a run, and a plugin adds its own without touching the four
the service started with.
export const runMigrations = {
name: 'run migrations',
after: 'open store',
run: (ctx) => {
ctx.migrated = ctx.store.query('create table if not exists product (id text)').length
},
}
Stop with a reason, not a stack trace
A step stops the sequence in one of two ways, and the result names the step either way.
async run(ctx = {}) {
const log = []
for (const step of steps) {
try {
const result = await step.run(ctx)
if (result?.stop) {
log.push({ step: step.name, ok: false, why: result.stop })
return { ready: false, stoppedAt: step.name, why: result.stop, log }
}
log.push({ step: step.name, ok: true })
} catch (err) {
log.push({ step: step.name, ok: false, why: err.message })
return { ready: false, stoppedAt: step.name, why: err.message, log }
}
}
return { ready: true, log }
},
Returning { stop } is for a failure the step expected and can name: the store did not answer,
the certificate file is missing. Throwing is for everything else, and the loop treats both the
same. ready is set by the caller from the result, never by a step, which is what makes the last
section possible.
Run the list, then run it as ordu tasks
The demo builds the four core steps, inserts two more by name, and boots twice: once with a store that answers and once with one that does not. Then it does the same with ordu.
node demo.mjs
the hand-written list, with two steps inserted by name
order read config > open store > run migrations > register handlers > warm cache > announce readiness
ready true 6 steps ran
ready false stopped at "open store": store unreachable 2 steps ran
the same list as ordu tasks
order read config > open store > run migrations > register handlers > warm cache > announce readiness
ready true 6 of 6 tasks ran
ready false stopped at "open store": store unreachable 2 of 6 tasks ran
a step that returns before its resource is ready
returns at once announced ready: true store open: false first request: store not open
awaits the store announced ready: true store open: true first request: 1 row
Adding run migrations with after: 'open store' and warm cache with
before: 'announce readiness' left the four core steps and the caller unchanged. The second
boot stops at the second step with the reason the store gave, and the four steps after it never
run.
The ordu version is the same six steps wrapped as tasks.
w.add({
name: step.name,
before: step.before,
after: step.after,
exec: async (spec) => {
const result = await step.run(spec.data)
if (result?.stop) return { op: 'stop', err: new Error(result.stop), why: result.stop }
},
})
What ordu adds over the hand-written list is the vocabulary the list would grow anyway. It has
before and after placement, a stop operator that carries err and why, and a task log
with timings. It has if, to run a task only when the data matches a pattern, and
active: false, to keep a task in the list without running it.2 What it costs is that
vocabulary, and a result whose shape, taskcount, tasktotal, task, tasklog, you read from
its README rather than from your own code.
NestJS reaches the same order by a different route. It
calls onModuleInit module by module,
deepest imports first and the root last, awaits each module’s hooks before moving on, and then
calls onApplicationBootstrap before it listens. A step that must run after the store opens goes
in a module that imports the store’s module. There is no name to insert after, and no log of
which hook stopped the boot beyond the rejected promise.3
Check the store is open before you say so
The third block of the demo is the pitfall. openStoreEarly calls connect() and returns, and
every client works this way: the call schedules a connection and the connection arrives later.
The step reported success, the list reached announce readiness, and the first request found a
store that was not open. openStore awaits the store before returning, and returns a stop with
the store’s own reason when it never comes up.
export const openStore = {
name: 'open store',
run: async (ctx) => {
ctx.store = store(ctx.config).connect()
try {
await ctx.store.whenReady()
} catch (err) {
return { stop: err.message }
}
},
}
The rule for every step that opens something: return when the thing is usable, not when it has
been asked for. A net server is usable at its listening
event, not when listen returns. A node-postgres client
is usable when the promise connect returns resolves. A step that returns before that point has
told the list a thing that is not yet true, and the list has no way to know.
Check it worked
Eight tests, and the sixth is the one that pins the pitfall rather than the fix.
node --test startup.test.mjs
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 197.627902
The sixth test boots with openStoreEarly and asserts three things together: the list reported
ready, the store reports not open, and the first request throws store not open. Keep that test.
It documents the failure the awaited version prevents, and a future edit that makes it pass by
accident has reintroduced the bug. The fourth asserts that nothing after a stop ran, by checking
that ctx.handlers was never set, which is the property a stop has to have.
When it goes wrong
Readiness fires and the first requests fail. A step returned before its resource was usable. Find
the step whose run does not await anything, and make it wait for the client’s own ready signal,
which every client exposes under some name.
A plugin’s step lands at the end of the list. Its after names a step that was renamed, and a
list that appends on a miss hides that. The list here throws at add, with the missing name in
the message; if yours does not, add the check.
The boot stops and the log says nothing useful. A step threw an error with a generic message, or
returned { stop: true } rather than a reason. Put the resource’s own message in the reason, and
test that it comes through.
The order differs between two environments. A step is added conditionally, and the step it names
is not there in one of them. Add every step unconditionally and give it active: false in ordu,
or an early return in the hand-written version, so the list is the same shape everywhere.
When not to do this
Do not build a task list for three steps that never change. await readConfig(); await openStore(); listen() is the whole program, and it reads better than any list. The list earns its place when
steps come from more than one module, or when somebody has to insert one in the middle.
Do not let a step announce readiness. A step that flips the health check is a step that can be placed before the store opens, by accident, by name. Readiness belongs to the caller, after the last step, from the result.
Do not adopt ordu to get ordering alone. Placement by name is six lines. The dependency is worth
taking when you also want its task log, its operators, and the if and active filters, which is
the case when plugins bring their own steps. ordu is the engine Seneca uses for its own extension
points, and that is the scale of problem it is shaped for.
Do not reach for lifecycle hooks in a service that is not already NestJS. The hooks are good because the module graph already exists there; adopting the framework to get the hooks buys an import graph you did not have and did not want.
Related how-tos
Last verified
Verified 2026-09-24 against Node 22.22.2 and ordu 4.3.0. Both output blocks are what the preceding command printed. The NestJS behavior is described from its documentation, not from a running NestJS service.
Footnotes
-
The Kubernetes documentation says a readiness probe runs on the container during its whole lifecycle, and that a pod whose containers report not ready does not receive traffic through its services. The platform asks the process one question, and believes the answer. That is the whole of the contract, and the whole of the problem in the third block of the demo. ↩︎ Back to text
-
ordu’s README describes it as the engine Seneca uses for its extension points, and mentions in passing that a Go port is available. A task runner whose first customer was its author’s other framework is a common shape for a small library, and an unusually well-exercised one, since every Seneca plugin boot runs through it. ↩︎ Back to text
-
NestJS documents the order as distance from the root module: the most deeply imported modules go first, the root goes last, and each module’s hooks are awaited before the next. That is a topological sort of the import graph, which means the order of a NestJS boot is a data structure nobody drew. It is also the same order every time, which is more than most hand-written startup functions can say. ↩︎ Back to text