# How to run service startup steps in a fixed order

> Turn an improvised startup function into an ordered list of named steps that can stop the boot and say why, and pin readiness to the store actually being open.

Source: https://voxgig.com/howto/run-service-startup-steps-in-order

- Audience: library-maintainer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-09-24
- Published: 2026-09-24

## 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](https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/)
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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function) | 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](https://docs.nestjs.com/fundamentals/lifecycle-events) | 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](https://github.com/rjrodger/ordu) | 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.

```js title="startup.mjs"
    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.

```js title="steps.mjs"
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.

```js title="startup.mjs"
    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.

```bash
node demo.mjs
```

```text output
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.

```js title="ordu-startup.mjs"
    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`](https://docs.nestjs.com/fundamentals/lifecycle-events) 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.

```js title="steps.mjs"
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](https://nodejs.org/api/net.html) is usable at its `listening`
event, not when `listen` returns. A [node-postgres client](https://node-postgres.com/apis/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.

```bash
node --test startup.test.mjs
```

```text output
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

- [Limit concurrency in a message worker](/howto/limit-concurrency-in-a-message-worker)

- [Test a plugin through every lifecycle stage](/howto/test-a-plugin-through-every-lifecycle-stage)

- [Merge nested config objects with predictable precedence](/howto/merge-nested-config-objects-with-precedence)

## 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.

[^1]: 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](https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/)
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.

[^2]: ordu's README describes it as the engine Seneca uses for its extension points, and
mentions in passing that [a Go port is available](https://github.com/rjrodger/ordu). 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.

[^3]: NestJS documents the order as
[distance from the root module](https://docs.nestjs.com/fundamentals/lifecycle-events): 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.