Skip to main content

Inngest vs Trigger.dev vs QStash: Serverless Jobs 2026

·PkgPulse Team

Inngest vs Trigger.dev v3 vs QStash: Serverless Jobs 2026

TL;DR

Serverless functions time out — but many tasks take minutes or hours. Background job frameworks handle long-running work, retries, and scheduled tasks outside the request lifecycle. Inngest is the event-driven step functions platform — define multi-step workflows in TypeScript, each step is retried independently, local dev server included, works in any serverless environment. Trigger.dev v3 is the TypeScript background jobs framework — deploys workers to their own infrastructure, supports realtime progress updates, and lets you write long-running tasks as plain async TypeScript functions. Upstash QStash is the HTTP-based message queue — the most lightweight option, sends HTTP requests to your endpoints with guaranteed delivery and scheduling, ideal for serverless apps already on Upstash. For complex multi-step workflows with local dev: Inngest. For long-running background tasks that need dedicated worker infrastructure: Trigger.dev. For simple scheduled HTTP calls and queue-based webhooks: QStash.

Key Takeaways

  • Inngest has a local dev servernpx inngest-cli@latest dev mirrors production locally
  • Trigger.dev v3 deploys workers — your code runs in isolated containers, not on your app server
  • QStash is HTTP-native — sends POST requests to any URL; no SDK required on the receiver
  • Inngest supports steps — each step is independently retried without replaying previous steps
  • Trigger.dev supports realtime — subscribe to job progress via useRealtimeRun React hook
  • QStash has dead letter queues — failed messages go to a DLQ for inspection
  • All three integrate with Next.js App Router — route handlers as endpoints

The Serverless Job Problem

Serverless function limits:
  Vercel (Hobby):  10 second timeout
  Vercel (Pro):    300 second timeout
  Lambda:          15 minute max

Jobs that break these limits:
  - Send 1,000 emails (slow SMTP)
  - Process large file upload (resize 50 images)
  - Generate AI report (LLM call chain)
  - Import 10,000 CSV rows into database
  - Send webhooks with retries

Solution: Background job frameworks
  Inngest / Trigger.dev: Long-running + retries + steps
  QStash: Queue-based + scheduled HTTP
  BullMQ: Redis-backed (requires always-on server)

Inngest: Event-Driven Step Functions

Inngest routes events to functions that run step-by-step. Each step is checkpointed — if a step fails, only that step retries (not the entire function from the start).

Installation

npm install inngest

Basic Setup (Next.js App Router)

// app/api/inngest/route.ts
import { serve } from "inngest/next";
import { inngest } from "@/inngest/client";
import { sendWelcomeEmail } from "@/inngest/functions/send-welcome-email";
import { processUpload } from "@/inngest/functions/process-upload";
import { generateReport } from "@/inngest/functions/generate-report";

export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: [sendWelcomeEmail, processUpload, generateReport],
});
// inngest/client.ts
import { Inngest } from "inngest";

export const inngest = new Inngest({ id: "my-app" });

Defining Functions with Steps

// inngest/functions/send-welcome-email.ts
import { inngest } from "@/inngest/client";
import { sendEmail } from "@/lib/email";
import { db } from "@/lib/db";

export const sendWelcomeEmail = inngest.createFunction(
  {
    id: "send-welcome-email",
    name: "Send Welcome Email",
    retries: 3,
    throttle: {
      count: 100,
      period: "1m",   // Max 100 per minute
    },
  },
  { event: "user/signed-up" },

  async ({ event, step }) => {
    const { userId, email, name } = event.data;

    // Step 1: Fetch user preferences (retried independently if fails)
    const preferences = await step.run("fetch-preferences", async () => {
      return db.userPreferences.findFirst({ where: { userId } });
    });

    // Step 2: Send welcome email
    await step.run("send-email", async () => {
      await sendEmail({
        to: email,
        template: "welcome",
        data: {
          name,
          language: preferences?.language ?? "en",
        },
      });
    });

    // Step 3: Wait 3 days, then send onboarding tips
    await step.sleep("wait-for-onboarding", "3d");

    await step.run("send-onboarding-tips", async () => {
      const user = await db.users.findUnique({ where: { id: userId } });
      if (user?.completedOnboarding) return;  // Skip if already done

      await sendEmail({
        to: email,
        template: "onboarding-tips",
        data: { name },
      });
    });
  }
);

