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.
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_URLset to the address the API answers on, if that is nothttp://localhost:3000. The tick is a request to that address.- A random
CRON_SECRETon production servers. In development the built-in default works.
Set up the adapter
Install the adapter
bun i @vitnode/node-cronA 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.
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.
bun devCheck 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:
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 URL | the origin comes from VITNODE_API_URL, falling back to http://localhost:3000 |
| the API must be able to reach itself over the network | a tick dies where that origin is wrong, unreachable, or fronted by an auth proxy |
| the request is authenticated like any other | an unset CRON_SECRET falls back to a built-in default, which works in development and is refused with 403 everywhere else |
Gotchas
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.
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.
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.
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.
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.
Related
Declare a cron job
buildCron, where jobs are registered, and the operational rules that come with a scheduler.
Trigger cron over REST
The same endpoint this adapter posts, called from an external scheduler instead.
Custom adapter
Wrap a different scheduling library in the one-method CronAdapter interface.
Queue tasks
One-off background work, drained by the process-queue job this adapter ticks.