Scheduled jobs for Node.js services, on top of node-cron, with the parts every service ends up adding: overlapping runs are skipped, a failed run is reported without stopping the schedule, each tick can run in its own context, and stop() waits for the run in progress, so a job finishes before the process exits.
- One run at a time, by default. A tick that arrives while the previous run is still going is skipped and logged, so a slow job can't pile up copies of itself. Jobs that are safe to overlap opt in with
allowOverlap. - A failure belongs to its tick. A run that throws is logged and passed to
onError, and the next tick runs as normal. A throwingonErroris logged too and never escapes as an unhandled rejection. - Stopping finishes the work.
stop()stops scheduling first, then waits for runs in progress, so it can sit in a shutdown sequence before the database closes. - Context without coupling.
runInContextwraps each tick in whatever context the service uses, anAsyncLocalStorageor a request-context library, without this package knowing about it. The failure log is written inside that context, so a logger that injects context values sees the tick's. - Logs only what the logger can't know. Lines carry the job's name, and its schedule when it starts. Request IDs and the like come from the logger's own context injection.
- Tested the way it runs. Unit tests hold coverage at 100%, enforced in CI. Component tests build the package as published, install it beside the real
node-cron, and check its behaviour on real schedules.
npm i @jtkunley/cronRequires Node.js 22 or later. Logging is optional; any logger with info, warn and error(message, error, data) fits, including a @jtkunley/logger child logger.
import { createCron } from '@jtkunley/cron';
const job = createCron({
name: 'nightly-sync',
schedule: '0 2 * * *', // 02:00 daily
run: async () => {
await syncSomething();
},
});
job.start();
// On shutdown:
await job.stop();The node-cron task is created stopped and named after the job; nothing is scheduled until start(). An invalid schedule makes createCron throw, so a bad expression from config fails at startup rather than never running.
const job = createCron({
name: 'poll-queue',
schedule: '*/5 * * * *', // every 5 minutes
run: drainQueue,
logger: childLogger,
allowOverlap: false, // default: skip a tick while the previous run is in progress
onError: (err) => Sentry.captureException(err), // called after the failure is logged
waitForCompletionOnStop: true, // default: stop() waits for runs in progress
});runInContext receives each tick and runs it, so the job can open a fresh context for every run:
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
const storage = new AsyncLocalStorage<{ requestId: string }>();
createCron({
name: 'reconcile',
schedule: '*/5 * * * *',
runInContext: (tick) => storage.run({ requestId: randomUUID() }, tick),
run: reconcile, // storage.getStore() works in here
logger: childLogger,
});With a @jtkunley/service-runtime service, a tick gets the same values a request would, worked out by the context's own value functions:
runInContext: (tick) => service.context.run({ headers: { 'x-tenant-id': process.env.TENANT_ID } }, tick),A runInContext that throws, such as a context missing a required value, fails the tick without calling run(). It is logged and reported to onError like a failed run, and later ticks still run.
Stop the job in a shutdown cleanup, listed before the resources its runs use. A @jtkunley/service-runtime service runs its cleanups in order:
configureService({
logger: rootLogger,
port: 3000,
cleanups: [() => job.stop(), closeDatabase],
});Returns a CronController.
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
name |
string |
Yes | — | Names the node-cron task and appears in every log line. |
schedule |
string |
Yes | — | A cron expression node-cron accepts, with an optional leading seconds field. |
run |
() => Promise<void> |
Yes | — | The work for each tick. |
logger |
CronLogger |
No | — | Logs start, stop, skipped ticks and failures. |
onError |
(error: unknown) => void |
No | — | Called with whatever a run threw, after it is logged. |
allowOverlap |
boolean |
No | false |
If false, a tick is skipped while the previous run is in progress, and a warn is logged. |
waitForCompletionOnStop |
boolean |
No | true |
If true, stop() waits for runs in progress. |
runInContext |
(tick) => Promise<void> |
No | — | Runs each tick, so it can open a context around it. |
| Method | Description |
|---|---|
start() |
Starts scheduling. Calling it again while started does nothing. |
stop() |
Stops scheduling, then, by default, waits for runs in progress. Does nothing when not started. Scheduling stops at once, so a start() made before stop() resolves takes effect. |
isStarted() |
Whether the job is started. |
isActive() |
Whether a run is in progress. |
Without allowOverlap, a run still in progress after stop() (with waitForCompletionOnStop: false) keeps blocking ticks after a restart, until it finishes.
info(message, data), warn(message, data) and error(message, error, data), where data is a plain object such as { name }.
npm install
npm run check # typecheck, lint, formatting and all tests
npm run test:unit # unit tests, next to the source in src/
npm run test:component # component tests in test/component/
npm run test:coverage # all tests, failing below 100% coverage
npm run format # format with Prettier
npm run build # compile the publishable package into dist/src/
index.ts the package entry point: createCron and its types
lib/
cron.ts createCron
types.ts CronOptions, CronController and CronLogger
to-error.ts wraps non-Error failures for the logger
__tests__/ unit tests, against a mocked node-cron
test/component/ behaviour tests against the built package and the real node-cron
main, types and exports in package.json point at built output. npm run build compiles it into dist/src/ and copies package.json, this README, the changelog and the license into dist/, which is the directory to publish:
npm run build && npm publish ./distPublishing from the repository root is refused, since it would ship the TypeScript sources.