Tasks
Run background work with guards, type-safe input, and automatic scheduling.
A task is a piece of work that runs in the background. Send an email, generate a report, clean up old data — anything you don't want blocking your API response.
Tasks use the same builder chain as procedures — $use, $input, $errors all work. The only difference: $task() instead of $resolve().
Tasks handle define, dispatch, and schedule. For queues, retries, and orchestration, pair with Trigger.dev, BullMQ, or pg-boss.
Defining a task
Use the builder chain, ending with $task():
const sendEmail = s
.$use(auth)
.$input(z.object({ to: z.string().email(), subject: z.string() }))
.$task({
name: 'send-email',
resolve: async ({ input, ctx }) => {
await ctx.mailer.send(input.to, input.subject)
return { sent: true }
},
})Everything before $task() works the same as procedures:
$use(guard)— adds guards, enrichesctx$input(schema)— validates input$errors({ ... })— typed error codes$route({ ... })— OpenAPI metadata
A task without input or guards:
const prune = s.$task({
name: 'prune-logs',
resolve: async ({ ctx }) => {
const count = await ctx.db.logs.deleteOlderThan('30d')
return { deleted: count }
},
})Dispatching
Call .dispatch() — input is validated, context is created:
await sendEmail.dispatch({ to: '[email protected]', subject: 'Welcome' })From a procedure — pass { ctx } for tracing
When you dispatch a task from inside a procedure, pass ctx as the second argument. This automatically adds a span to the parent request's trace timeline:
const createUser = s.$input(z.object({ name: z.string(), email: z.string() })).$resolve(async ({ input, ctx }) => {
const user = await ctx.db.users.create(input)
sendEmail.dispatch({ to: input.email, subject: 'Welcome' }, ctx)
return user
})The analytics dashboard shows the task as a queue span on the request detail:
users/create — 2 spans, 4.2ms total
├─ db.users.create (db) 1.1ms
└─ task:send-email (queue) 92.3msctx is optional. Without it, the task still runs and tracks analytics, but no span appears on the parent request.
Mounting on router
Tasks mount directly — guards run when called via HTTP:
const router = s.router({
users: { create: createUser },
tasks: {
sendEmail, // POST /tasks/sendEmail — auth guard runs
prune, // POST /tasks/prune — no guard
},
})Cron scheduling
Add cron to run a task on a schedule:
const dailyReport = s.$task({
name: 'daily-report',
cron: '0 9 * * *',
description: 'Generate daily sales report',
resolve: async ({ ctx }) => {
return await ctx.db.reports.generateDaily()
},
})Put it in the router and call serve() — cron starts automatically:
const router = s.router({ tasks: { dailyReport } })
await s.serve(router)$input() with them.| Pattern | Meaning |
|---|---|
0 9 * * * | Every day at 9:00 AM |
0 */6 * * * | Every 6 hours |
*/10 * * * * | Every 10 minutes |
0 0 * * 0 | Every Sunday at midnight |
Analytics
Every dispatch is tracked in the analytics dashboard:
- Task list — runs, errors, avg duration
- Scheduled tasks — cron expression, next/last run
- Execution detail — click for input, output, error, trace spans
Prevent duplicate runs
runTask() ensures one instance at a time:
import { runTask } from 'silgi'
const [a, b] = await Promise.all([runTask(prune), runTask(prune)])
// Runs once — a === b