Advanced
Queue
Run slow work in the background with VitNode's PostgreSQL queue: define a typed task, dispatch it from a route and follow it through retries and leases.
The queue moves work out of the request. A route stores a task in the core_queue table and answers at once, and a background worker runs it a little later, retrying it if it fails. In this guide the site-notes plugin queues an announcement for a note and you watch the task complete.
Before you begin
- A queue processor. Tasks run only when core's
process-queuecron job runs, every minute. Configure a cron adapter or an external trigger. During development you can trigger it by hand, as shown in Check the result. - The
site-notesplugin with itssite_notestable and the Create notes permission, from the beginner path up to Permissions. Any plugin with a top-level API module and a guarded route works the same way.
Create the task
Create src/api/tasks/announce-note.task.ts. buildQueueTask comes from the plugin's src/api/builders.ts, which create-vitnode-app writes with every builder bound to your plugin id:
import { eq } from "drizzle-orm";
import { z } from "zod";
import { buildQueueTask } from "@/api/builders";
import { site_notes } from "@/database/notes";
export const announceNoteTask = buildQueueTask({
name: "announce-note",
payload: z.object({ noteId: z.number().int().positive() }),
maxAttempts: 5,
handler: async (c, { noteId }) => {
const [note] = await c
.get("db")
.select({ title: site_notes.title })
.from(site_notes)
.where(eq(site_notes.id, noteId));
if (!note) return;
await c.get("log").debug(`Announced note "${note.title}"`);
},
});payloadis the task's contract. Every task has one; usez.object({})for a task without data.dispatch()type-checks against the schema's input and validates before inserting; the handler receives the schema's output, parsed again just before it runs.noteIdis anumberhere, with no cast.- The task belongs to
site-notes. The bound builder stamps the plugin id on it, so its rows are stored undersite-noteswhichever route, listener or cron job dispatches it. - The payload is stored as JSON. The schema's input must be JSON, so use
z.iso.datetime()rather thanz.date(). See JSON payloads. - Returning completes the task. Throwing fails the attempt and schedules a retry. A note deleted since it was queued returns early: there is nothing to announce, and a retry would not change that.
nameis unique within the plugin. Stored rows are matched to the handler by plugin id and name, so keep both stable once tasks are queued. Change a task payload covers evolving the schema.c.get("log").debug()writes to the console and to the Debug Panel's log explorer.
Register it on a top-level module
Add the task to the queueTasks of notesModule, which config.api.ts passes to buildApiPlugin directly:
import { buildModule } from "@/api/builders";
import { announceNoteTask } from "@/api/tasks/announce-note.task";
import { listNotesRoute } from "./routes/list.route";
import { showNoteRoute } from "./routes/show.route";
export const notesModule = buildModule({
name: "notes",
routes: [listNotesRoute, showNoteRoute],
queueTasks: [announceNoteTask],
});Queue tasks are read only from modules passed to buildApiPlugin. Declared on a nested module, such as admin/notes, a task is never registered, and dispatching it throws QueueTaskRegistrationError. buildApiPlugin throws in development to say so, and vitnode doctor reports it.
Dispatch it from a route
Create src/api/modules/admin/notes/routes/announce.route.ts. It queues the task and returns what dispatch() reported:
import { z } from "@hono/zod-openapi";
import { buildRoute } from "@/api/builders";
import { announceNoteTask } from "@/api/tasks/announce-note.task";
import { staffPermissions } from "@/staff-permissions";
export const announceNoteRoute = buildRoute({
adminStaffPermission: staffPermissions.admin.notes.canCreate,
route: {
method: "post",
path: "/{id}/announce",
request: {
params: z.object({ id: z.coerce.number().int().positive() }),
},
responses: {
202: {
content: {
"application/json": {
schema: z.object({
deduplicated: z.boolean(),
taskId: z.number().nullable(),
}),
},
},
description: "The announcement is queued, or already waiting.",
},
},
},
handler: async c => {
const { id } = c.req.valid("param");
const { deduplicated, id: taskId } = await c
.get("queue")
.dispatch(
announceNoteTask,
{ noteId: id },
{ dedupeKey: `announce-note:${id}` },
);
return c.json({ deduplicated, taskId }, 202);
},
});Then add announceNoteRoute to the routes of adminNotesModule in src/api/modules/admin/notes/notes.module.ts, after createNoteRoute.
dispatch(task, payload, options) only inserts a row and returns { deduplicated, id }. It never runs the handler. A missing or mistyped field is a compile error, and a value the schema rejects at runtime throws QueuePayloadValidationError before anything is stored. The task's name and plugin come from the definition, so a listener or cron job running in core's request still queues the task under site-notes.
Check the result
-
Add a temporary button that queues an announcement for note
3withfetcher. Createsrc/pages/check-announce-note.tsx:plugins/site-notes/src/pages/check-announce-note.tsx import { Button } from "@vitnode/core/components/ui/button"; import { fetcher } from "@vitnode/core/tanstack/fetcher"; import { useState } from "react"; import { manifest } from "@/manifest.gen"; export const CheckAnnounceNote = () => { const [result, setResult] = useState(""); const [isPending, setIsPending] = useState(false); const onClick = async () => { setIsPending(true); try { const response = await fetcher({ plugin: manifest.id, method: "post", module: "admin/notes", path: "/{id}/announce", args: { params: { id: 3 } }, }); setResult( response.ok ? JSON.stringify(await response.json()) : String(response.status), ); } finally { setIsPending(false); } }; return ( <div className="flex items-center gap-3"> <Button isLoading={isPending} onClick={onClick}> Announce note 3 </Button> {result ? <output>{result}</output> : null} </div> ); };Render
<CheckAnnounceNote />insrc/pages/notes-page.tsx, sign in to the AdminCP in the same browser, open/site-notes/notesand press the button twice. The first press shows{"deduplicated":false,"taskId":ā¦}with the new row's id. The second shows{"deduplicated":true,"taskId":null}: it is dropped because a task with the samededupeKeyis still waiting. -
Open Debug Panel ā Queue (
/admin/core/debug/queue). Theannounce-notetask fromsite-notesis pending, because nothing has run it yet. -
Run the processor. With a cron adapter, wait for the next minute. During development you can trigger cron yourself;
CRON_SECRETis in your.env:curl -X POST http://localhost:3000/api/@vitnode/core/cron \ -H "Authorization: Bearer $CRON_SECRET" \ -H "Content-Type: application/json"It answers
Cron jobs processed. Executed N jobs. -
Reload Debug Panel ā Queue. The task is completed, with
1/5attempts. Debug Panel ā Log explorer has the debug entryAnnounced note "New coffee machine". It is listed under@vitnode/core, because the worker runs outside any plugin's route. RemoveCheckAnnounceNotewhen you are done.

