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 forComplex multi-step workflowsSimple deferred tasks
StepsYes — each step retried independentlyNo — job is atomic
Fan-outYes — one event triggers many functionsNo
ObservabilityFull UI, traces, replayBasic logging
Scheduled jobsYes (cron syntax)No (use Vercel cron or Inngest)
Self-hostedNo (managed)Yes (BullMQ + Redis)
ServerlessYesYes (QStash) or No (BullMQ)
ThroughputMediumHigh

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/:type

Common 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=""

How is this guide?

Edit on GitHub

Last updated on

On this page