Documentation

Cron

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 supportedSupportednpm Ā· source

Before you begin

  • An API that runs as a long-lived Node process: a self-hosted server, a container or your dev machine.
  • VITNODE_API_URL set to the address the API answers on, if that is not http://localhost:3000. The tick is a request to that address.
  • A random CRON_SECRET on production servers. In development the built-in default works.

Set up the adapter

Install the adapter

Install the Node CRON adapter
bun 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

Check the result

Open Debug Panel → Cron & queue (/admin/core/debug/cron) and find the process-queue row. Core schedules it every minute, so within a minute of the restart its Time column shows a last run, and that last run never gets more than a minute old while the adapter is alive. The scheduler card above the table turns green: All jobs on schedule.

If the last run stays empty, read the server console for a [VitNode] Cron tick failed line, then see the gotchas below.

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()
        },
        { noOverlap: true },
      )
    },
  }
}

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.

noOverlap skips a tick while the previous request is still waiting, and the request gives up after 55 seconds - the API keeps running the jobs, it just stops making the ticker wait. Running several API instances? Each one ticks, but every due job is claimed in core_cron first, so it runs on exactly one of them.

Three consequences worth writing down:

Because the tick is an HTTP call……this is true
it needs a URLthe origin comes from VITNODE_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 falls back to a built-in default, which works in development and is refused with 403 everywhere else

Gotchas

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

handleCronJobs() builds its URL from VITNODE_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 the Time column keeps saying Last Never forever.

A rejected tick shows up in the console only

When the endpoint answers with anything but a 2xx, or cannot be reached, handleCronJobs() prints [VitNode] Cron tick failed: <status> <body> to the server console, for example 403 Invalid cron authorization. The Debug Panel log gets nothing, because the request is refused before any route runs. Watch the console, and the last run in the Time column of Debug Panel → Cron & queue.

Production refuses the default secret

Outside development, an unset CRON_SECRET, the built-in default and the placeholders changeme, secret and your-secure-cron-secret-key (the value in apps/api/.env.example) are all refused with 403. Every tick fails, and no job runs, the queue included. Set a random value on the server.

Every instance runs its own timer

The adapter schedules per process, so four instances send four ticks a minute. That is safe, because the endpoint runs only what is due and each due run is claimed by one tick. A slow job can still overlap its next run, though, and a failed one is retried. 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.