Cron Jobs

Node CRON

Tick VitNode's cron jobs from an in-process scheduler with the @vitnode/node-cron adapter - one package, one line of config.

@vitnode/node-cron is the in-process cron adapter: a node-cron timer that lives inside your API process and posts to the cron endpoint once a minute. No external service, no scheduler dashboard, nothing to keep in sync - which is exactly why it only works where your API is a long-lived process.

Cloud (serverless)Self-hostedLinks
❌ Not supported✅ Supportednpm · source

Quick start

Install the adapter

Install the Node CRON adapter
bun i @vitnode/node-cron
pnpm i @vitnode/node-cron
npm i @vitnode/node-cron

A runtime dependency, not a dev one - the adapter is imported by the server that serves your API, so it has to survive a production install.

Register it in the API config

NodeCronAdapter() takes no arguments. There is nothing to configure, because the schedule it keeps is not yours: each job carries its own expression, and this only decides how often VitNode looks.

src/vitnode.api.config.ts
import { buildApiConfig } from '@vitnode/core/vitnode.config'
import { NodeCronAdapter } from '@vitnode/node-cron'

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

This repository's own standalone API does exactly that - see apps/api/src/vitnode.api.config.ts.

Restart the server

schedule() is a boot step, not a request step: VitNode calls it once while the API is being built, so a running process never picks the adapter up.

Restart the dev server
bun dev
pnpm dev
npm run dev

Verify the first tick

Open /admin/core/advanced/cron and watch the clean row - core's hourly session cleanup, which every installation has. Within a minute of the restart Last Run stops saying Never and Next Run fills in.

Nothing else needs to happen for the clock to be proven: process-queue runs every minute, so its Last Run should never be more than a minute old while the adapter is alive.

What the adapter actually does

The whole package is the file below, and knowing it saves an afternoon of debugging:

packages/node-cron/src/index.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()
      })
    },
  }
}

handleCronJobs() is an HTTP request to your own API - POST /api/@vitnode/core/cron, with Content-Type: application/json and Authorization: Bearer $CRON_SECRET. The adapter never touches the database and never calls a job handler; it only knocks on the door once a minute, and the cron endpoint decides what is due.

Three consequences worth writing down:

Because the tick is an HTTP call……this is true
it needs a URLthe origin comes from NEXT_PUBLIC_API_URL, falling back to http://localhost:3000
the API must be able to reach itself over the networka tick dies where that origin is wrong, unreachable, or fronted by an auth proxy
the request is authenticated like any otheran unset CRON_SECRET means both sides use the well-known default, and the tick works

Gotchas

The tick posts to NEXT_PUBLIC_API_URL, not to the port you booted

handleCronJobs() builds its URL from NEXT_PUBLIC_API_URL and falls back to http://localhost:3000. This repo's standalone API listens on 8000 - which is exactly why apps/api/.env.example sets the variable - so leaving it unset sends every tick to port 3000, where the web app lives. Symptom: the scheduler is configured, the boot log is clean, and Last Run stays Never forever.

A rejected tick leaves no trace

handleCronJobs() awaits the fetch and then discards the response, and a 403 is answered by middleware rather than by the route: the CSRF check runs before the logger even exists, and a mismatched secret raises an HTTPException that the API's error handler returns without logging. So the failure you are most likely to hit writes nothing to the console and nothing to the Debug Panel. The AdminCP's Last Run column is the health check.

Every instance runs its own timer

The adapter schedules per process, so four instances send four ticks a minute. That is safe - the endpoint runs only what is due - but it does not de-duplicate the work, so two ticks can start the same job at once. Write idempotent handlers, or move the clock to a single external scheduler.

Serverless has nowhere to keep the timer

A function that is frozen between requests cannot hold a one-minute interval, so this adapter is not a deployment choice on Vercel-style hosting - it is a no-op with a package to maintain. Use the REST endpoint there.

Next