Background jobs
Devora.js has no job runner. A request handler answers and returns; work that should happen later, or on a schedule, runs somewhere else. Three setups were tested on one Node server. None was deployed to Vercel or Netlify.
Do you need one?
If the work takes less than a second or two and the user can wait, do it in the request and answer when it is done. Reach for a job when the work is slow (a report, an image, a batch of emails), must be retried if it fails, or must run on a schedule with no request to start it.
BullMQ with Redis
A queue in Redis and a worker process. Retries, delays, several workers. Needs Redis.
A SQLite queue
A table and a worker, no Redis. One server only: both processes read the same file.
node-cron
A schedule inside the server process. It starts at the first request to its route.
Where this works
All three were run on one Node server with devora start. A worker is a separate process that must stay on, so it needs an always-on host (a VPS, a container). A Vercel or Netlify function cannot run a worker, hold a timer, or share a file between invocations. Not verified on either platform: nothing was deployed.
1. BullMQ with Redis and a worker
The API route enqueues the job and returns its id at once (202). A separate worker takes it from Redis. bullmq needs ioredis installed separately; without it the first request fails with a BullMQ message saying so.
The API route
import { apiRoute, HttpError } from "@devorajs/core";
import { Queue } from "bullmq";
// POST /api/jobs-bullmq {n} enqueues a job on Redis; GET ?id=... reads its state and result.
// The worker is a separate process (lab-scripts/worker-bullmq.mjs).
const connection = { host: "127.0.0.1", port: Number(process.env.REDIS_PORT ?? 6390), maxRetriesPerRequest: null as null };
const queue = new Queue("square", { connection });
export const methods = ["GET", "POST"];
export const handler = apiRoute(async (req, ctx) => {
ctx.requireAuth();
if (req.method === "POST") {
const { n } = JSON.parse(req.body.toString("utf-8") || "{}");
if (typeof n !== "number") throw new HttpError(400, "n must be a number");
const job = await queue.add("square", { n, by: (ctx.session as { username: string }).username });
return json(202, { id: job.id });
}
const id = new URL(req.url, "http://x").searchParams.get("id") ?? "";
const job = await queue.getJob(id);
if (!job) throw new HttpError(404, "no such job");
return json(200, { id, state: await job.getState(), result: job.returnvalue ?? null });
});
function json(status: number, body: unknown) {
return { status, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) };
}The worker
import { Worker } from "bullmq";
const connection = { host: "127.0.0.1", port: Number(process.env.REDIS_PORT ?? 6390), maxRetriesPerRequest: null };
new Worker("square", async (job) => { await new Promise((r) => setTimeout(r, 300)); return { square: job.data.n ** 2, by: job.data.by, worker: process.pid }; }, { connection })
.on("completed", (j, r) => console.log("completed", j.id, r));
console.log("bullmq worker up, pid", process.pid);Run it
From the project folder; replace lab with your app's name.
npm install bullmq ioredis # bullmq needs ioredis installed next to it
redis-server --port 6390 # or any Redis you already run; set REDIS_PORT to match
npx devora build --app=lab
PORT=4501 REDIS_PORT=6390 npx devora start --app=lab # terminal 1: the app
REDIS_PORT=6390 node lab-scripts/worker-bullmq.mjs # terminal 2: the worker
TOKEN=$(curl -s -X POST localhost:4501/api/session -d '{"username":"ann"}' | node -pe 'JSON.parse(require("fs").readFileSync(0)).token')
curl -s -X POST localhost:4501/api/jobs-bullmq -H "authorization: Bearer $TOKEN" -d '{"n":12}'
# {"id":"1"}
curl -s "localhost:4501/api/jobs-bullmq?id=1" -H "authorization: Bearer $TOKEN"
# {"id":"1","state":"completed","result":{"square":144,"by":"ann","worker":...}}2. A SQLite queue and a worker (no Redis)
Node 24 ships node:sqlite, so there is nothing to install. The route inserts a row; the worker claims one waiting row with a single UPDATE ... RETURNING, runs it and writes the result back. WAL mode and a busy timeout let the app and the worker share the file.
The API route
import { apiRoute, HttpError } from "@devorajs/core";
import { DatabaseSync } from "node:sqlite";
// A queue with no Redis: a SQLite file (node:sqlite, built into Node). POST enqueues a row;
// a separate worker (lab-scripts/worker-sqlite.mjs) claims rows and writes the result back.
const db = new DatabaseSync(process.env.JOBS_DB ?? "lab-data/jobs.db");
db.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS jobs (id INTEGER PRIMARY KEY AUTOINCREMENT, payload TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'waiting', result TEXT, created INTEGER NOT NULL)");
export const methods = ["GET", "POST"];
export const handler = apiRoute(async (req, ctx) => {
ctx.requireAuth();
if (req.method === "POST") {
const { n } = JSON.parse(req.body.toString("utf-8") || "{}");
if (typeof n !== "number") throw new HttpError(400, "n must be a number");
const r = db.prepare("INSERT INTO jobs (payload, created) VALUES (?, ?)").run(JSON.stringify({ n }), Date.now());
return json(202, { id: Number(r.lastInsertRowid) });
}
const id = Number(new URL(req.url, "http://x").searchParams.get("id"));
const row = db.prepare("SELECT id, state, result FROM jobs WHERE id = ?").get(id) as { id: number; state: string; result: string | null } | undefined;
if (!row) throw new HttpError(404, "no such job");
return json(200, { id: row.id, state: row.state, result: row.result ? JSON.parse(row.result) : null });
});
function json(status: number, body: unknown) {
return { status, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) };
}The worker
import { DatabaseSync } from "node:sqlite";
const db = new DatabaseSync(process.env.JOBS_DB ?? "lab-data/jobs.db");
db.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS jobs (id INTEGER PRIMARY KEY AUTOINCREMENT, payload TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'waiting', result TEXT, created INTEGER NOT NULL)");
console.log("sqlite worker up, pid", process.pid);
setInterval(() => {
// Claim one waiting job atomically, run it, store the result.
const job = db.prepare("UPDATE jobs SET state='active' WHERE id = (SELECT id FROM jobs WHERE state='waiting' ORDER BY id LIMIT 1) RETURNING id, payload").get();
if (!job) return;
const { n } = JSON.parse(job.payload);
db.prepare("UPDATE jobs SET state='completed', result=? WHERE id=?").run(JSON.stringify({ square: n ** 2, worker: process.pid }), job.id);
console.log("completed", job.id);
}, 200);Run it
npx devora build --app=lab
PORT=4501 npx devora start --app=lab # terminal 1: the app
node lab-scripts/worker-sqlite.mjs # terminal 2: the worker (same folder, same jobs.db)
curl -s -X POST localhost:4501/api/jobs-sqlite -H "authorization: Bearer $TOKEN" -d '{"n":12}'
# {"id":1}
curl -s "localhost:4501/api/jobs-sqlite?id=1" -H "authorization: Bearer $TOKEN"
# {"id":1,"state":"completed","result":{"square":144,"worker":...}}3. node-cron inside the server
node-cron runs a function on a schedule inside the same process. There is no start-up hook in devora start, so the schedule is registered when the module that contains it is first imported: for a built route that is the first request to it. If the schedule must run from boot, hit the route once at start-up, or run the scheduler as its own process.
import { apiRoute } from "@devorajs/core";
import cron from "node-cron";
import { appendFileSync, existsSync, readFileSync } from "node:fs";
// node-cron inside the server process. The schedule is registered when this module is first
// imported, which for a built route is the first request to /api/cron-status (there is no start-up hook).
const LOG = "lab-data/cron.log";
const startedAt = Date.now();
cron.schedule("* * * * * *", () => appendFileSync(LOG, `tick ${new Date().toISOString()}\n`));
export const methods = ["GET"];
export const handler = apiRoute(() => {
const lines = existsSync(LOG) ? readFileSync(LOG, "utf8").trim().split("\n").length : 0;
return { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ registeredMsAgo: Date.now() - startedAt, ticksSoFar: lines, pid: process.pid }) };
});npx devora build --app=lab && PORT=4501 npx devora start --app=lab
ls lab-data/cron.log # not there yet: nothing has imported the route
curl -s localhost:4501/api/cron-status # first request: the schedule is registered now
sleep 4; curl -s localhost:4501/api/cron-status
# {"registeredMsAgo":3528,"ticksSoFar":4,"pid":...}A schedule inside the server runs once per server process. Two instances run it twice; use a lock, or the queue patterns above, if that matters.
What was verified, and what was not
Tested with 0.3.4, one local Node server
- Enqueueing without a login answers 401; with a Bearer token it answers 202 and an id.
- BullMQ: the worker ran the job and the route returned
completedwith the result (about 330 ms). The Redis was a localredis-server. - SQLite: the same, about 100 to 220 ms.
- node-cron: no
cron.logbefore the first request; four ticks in the next 3.5 seconds.
Not verified
- Vercel and Netlify: nothing was deployed. The enqueue side can reach a hosted Redis over the network; the worker needs another host. A SQLite file on a function's disk is not shared or durable. Use the platform's own scheduled functions instead of node-cron.
- Retries, failure handling, several workers and many jobs were not tested.
- A production Redis, and memory use of a worker over time.
For pushing a result to the browser when a job finishes, see Live updates (WebSockets).