Skip to main content

Background Jobs in SaaS: Inngest vs BullMQ vs Trigger.dev in 2026

·StarterPick Team
inngestbullmqtrigger-devbackground-jobs2026

TL;DR

Inngest for Vercel-hosted Next.js apps — serverless-native, no Redis required, great DX. BullMQ for apps on Railway/Render with a persistent Redis instance — battle-tested, familiar API. Trigger.dev for complex job orchestration, fan-out, and team-facing job visibility. Most indie SaaS starts with Inngest.

Why Background Jobs Matter

Every SaaS hits the same pattern: an action takes too long for a synchronous HTTP response.

User signs up → Send welcome email                     ← 200-500ms, okay
User upgrades → Process invoice + notify team          ← 500ms+, getting slow
User exports data → Generate PDF/CSV                   ← 5-60s, must be async
User uploads video → Transcode to multiple formats     ← Minutes, definitely async

Background jobs move long-running work out of the request/response cycle.


Inngest: Serverless-Native Jobs

Inngest is designed for serverless environments. No Redis, no persistent server — jobs are triggered via HTTP and executed as serverless functions.

// lib/inngest.ts
import { Inngest } from 'inngest';

export const inngest = new Inngest({ id: 'my-saas' });

// Define a function
export const sendWelcomeEmail = inngest.createFunction(
  { id: 'send-welcome-email' },
  { event: 'user/signed.up' },
  async ({ event, step }) => {
    // step.run() — each step is retried independently on failure
    const user = await step.run('get-user', async () => {
      return prisma.user.findUnique({ where: { id: event.data.userId } });
    });

    await step.run('send-email', async () => {
      await resend.emails.send({
        to: user!.email,
        subject: 'Welcome!',
        react: <WelcomeEmail name={user!.name} />,
      });
    });

    // Sleep for 3 days, then check if user onboarded
    await step.sleep('wait-for-onboarding', '3 days');

    const updatedUser = await step.run('check-onboarding', async () => {
      return prisma.user.findUnique({ where: { id: event.data.userId } });
    });

    if (!updatedUser?.onboardedAt) {
      await step.run('send-onboarding-nudge', async () => {
        await resend.emails.send({
          to: updatedUser!.email,
          subject: 'Haven't finished setup yet?',
          react: <OnboardingNudgeEmail />,
        });
      });
    }
  }
);
// app/api/inngest/route.ts — Inngest handler
import { serve } from 'inngest/next';
import { inngest, sendWelcomeEmail } from '@/lib/inngest';

export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: [sendWelcomeEmail],
});
// Trigger from API route — fire and forget
await inngest.send({ name: 'user/signed.up', data: { userId: user.id } });

Inngest Strengths

  • No infrastructure: No Redis, no persistent worker — Inngest Cloud handles it
  • Step-level retries: Each step.run() retries independently. If step 3 fails, steps 1-2 don't re-run.
  • Sleep / scheduling: step.sleep() and step.sleepUntil() for delayed actions
  • Observable: Inngest dashboard shows every job run, step, and failure
  • Vercel-native: Scales with your serverless functions

Inngest Limitations

  • Cold starts: Serverless functions have cold start latency
  • Rate limits on free tier (50k function runs/month)
  • Less control over concurrency than BullMQ
  • Requires Inngest Cloud (or self-hosted) for job tracking

BullMQ: Battle-Tested Redis Queues

BullMQ is the most mature Node.js job queue. Uses Redis as a broker, runs as persistent workers.

// lib/queues.ts — queue definitions
import { Queue, Worker, QueueEvents } from 'bullmq';
import { Redis } from 'ioredis';

const connection = new Redis(process.env.REDIS_URL!, {
  maxRetriesPerRequest: null,
});

export const emailQueue = new Queue('emails', { connection });
export const pdfQueue = new Queue('pdf-generation', { connection });

// Worker — runs as a separate process on Railway/Render
const emailWorker = new Worker(
  'emails',
  async (job) => {
    switch (job.name) {
      case 'welcome':
        await sendWelcomeEmail(job.data.userId);
        break;
      case 'password-reset':
        await sendPasswordResetEmail(job.data.userId, job.data.token);
        break;
      default:
        throw new Error(`Unknown job type: ${job.name}`);
    }
  },
  {
    connection,
    concurrency: 20,  // Process 20 jobs simultaneously
    limiter: {
      max: 100,
      duration: 1000,  // Max 100 jobs per second (rate limit)
    },
  }
);

// Priority queues
await emailQueue.add('welcome', { userId }, {
  priority: 1,  // Lower = higher priority
  attempts: 3,
  backoff: { type: 'exponential', delay: 2000 },
});

// Scheduled/cron jobs
await emailQueue.add(
  'weekly-digest',
  {},
  { repeat: { cron: '0 9 * * 1' } }  // Every Monday 9am
);

BullMQ Strengths

  • Mature, battle-tested (10+ years of history)
  • Powerful job prioritization, rate limiting, concurrency control
  • Cron scheduling built-in
  • Excellent for high-volume job processing
  • Bull Board UI for monitoring

BullMQ Limitations

  • Requires Redis (Upstash doesn't support BullMQ — need Railway/Render Redis)
  • Requires persistent worker process (not serverless)
  • More infrastructure to manage

Trigger.dev: The Orchestration Platform

Trigger.dev focuses on complex, observable job workflows:

// Trigger.dev v3
import { task, schedules } from '@trigger.dev/sdk/v3';

export const onboardingSequence = task({
  id: 'onboarding-sequence',
  run: async (payload: { userId: string }) => {
    // Fan-out to parallel tasks
    const [user, subscription] = await Promise.all([
      getUser(payload.userId),
      getSubscription(payload.userId),
    ]);

    // Conditional branching
    if (subscription?.plan === 'pro') {
      await sendProWelcomeEmail(user);
      await scheduleProOnboardingCall(user);
    } else {
      await sendFreeWelcomeEmail(user);
    }

    // Wait and check
    await new Promise(resolve => setTimeout(resolve, 3 * 24 * 60 * 60 * 1000));
    const hasOnboarded = await checkOnboardingComplete(payload.userId);

    if (!hasOnboarded) {
      await sendOnboardingNudge(user);
    }
  },
});

Choose Trigger.dev when:

  • Complex multi-step workflows with branching
  • Need team-visible job runs (customer success can see what happened)
  • AI agent tasks that need to run for minutes/hours
  • Fine-grained observability is a product requirement

When to Add Each

SignalAdd
Email sending is slowInngest or BullMQ
File processing (PDF, video, images)Inngest or BullMQ
Webhook processing with retriesInngest
Scheduled cron jobsInngest, BullMQ, or Vercel Cron
High-volume job processing (>100k/day)BullMQ
Complex orchestration with branchingTrigger.dev
Multi-step with independent retriesInngest

Boilerplate Inclusion

BoilerplateBackground JobsProvider
ShipFast
Supastarter
MakerkitInngest
T3 StackAdd yourself
Open SaaSInngest

Find boilerplates with background job setup on StarterPick.

Check out this boilerplate

View Inngest on StarterPick →

Comments