Triggering Events

// In your API route or server action
import { inngest } from "@/inngest/client";

// Trigger after user registration
await inngest.send({
  name: "user/signed-up",
  data: {
    userId: user.id,
    email: user.email,
    name: user.name,
  },
});

// Trigger a bulk processing job
await inngest.send({
  name: "import/csv-uploaded",
  data: {
    fileUrl: uploadedFileUrl,
    userId: user.id,
    rowCount: 5000,
  },
});

Multi-Step AI Workflow

export const generateReport = inngest.createFunction(
  { id: "generate-ai-report", retries: 2, timeout: "30m" },
  { event: "report/requested" },

  async ({ event, step }) => {
    const { reportId, userId, topic } = event.data;

    // Step 1: Gather data (web scraping / database queries)
    const rawData = await step.run("gather-data", async () => {
      return await gatherTopicData(topic);
    });

    // Step 2: Generate with LLM (separate retry scope)
    const report = await step.run("generate-with-llm", async () => {
      return await generateWithClaude({
        prompt: buildReportPrompt(topic, rawData),
        maxTokens: 4000,
      });
    });

    // Step 3: Save and notify
    await step.run("save-report", async () => {
      await db.reports.update({
        where: { id: reportId },
        data: { content: report, status: "complete" },
      });
      await notifyUser(userId, reportId);
    });

    return { reportId, wordCount: report.split(" ").length };
  }
);

Local Development

# Start the Inngest dev server
npx inngest-cli@latest dev

# Opens UI at http://localhost:8288
# All events and function runs visible locally
# No cloud connection needed for development

Trigger.dev v3: Background Jobs with Worker Infrastructure

Trigger.dev v3 deploys your tasks to dedicated worker processes — not running inside your Next.js server. This means no timeout limits, isolated execution, and realtime progress tracking.

Installation

npm install @trigger.dev/sdk@v3
npx trigger.dev@latest init

Defining Tasks

// trigger/send-welcome.ts
import { task, logger } from "@trigger.dev/sdk/v3";
import { sendEmail } from "@/lib/email";
import { db } from "@/lib/db";

export const sendWelcomeEmailTask = task({
  id: "send-welcome-email",
  retry: {
    maxAttempts: 3,
    factor: 2,
    minTimeoutInMs: 1000,
    maxTimeoutInMs: 30000,
  },

  run: async (payload: { userId: string; email: string; name: string }) => {
    logger.info("Sending welcome email", { userId: payload.userId });

    // No step system — just plain async code
    // Trigger.dev handles timeouts and retries at the task level
    const preferences = await db.userPreferences.findFirst({
      where: { userId: payload.userId },
    });

    await sendEmail({
      to: payload.email,
      template: "welcome",
      data: { name: payload.name },
    });

    logger.info("Welcome email sent");
    return { emailId: `email_${Date.now()}` };
  },
});

Triggering Tasks

// In your API route
import { sendWelcomeEmailTask } from "@/trigger/send-welcome";

// Trigger a task (fire and forget)
const handle = await sendWelcomeEmailTask.trigger({
  userId: user.id,
  email: user.email,
  name: user.name,
});

// Trigger and wait for result (if within a task)
const result = await sendWelcomeEmailTask.triggerAndWait({
  userId: user.id,
  email: user.email,
  name: user.name,
});

Realtime Progress Updates

// trigger/process-import.ts
import { task, logger, metadata } from "@trigger.dev/sdk/v3";

export const processImportTask = task({
  id: "process-csv-import",
  machine: { preset: "medium-2x" },  // More CPU/RAM for heavy processing

  run: async (payload: { fileUrl: string; rowCount: number }) => {
    const rows = await downloadAndParseCsv(payload.fileUrl);

    for (let i = 0; i < rows.length; i++) {
      await processRow(rows[i]);

      // Update progress metadata — visible in realtime
      if (i % 100 === 0) {
        metadata.set("progress", {
          processed: i,
          total: rows.length,
          percentage: Math.round((i / rows.length) * 100),
        });
      }
    }

    return { processedRows: rows.length };
  },
});
// React component — realtime progress updates
import { useRealtimeRun } from "@trigger.dev/react-hooks";

