Cron Jobs

Custom Adapter

Wrap any scheduler in VitNode's one-method CronAdapter interface - a plain setInterval, a cron library, or a platform trigger.

A cron adapter is the thinnest interface in VitNode: one method, no return value, called once while the API boots. Its whole job is to call handleCronJobs() on a clock. Everything else - which jobs exist, which are due, what gets logged - is the cron endpoint's business, not the adapter's.

Write one when the Node CRON adapter is not the scheduler you want: you already run a job runner, you need the tick to carry a trace header, or the platform hands you a trigger of its own.

Quick start

The shortest adapter that works is a timer:

src/adapters/interval-cron.adapter.ts
import { type CronAdapter, handleCronJobs } from '@vitnode/core/api/lib/cron'

export const IntervalCronAdapter = (): CronAdapter => ({
  schedule() {
    setInterval(() => {
      handleCronJobs().catch((error: unknown) => {
        console.error('[cron] tick failed', error)
      })
    }, 60_000)
  },
})
src/vitnode.api.config.ts
import { buildApiConfig } from '@vitnode/core/vitnode.config'

import { IntervalCronAdapter } from './adapters/interval-cron.adapter'

export const vitNodeApiConfig = buildApiConfig({
  cron: IntervalCronAdapter(), 
  // ...
})

That is a complete, working adapter. The rest of this page is about doing it with a real scheduler, and about the ways it can go quietly wrong.

The interface

packages/vitnode/src/api/lib/cron.ts
export interface CronAdapter {
  schedule: () => void
}

Prop

Type

handleCronJobs() comes from the same module and takes no arguments. It posts to /api/@vitnode/core/cron on the origin in NEXT_PUBLIC_API_URL (falling back to http://localhost:3000) with Content-Type: application/json and Authorization: Bearer $CRON_SECRET. It resolves as soon as the response arrives and never looks at it, so a 403 or a 500 resolves exactly like a 200 - only a network-level failure rejects.

You do not have to use handleCronJobs

It is a convenience, not a contract. An adapter whose scheduler lives outside the process - a platform trigger, a separate worker - can leave schedule() empty and let that scheduler call the endpoint directly. The adapter still earns its place: setting cron is what makes the AdminCP report a scheduler as configured.

Build one with a library

Write the adapter

Keep it in your app rather than in a plugin - a cron adapter is a deployment decision, and it is registered from the API config, which only the host has.

node-cron.adapter.ts
vitnode.api.config.ts

The official package is the reference implementation, and it is short enough to read in one go:

src/adapters/node-cron.adapter.ts
import { type CronAdapter, handleCronJobs } from '@vitnode/core/api/lib/cron'
import { schedule } from 'node-cron'

export const NodeCronAdapter = (): CronAdapter => {
  return {
    schedule() {
      schedule('*/1 * * * *', async () => {
        await handleCronJobs() 
      })
    },
  }
}

Give the factory options if your scheduler has any worth exposing - a tick interval, a timezone, a logger. CronAdapter says nothing about the factory, so its signature is yours.

Register it in the API config

src/vitnode.api.config.ts
import { buildApiConfig } from '@vitnode/core/vitnode.config'

import { NodeCronAdapter } from './adapters/node-cron.adapter'

export const vitNodeApiConfig = buildApiConfig({
  cron: NodeCronAdapter(), 
  // ...
})

Restart and verify the first tick

schedule() runs at boot, so a running process never picks up the change.

Restart the dev server
bun dev
pnpm dev
npm run dev

Open /admin/core/advanced/cron and watch the process-queue row - it is scheduled * * * * *, so a healthy adapter keeps its Last Run under a minute old. If it stays on Never, your schedule() ran but your tick is not landing: check the origin, then the secret, then whether the API can reach itself.

Gotchas

A throw in schedule() takes the API down

VitNode calls schedule() synchronously while building the application and does not wrap it. An adapter that validates its own options by throwing turns a bad cron expression into a server that will not start. Validate, log, and return instead.

schedule() can be called more than once per process

There is no stop and no teardown hook, and a dev server that re-evaluates its server modules can build the API again. VitNode's TanStack Start app parks the API instance on globalThis for exactly this reason - if yours does not, an adapter that keeps a timer should be able to notice it already has one instead of stacking a second.

Ticking faster does not make a job faster

The endpoint runs what is due when it is called, so the tick interval is the floor on every job's resolution - but it is not a speed control. A tick every ten seconds on jobs that are all */5 * * * * is five wasted requests a minute, and a tick every five minutes silently downgrades * * * * * jobs, queue draining included.

Overlapping ticks are your problem, not the adapter's

Nothing locks a job while it runs. If your scheduler can fire again before the previous tick's handlers have finished, the same handler can be running twice. The operational rules cover what to do about it.

Next