Run a task later
availableAt is the earliest time the task may run. The default is now. This dispatch runs the announcement in an hour at the earliest:
await c.get("queue").dispatch(
announceNoteTask,
{ noteId: id },
{ availableAt: new Date(Date.now() + 60 * 60 * 1000) },
);The task still waits for the next processor run after that time. priority (higher first, default 0) orders tasks that are due at the same time.
Retries and backoff
A thrown error fails the attempt. Until maxAttempts is reached, the task returns to pending with an exponential delay. The first retry waits about 10 seconds, and each later one waits twice as long, up to one hour. After the last attempt it is failed, with the error in lastError.
maxAttempts comes from the dispatch, then the task definition, then 3. To wait without using an attempt, for example on a rate limit, throw QueueDeferError from @vitnode/core/api/lib/queue:
throw new QueueDeferError(new Date(Date.now() + 60_000), "Provider rate limit");A failed task stays failed. Fix the cause and dispatch again.
A stored payload the schema rejects, for example a row queued before an incompatible schema change, fails at once without a retry, because no attempt can fix stored data. See What happens to an invalid stored payload.
Leases and abandoned tasks
A worker that claims a task holds it for the task's leaseSeconds, 600 by default. If the worker dies, or the handler is still running when the lease ends, the task counts as abandoned. It returns to pending, or becomes failed when it has no attempts left, and another run picks it up.
Keep handlers well inside their lease, or raise leaseSeconds on the task. A handler that outlives its lease may run twice at once. For large jobs, save progress and dispatch a follow-up task, as notification fan-out does.
Deduplication
While a task with the same plugin id and dedupeKey is pending or processing, a new dispatch is dropped and returns { deduplicated: true, id: null }. Once the first task completes or fails, the same key can be queued again. Without a dedupeKey, every dispatch creates a task.
Enqueue inside a transaction
Pass tx to insert the task with the transaction that writes your data, so it is only queued if that write commits:
await c.get("db").transaction(async tx => {
const [note] = await tx.insert(site_notes).values(values).returning();
if (!note) throw new Error("Inserting the note returned no row.");
await c.get("queue").dispatch(announceNoteTask, { noteId: note.id }, { tx });
});Without tx, a task dispatched before a rollback would still run, for a note that was never stored. Write a complete operation shows the whole pattern, with the cache and events after the commit.
How the worker runs
Each process-queue run claims tasks in batches of 25, runs up to 5 at once, highest priority first, and keeps draining for about 45 seconds. Tasks on the ai queue are drained by core's AI job instead. Completed and failed tasks stay in core_queue for 7 days; the hourly cleanup removes older ones.
Debug Panel ā Queue lists every task with its queue, status, attempts, next run, creation time and last error, filterable by status. It is read-only and needs the queue ā can_view staff permission.
Every option, type and error is listed in the queue reference.
Related
- Change a task payload: evolve a task's schema without breaking queued rows.
- Cron: recurring jobs, and the adapters that run the queue.
- Events: react to something that happened, inside the request.