function ImportProgress({ runId }: { runId: string }) {
  const { run } = useRealtimeRun(runId);

  const progress = run?.metadata?.progress;

  return (
    <div>
      <p>Status: {run?.status}</p>
      {progress && (
        <ProgressBar
          value={progress.percentage}
          label={`${progress.processed} / ${progress.total} rows`}
        />
      )}
    </div>
  );
}

Upstash QStash: HTTP Message Queue

QStash is the lightest option — it sends HTTP POST requests to your endpoints on a schedule or via queue, with guaranteed delivery, retries, and dead letter queues.

Installation

npm install @upstash/qstash

Publishing Messages

import { Client } from "@upstash/qstash";

const qstash = new Client({ token: process.env.QSTASH_TOKEN! });

// Queue a message — QStash calls your endpoint
const result = await qstash.publish({
  url: "https://yourapp.com/api/jobs/send-email",
  body: JSON.stringify({
    userId: user.id,
    email: user.email,
    template: "welcome",
  }),
  headers: {
    "Content-Type": "application/json",
  },
  retries: 3,
  delay: "5s",   // Delay delivery by 5 seconds
});

console.log("Message ID:", result.messageId);

Scheduled Jobs (Cron)

// Create a recurring scheduled job
const schedule = await qstash.schedules.create({
  destination: "https://yourapp.com/api/jobs/daily-digest",
  cron: "0 9 * * *",    // Every day at 9 AM UTC
  body: JSON.stringify({ type: "daily_digest" }),
  headers: { "Content-Type": "application/json" },
  retries: 2,
});

console.log("Schedule ID:", schedule.scheduleId);

Receiving and Verifying Messages

// api/jobs/send-email/route.ts
import { verifySignatureAppRouter } from "@upstash/qstash/nextjs";
import { NextRequest, NextResponse } from "next/server";

async function handler(req: NextRequest) {
  const body = await req.json();
  const { userId, email, template } = body;

  await sendEmail({ to: email, template });

  return NextResponse.json({ success: true });
}

// Wraps handler with signature verification
export const POST = verifySignatureAppRouter(handler);

URL Groups (Fan-out)

// Send one message to multiple endpoints simultaneously
await qstash.publish({
  urlGroup: "notifications",   // Pre-configured group
  body: JSON.stringify({ event: "new_signup", userId: user.id }),
});

// All endpoints in the group receive the message
// Good for: audit logging + notifications + analytics all at once

Feature Comparison

FeatureInngestTrigger.dev v3QStash
Step functions❌ (task-level only)
Worker infrastructureOn your servers✅ Dedicated❌ (HTTP endpoints)
Local dev server
Realtime progress
Cron scheduling
Fan-out (URL groups)
DLQ
Max job durationHoursUnlimited30 min
Cold startMinimalContainer spin-upNone (HTTP)
Requires SDK❌ (receiver optional)
Self-hostable❌ (Cloud)✅ v3 open-source❌ (Upstash only)
Free tier50k runs/month5k runs/month500 messages/day

When to Use Each

Choose Inngest if:

  • Multi-step workflows with independent retry scoping are needed
  • Local development environment that mirrors production is important
  • Event-driven fan-out (one event triggers multiple functions) is useful
  • Existing Next.js / serverless architecture without adding new infrastructure

Choose Trigger.dev v3 if:

  • Tasks genuinely need hours to complete (Inngest has practical limits)
  • Realtime progress updates to the UI during long jobs are required
  • Isolated worker environments (separate from app server CPU/memory) matter
  • Open-source self-hosted deployment is a requirement

Choose QStash if:

  • You're already on Upstash (Redis or Kafka) and want a unified stack
  • Simple HTTP-based queue with no SDK on the job processor is preferred
  • Scheduled HTTP calls (cron webhooks) to external services are the main use case
  • Minimal latency and minimal infrastructure complexity are priorities

Methodology

Data sourced from official Inngest documentation (inngest.com/docs), Trigger.dev v3 documentation (trigger.dev/docs), Upstash QStash documentation (upstash.com/docs/qstash), GitHub star counts as of February 2026, npm download statistics, and community discussions from the Inngest Discord, Trigger.dev Discord, and r/nextjs.


Related: BullMQ vs bee-queue vs pg-boss for Redis-backed job queues that run on traditional servers, or Temporal vs Restate vs Windmill for enterprise-grade durable workflow orchestration.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.