Nitro scheduled tasks not running in production (Nuxt): 8 things to check
Your Nuxt scheduledTasks work in nuxi dev and do nothing in production. The experimental.tasks flag, serverless presets that drop tasks without a warning, hosts that stop the process, NODE_ENV=test, node-cluster running each task once per worker, and how to keep a record of runs.
10 min read
You added a nightly db:cleanup to scheduledTasks in nuxt.config.ts. In nuxi dev it fires
right on schedule. In production it doesn’t, or you can’t tell whether it does, because nothing in
the logs says either way.
Nitro’s task docs are short on what happens outside dev. We built one Nuxt 4.6.2 app (Nitro 2.13.5) with a task that logs its PID, ran it on five presets, and read Nitro’s source where the output didn’t explain itself.
Short answer
Nitro’s scheduled tasks only run where Nitro starts its own scheduler: the node-server,
node-cluster, bun and deno-server presets, and nuxi dev. Check four things first:
nitro.experimental.tasks is true, you built with one of those presets (Netlify, Vercel and AWS
Lambda builds drop tasks without a word), the process stays up between requests, and NODE_ENV
isn’t test. If they all hold, the task probably runs and fails, and the only trace is one
Error while running scheduled task line in the server’s stderr. On node-cluster each task runs
once per worker, and Nitro keeps no run history on any preset.
If you want a record of every run and an alert when one fails, without changing your task files,
RunWisp, an open-source cron replacement that ships as a single binary, runs Nitro’s
scheduledTasks through its npm package @runwisp/js. Setup below.
| Works on Netlify/Vercel | Runs once with N workers | Run history | Failure alerts | Nothing new to run | |
|---|---|---|---|---|---|
Nitro’s scheduler (node-server/bun) | ✗ | ✗ | ✗ | ✗ | ✓ |
| Server route + host crontab | ✗ | ✓ | ✗ | ✗ | ✓ |
| Server route + platform cron | ✓ | ✓ | ✓ | ✗ | ✓ |
| Server route + external pinger | ✓ | ✓ | ✓ | ✓ | ✗ |
RunWisp @runwisp/js/nuxt | ✗ | ✓ | ✓ | ✓ | ✗ |
“Runs once with N workers” means node-cluster or PM2 cluster mode on one box. “Platform cron” is
Vercel Cron or a Netlify scheduled function; both keep invocation logs, neither alerts on a failed
run by default.
The setup we tested
A task file and a schedule. Every check below assumes this shape:
// server/tasks/db/cleanup.ts
export default defineTask({
meta: { name: "db:cleanup", description: "Remove expired sessions" },
async run() {
const removed = await cleanupSessions();
console.log(`removed ${removed} expired sessions`);
return { result: removed };
},
});
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
experimental: { tasks: true },
scheduledTasks: { "0 3 * * *": ["db:cleanup"] },
},
});
1. experimental.tasks is off
Tasks are still behind a flag in Nitro 2. Without experimental: { tasks: true }, Nitro doesn’t
scan server/tasks/, and the schedule points at a task it can’t find. The build prints one line:
[nitro] WARN Scheduled task db:cleanup is not defined!
The file is right there, so the warning reads like a typo in the name. It’s the flag. The build
succeeds, the server starts, and nothing runs. Put the flag under nitro, not at the top level of
nuxt.config.ts.
2. The preset has no scheduler
Nitro starts its scheduler from the server entry, and only some entries do it. In Nitro 2.13.5 that’s
node-server, node-listener, node-cluster, bun and deno-server, plus the dev server.
We built the same app with NITRO_PRESET=netlify and NITRO_PRESET=vercel. Both builds finished
without a warning, and neither output contained the task: there was no chunks/tasks/ directory,
and nothing cron-related in Vercel’s config.json. A serverless function only exists while it
answers a request, so there’s nowhere for a timer to live.
Cloudflare is the exception. The cloudflare-module preset runs your tasks from Cloudflare Cron
Triggers, but Nitro doesn’t create the triggers. (We didn’t test cloudflare-pages, and Nitro’s docs
only name cloudflare-module.) Add them to your wrangler
config with the same strings as in scheduledTasks, because Nitro matches on the exact string:
# wrangler.toml
[triggers]
crons = ["0 3 * * *"]
Static builds (nuxi generate) have no server at all, so no tasks.
On Netlify or Vercel, expose the task as a route and let the platform’s scheduler call it.
Running a task from a route has the route; on Vercel, point a
crons entry in vercel.json at it (Vercel calls it with GET and sends Authorization: Bearer $CRON_SECRET, so set NUXT_CRON_SECRET to the same value as Vercel’s CRON_SECRET), and on Netlify, a scheduled function that fetches it.
3. The host stops the process
The preset can be right and the process still not there at 03:00. Hosts that scale to zero stop your server when no requests come in, and a timer in a stopped process doesn’t fire:
- Render’s free web services spin down after 15 minutes without traffic.
- Fly.io stops machines with
auto_stop_machineson, whichfly launchsets by default. - Cloud Run, with request-based billing, only gives the container CPU while it handles a request, and scales to zero instances unless you set a minimum.
Turn scale-to-zero off for the app, keep one instance running, or have something outside call a route instead. A nightly job on a server that sleeps at night is a common way to spend a week debugging cron syntax that was fine.
4. NODE_ENV is test
Nitro skips the scheduler when std-env says it’s a test run,
which is NODE_ENV=test or any non-empty TEST variable. We started the built server with each:
it listened on its port, served pages, and never ran the task. Nothing in the output says the
scheduler is off.
This bites when a staging Compose file or a CI-built image carries NODE_ENV=test into a deployed
container. env | grep -E '^(NODE_ENV|TEST)=' in the running container settles it.
5. It ran and threw
Nitro catches a scheduled task’s error and prints it, and that’s all it does with it:
Error while running scheduled task "db:cleanup" Error: connection refused
at Object.run (file:///app/.output/server/chunks/tasks/cleanup.mjs:21:33)
at runTask (file:///app/.output/server/chunks/nitro/nitro.mjs:5404:36)
The server keeps going, the next tick runs again, and nothing counts failures or tells anyone. If
your server logs go to docker logs and get rotated, the error goes with them. A successful run
prints nothing unless the task logs, so “no output” doesn’t tell you whether it ran either.
6. The last run was still going
runTask() won’t start a task that’s already running; it hands back the running one instead. So
when a run outlasts the interval, the ticks in between are skipped. We ran a 12-second task every
5 seconds:
[db:cleanup] 19:11:30.007Z
[db:cleanup] done <- 19:11:42, the 35 and 40 ticks were dropped
[db:cleanup] 19:11:45.003Z
That’s the right default for most jobs, but it’s silent. A nightly job that starts taking 25 hours runs every other night, and no line says so. Preventing overlapping runs covers the other policies.
7. The clock isn’t the one you think
Nitro passes scheduledTasks to croner without a timezone, so
0 3 * * * means 03:00 on the server’s clock. Your laptop is probably on local time; most Docker
images are on UTC. Set TZ on the container, or convert the hour. Croner also accepts a six-field
expression with seconds, */30 * * * * *, which is handy for testing and easy to deploy by accident.
While you’re reading the payload: Nitro sets payload.scheduledTime once, when the server starts,
and hands the same object to every run. In our test every run reported the server’s start time, so
don’t use it to work out which window a run covers. Use Date.now().
8. A deploy killed it halfway
On SIGTERM, the node-server preset closes the HTTP server and runs Nitro’s close hooks, which
know nothing about scheduled tasks. We sent SIGTERM one second into an eight-second task: the
process exited 50 ms later with exit code 0, the task never printed “done”, and nothing logged
that it was cut off.
Every redeploy, docker stop and autoscaler shrink can do this. Keep tasks idempotent so the next
run finishes the work, or wait for running tasks yourself in a Nitro plugin’s close hook (and keep
Docker’s 10-second stop_grace_period in mind).
node-cluster runs each task once per worker
The opposite problem. The node-cluster preset forks one worker per CPU core by default, and each
worker loads node-server, which starts its own scheduler. With NITRO_CLUSTER_WORKERS=3:
[db:cleanup] 19:10:40.005Z pid=415070
[db:cleanup] 19:10:40.006Z pid=415077
[db:cleanup] 19:10:40.006Z pid=415071
Three runs every tick, and on our 8-core box the default would have been eight. Nitro has no lock or
leader election. The same happens with node-server under PM2 cluster mode, or with two replicas of
the container.
The fixes are the ones from node-cron under PM2 cluster
mode: build with node-server and run one process,
take a lock in the task (a Redis SET key NX PX, or a Postgres advisory lock), or move the schedule
out of the web processes.
Running a task on demand
nuxi task run db:cleanup and the /_nitro/tasks endpoints only exist on the dev server; we found
no trace of them in the production build. To run a task in production, or to let an outside
scheduler trigger it, add a route:
// server/api/cron/[task].ts (no method suffix, so it answers Vercel's GET and curl's POST)
export default defineEventHandler(async (event) => {
const secret = useRuntimeConfig(event).cronSecret;
if (!secret || getHeader(event, "authorization") !== `Bearer ${secret}`) {
throw createError({ statusCode: 401 });
}
return await runTask(getRouterParam(event, "task")!);
});
Set runtimeConfig: { cronSecret: "" } in nuxt.config.ts and NUXT_CRON_SECRET where it runs.
Then a host crontab line works on any server preset:
0 3 * * * curl -fsS -X POST -H @/etc/myapp/cron-auth http://127.0.0.1:3000/api/cron/db:cleanup
/etc/myapp/cron-auth holds Authorization: Bearer <secret>, chmod 600. Remove
scheduledTasks once you do this, or the task runs from both. If one crontab line is all you need
and you don’t care about history, stop here. Where cron output goes and
failure alerts cover the rest.
Keep scheduledTasks and record every run with RunWisp
RunWisp (our project, so weigh accordingly) is an open-source cron replacement that ships as a single
binary, with a web UI that records every run. Its npm package @runwisp/js has a Nuxt module that
takes over scheduledTasks from Nitro’s scheduler. Your config and server/tasks/ stay as they are:
npm install @runwisp/js
// nuxt.config.ts
export default defineNuxtConfig({
modules: ["@runwisp/js/nuxt"],
nitro: {
experimental: { tasks: true },
scheduledTasks: { "0 3 * * *": ["db:cleanup"] },
},
});
Each scheduled task becomes a RunWisp task with the same name. What it prints goes into that run’s log, and a throw fails the run. We ran the throwing version from check 5:
$ npx runwisp logs db:cleanup
Error: connection refused
at Object.run (file:///app/.output/server/chunks/tasks/cleanup.mjs:22:33)
at runTask (file:///app/.output/server/chunks/nitro/nitro.mjs:4987:36)
[WARN] run failed task=db:cleanup run=01M5T7SMS40HQAGRRQVZX0YVWG exit=1 reason=failed dur=3ms
That run is in the web UI at :9477 next to the successful ones, with a “run now” button, and a
notifier sends failures to Slack, Discord, Telegram, email or a webhook.
The same build on node-cluster with three workers ran the task once per tick. Workers that share a
data directory share one RunWisp: one runs the tasks, and the rest log
standby: another copy of this app is active and take over if it exits. On node-server it also
fixes check 8: we sent SIGTERM one second into the eight-second task, and the server waited for
it to print “done” before exiting, up to Nitro’s 30-second NITRO_SHUTDOWN_TIMEOUT.
It keeps Nitro’s semantics where they matter: a time missed while the server was down isn’t run late, and a tick that comes while the task is still running is skipped, except now the skip is recorded.
In Docker, build the image on the platform you deploy to (the binary is installed for the machine
that runs npm install), put the data directory on a volume (.runwisp in the directory you start
the server from, or runwisp: { data: "/data/runwisp" } in the config), and set RUNWISP_PASSWORD
if you open the dashboard beyond localhost. The Nuxt guide
covers defining tasks in code with retries and timeouts.
The limits:
- It needs a server that stays up. The build fails with serverless, edge and static presets rather than shipping tasks that never run, so it’s no help on Netlify or Vercel, and none on a host that scales to zero.
- It’s per machine. Two servers, or two replicas with their own data directories, each run every task. Across hosts you still want a lock or one scheduler.
- Tasks get an empty payload, so no
payload.scheduledTime. Given check 7, you weren’t using it. - It’s a daemon your app starts, with its own port and a data directory to keep.
Which one to use
- One
node-serverprocess that stays up, idempotent tasks, and logs you read: Nitro’s scheduler as is. Turn the flag on and check 3 and 4. - On Cloudflare: Nitro’s Cron Trigger support, with matching triggers in wrangler.
- On Netlify or Vercel: a protected route that calls
runTask(), and the platform’s scheduler. - On a VPS and fine without history: the same route and a crontab line.
- On
node-cluster, or you want each run’s output, failures alerted and a deploy not to cut runs off: RunWisp. The quick start covers the standalone daemon, and the code is on GitHub.
Common questions
Why are my Nitro scheduled tasks not running in production?
Which Nitro presets support scheduledTasks?
Why does my Nitro scheduled task run several times?
How do I run a Nitro task manually in production?
Does Nitro keep a history of scheduled task runs?
Is there a way to keep Nitro's scheduledTasks and get a record of every run?
Read next
-
Django periodic tasks without Celery or Redis: 5 Docker Compose setups
You need clearsessions every night and a report every 15 minutes, and every tutorial adds Celery, beat and Redis. Five setups without them, each with a Compose file: a management command plus supercronic, APScheduler (and why it runs 3 times under gunicorn), Huey on SQLite, django-q2 on your database, and RunWisp.
-
Laravel scheduler in Docker: schedule:run with cron, schedule:work or Supervisor?
Four ways to run Laravel's scheduler in a container, tested on Laravel 12 and 13: which see your env vars, where the output goes, what docker stop does to a running task, and why schedule:run exits 0 when a task fails.