Documentation

Cron

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(() => {
      void handleCronJobs()
    }, 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 VITNODE_API_URL (falling back to http://localhost:3000) with Content-Type: application/json and Authorization: Bearer $CRON_SECRET. It never rejects once the URL is valid. A non-2xx response or a network error is printed to the console as [VitNode] Cron tick failed: <reason>, so your adapter needs no catch. After 55 seconds it stops waiting for the response, while the API keeps running the jobs.

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() 
        },
        // Skip a tick while the previous one is still waiting on the API.
        { noOverlap: true },
      )
    },
  }
}

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

Open Debug Panel → Cron & queue (/admin/core/debug/cron) and watch the process-queue row. It is scheduled * * * * *, so a healthy adapter keeps its last run under a minute old. If the Time column keeps saying Last Never, your schedule() ran but your tick is not landing. The server console then shows [VitNode] Cron tick failed: ... with the status. 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

Each due run is claimed once, but nothing waits for a running job to finish. If your scheduler fires again before a slow handler is done, its next run can start alongside it. The operational rules cover what to do about it.