← all posts

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.

0warnings from the netlify and vercel builds, which left the task out
3×every task fired per tick on node-cluster with 3 workers
exit 0after SIGTERM cut a running task off halfway

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/VercelRuns once with N workersRun historyFailure alertsNothing 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_machines on, which fly launch sets 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-server process 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?
The common causes, in order: nitro.experimental.tasks isn't true, so Nitro never scans server/tasks and only warns 'Scheduled task db:cleanup is not defined!' at build time; the app was built with a serverless preset like netlify, vercel or aws-lambda, which has no scheduler and doesn't even bundle the task files; the host stops the process when idle, as Render's free plan, Fly.io's auto_stop_machines and Cloud Run's request-based billing do; NODE_ENV=test or TEST is set, which turns the scheduler off; or the task ran and threw, which only leaves an 'Error while running scheduled task' line in the server log.
Which Nitro presets support scheduledTasks?
In Nitro 2.13, node-server, node-listener, node-cluster, bun and deno-server start Nitro's built-in scheduler, and nuxi dev runs it too. cloudflare-module runs tasks from Cloudflare Cron Triggers, but you have to add the triggers to your wrangler config yourself, with the same cron strings as in scheduledTasks. netlify, vercel, aws-lambda and the other serverless presets ignore scheduledTasks; you need the platform's own scheduler to call a route that runs the task.
Why does my Nitro scheduled task run several times?
Because each server process runs its own scheduler. The node-cluster preset forks one worker per CPU core by default (NITRO_CLUSTER_WORKERS overrides it), and every worker runs every scheduled task, so with 3 workers each task ran 3 times per tick in our test. PM2 cluster mode and several Docker replicas do the same. Nitro has no lock. Set NITRO_CLUSTER_WORKERS, build with node-server and run one process, or take a lock in the task.
How do I run a Nitro task manually in production?
Nitro's /_nitro/tasks endpoints and nuxi task run only exist on the dev server. In production, add a server route that calls runTask('db:cleanup') and protect it with a secret header, then call it with curl or from a platform scheduler. runTask returns the task's result, and a second call while the task is still running joins that run instead of starting another.
Does Nitro keep a history of scheduled task runs?
No. Nitro's scheduler keeps nothing: a successful run leaves no trace unless the task logs something, a failed one prints its error to the server's stderr, and a run skipped because the last one was still going leaves nothing at all. If you need to know when a task last ran, log it yourself, have the task ping a heartbeat monitor, or run it under something that records runs.
Is there a way to keep Nitro's scheduledTasks and get a record of every run?
RunWisp, an open-source cron replacement that ships as a single binary, has an npm package, @runwisp/js, with a Nuxt module. Add it to modules and your scheduledTasks run under RunWisp instead of Nitro's scheduler, with the same server/tasks files: each run is recorded with its output, exit code and duration, a task that throws fails the run and can alert you, and node-cluster workers share one RunWisp so each task fires once per tick. Its limits: it needs a server that stays up, so the build fails with serverless and static presets; it doesn't coordinate across several servers; tasks don't get payload.scheduledTime; and it starts a daemon next to your app.
  1. 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.

  2. 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.

Get started
GPL 3.0 · no accounts · free to self-host

Start in 30 seconds. Ship on your terms.

A single Go binary. If it doesn't earn the disk space, rm it. No account, no signup, no catch.

Install in 30 seconds
curl -fsSL https://get.runwisp.com | sh