Background Jobs Overview
Run work outside the request cycle — Inngest for complex workflows and @nebutra/queue for simple task queues.
Why background jobs?
Some tasks are too slow, too risky, or too expensive to run synchronously inside an HTTP request:
- Sending emails after sign-up
- Generating large reports
- Syncing data to third-party services
- Processing file uploads
- Sending quota-warning notifications
- Scheduled cleanups and resets
Nebutra ships two systems for this, each optimized for different workload shapes.
Inngest vs Queue — when to use each
Inngest (@nebutra/event-bus) | Queue (@nebutra/queue) | |
|---|---|---|
| Best for | Complex multi-step workflows | Simple deferred tasks |
| Steps | Yes — each step retried independently | No — job is atomic |
| Fan-out | Yes — one event triggers many functions | No |
| Observability | Full UI, traces, replay | Basic logging |
| Scheduled jobs | Yes (cron syntax) | No (use Vercel cron or Inngest) |
| Self-hosted | No (managed) | Yes (BullMQ + Redis) |
| Serverless | Yes | Yes (QStash) or No (BullMQ) |
| Throughput | Medium | High |
Rule of thumb: if your job has more than one step, needs retry granularity, or you want to replay it from a UI — use Inngest. If you just need to defer a fast task or process a high-volume queue — use @nebutra/queue.
Architecture
Event emitter / API gateway
│
├─── inngest.send(event) ──────► Inngest Cloud
│ │
│ Inngest function
│ (multi-step, retries)
│ │
│ POST /api/v1/inngest
│ (your API gateway)
│
└─── queue.enqueue(job) ───────► QStash (serverless)
or BullMQ (Redis)
│
queue.registerHandler()
POST /api/v1/queue/:queue/:typeCommon use cases
Welcome email on sign-up
// Emit from your auth callback
await inngest.send({
name: "user/signed-up",
data: { userId: user.id },
});
// Handled by a durable Inngest function with retries
export const sendWelcomeEmail = inngest.createFunction(
{ id: "send-welcome-email", retries: 3 },
{ event: "user/signed-up" },
async ({ event, step }) => {
const user = await step.run("fetch-user", () =>
db.user.findUnique({ where: { id: event.data.userId } })
);
await step.run("send-email", () =>
email.send({ to: user.email, template: "welcome" })
);
}
);Monthly report generation
// Enqueue a report job (runs async, returns immediately)
const queue = await getQueue();
await queue.enqueue(
createJob("report", "generate", {
tenantId: "org_123",
reportType: "monthly",
periodStart: "2025-03-01",
})
);Quota warning notification
// Triggered by metering when usage crosses 80%
await inngest.send({
name: "quota/threshold-reached",
data: { tenantId: "org_123", meter: "ai_tokens", percentage: 0.8 },
});Data export
// Fan-out: one event triggers export + email notification in parallel
await inngest.send({
name: "export/requested",
data: { tenantId: "org_123", exportId: "exp_abc", format: "csv" },
});Environment variables
# Inngest
INNGEST_EVENT_KEY="" # Secret key for sending events
INNGEST_SIGNING_KEY="" # Validates Inngest → your server requests
# QStash (serverless queue)
QSTASH_TOKEN=""
QSTASH_CURRENT_SIGNING_KEY=""
QSTASH_NEXT_SIGNING_KEY=""
QSTASH_CALLBACK_BASE_URL="" # e.g. https://api.yourdomain.com
# BullMQ (self-hosted queue — reuses REDIS_URL)
REDIS_URL=""Related
How is this guide?
Edit on GitHub
Last updated on