Inngest vs BullMQ vs Trigger.dev 2026
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.
Key Takeaways
Before diving in, here is the practical summary:
- Inngest: no Redis, serverless-native, step-level retries, Vercel-friendly — lowest operational overhead for small teams
- BullMQ: requires Redis, runs persistent workers, best for high-volume throughput with fine-grained concurrency control
- Trigger.dev: best observability of the three, built for complex multi-step workflows and AI agent tasks where you need to see exactly what happened
- Most indie SaaS starts with Inngest and only migrates if usage patterns demand BullMQ's throughput or Trigger.dev's orchestration features
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. The question is not whether you need them — it is which tool fits your infrastructure and scale.
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()andstep.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
Pricing Comparison
Infrastructure costs and free tiers matter a lot at the indie SaaS stage.
| Inngest | BullMQ | Trigger.dev | |
|---|---|---|---|
| Free tier | 50k runs/month | Free (self-host) | 50k runs/month |
| Infra needed | None | Redis ($7-20/mo) | None |
| Paid starts at | $35/month | Redis cost only | $59/month |
| Self-hostable | Yes (Railway/Render) | Yes (just need Redis) | Yes (Docker) |
BullMQ has no SaaS cost of its own — you pay for Redis hosting, which runs $7–20/month on Railway or Render for a small instance. That makes it the cheapest option once you factor in that Inngest and Trigger.dev both have paid tiers once you exceed 50k runs.
Inngest's $35/month tier raises the run limit substantially and adds features like advanced observability. Trigger.dev's $59/month adds team seats and priority support.
For early-stage projects with low job volumes, all three are effectively free. The cost conversation only becomes real at scale.
Self-Hosting Options
All three tools can be self-hosted, though the complexity varies.
Inngest has an open-source orchestrator you can deploy on Railway, Render, or Fly.io. Self-hosting means your job state never leaves your infrastructure, which matters for compliance-sensitive applications. The self-hosted version has parity with Inngest Cloud for core features.
BullMQ is fully self-hosted by design — the library itself is open source, and you provide the Redis instance. There is no cloud version. Bull Board, the monitoring UI, is also open source and can be embedded in your existing Express or Next.js app.
Trigger.dev has a Docker-based self-host deployment with a docker-compose.yml that sets up the orchestrator, workers, and a Postgres database. The self-hosted version is full-featured and actively maintained. It requires more setup than the other two but gives you complete control.
When to Add Each
| Signal | Add | Why |
|---|---|---|
| Email sending is slow | Inngest or BullMQ | Move out of request cycle immediately |
| File processing (PDF, video, images) | Inngest or BullMQ | Can take seconds to minutes |
| Webhook processing with retries | Inngest | Step-level retries are ideal for this |
| Scheduled cron jobs | Inngest, BullMQ, or Vercel Cron | All three handle this well |
| High-volume job processing (>100k/day) | BullMQ | Concurrency and rate limiting controls |
| Complex orchestration with branching | Trigger.dev | Built for this use case |
| Multi-step with independent retries | Inngest | step.run() is the best API for this |
| AI agent tasks running minutes/hours | Trigger.dev | Long-running task support is a focus |
| Teams needing job visibility | Trigger.dev | Dashboard designed for non-engineers too |
| On Vercel, no Redis | Inngest | Only real serverless-native option |
| Compliance: data must stay in your infra | BullMQ or self-hosted Inngest/Trigger.dev | Self-hosting required |
Migration Path: Start with Inngest, When to Switch
For most indie SaaS and early-stage products, start with Inngest. The reasons are practical:
- No Redis to provision, secure, and pay for on day one
- The Inngest dashboard makes it easy to debug job failures without custom logging
step.run()with independent retries covers 90% of background job patterns- It works on Vercel without any additional infrastructure
Switch to BullMQ when you are processing more than 100k jobs/day and need fine-grained concurrency control, or when your Redis costs would be cheaper than Inngest's paid tiers at your volume. BullMQ also makes sense if you need strict FIFO ordering within a queue, which Inngest does not guarantee.
Switch to Trigger.dev when your workflows become genuinely complex — multi-step fan-out, conditional branching, AI tasks that run for minutes, or when your customer success team needs to answer support questions by looking at job run history. Trigger.dev's observability dashboard is built for that kind of transparency.
The migration from Inngest to BullMQ or Trigger.dev is not difficult. Background job logic is usually isolated in a small number of files, and the core patterns (enqueue a job, define a handler, retry on failure) translate directly. Build the abstraction layer cleanly from the start and switching is a few days of work, not a rewrite.
Boilerplate Inclusion
| Boilerplate | Background Jobs | Provider |
|---|---|---|
| ShipFast | ❌ | — |
| Supastarter | ❌ | — |
| Makerkit | ✅ | Inngest |
| T3 Stack | ❌ | Add yourself |
| Open SaaS | ✅ | Inngest |
Makerkit is the most complete SaaS boilerplate that includes Inngest wired up out of the box. If you want a production-ready starting point with background jobs already configured, it is worth looking at — see the Makerkit boilerplate page for details.
If you are adding background jobs to an existing project, the how-to guide for adding background jobs to a SaaS starter walks through the integration step by step. You can also browse all boilerplates with background job support on the background jobs tools page.
Observability and Debugging
Getting background jobs running is the easy part. Knowing what happened when a job fails at 3am is the hard part.
All three tools give you a dashboard, but their debugging ergonomics differ significantly. Inngest's dashboard is built for developers: you can replay any function run, inspect the payload, and see exactly which step failed and why. The step-by-step replay is particularly useful for multi-step functions where you want to understand the intermediate state between steps.
BullMQ's monitoring requires an additional library — Bull Board is the standard choice. It shows queue depths, job states (waiting, active, completed, failed, delayed), and job details including the error message and stack trace. The monitoring is functional but requires you to deploy and maintain a separate UI endpoint in your application. Some teams skip Bull Board and instead rely on logging, querying Redis directly, or using a third-party job monitoring service.
Trigger.dev has the most polished observability of the three. The dashboard is designed to be readable by non-engineers — customer success teams can look up what happened during a user's job run without needing developer access to logs. Every task run has a structured view showing each step, the input and output, timing, and any errors. This matters more than it sounds: when a customer contacts support because their export failed, having a non-technical team member who can look up the job run and say "the PDF generation timed out on the large file" closes the support ticket faster and with less engineering interruption.
Practical logging advice for all three: Log job IDs in your application logging. When you enqueue a job, log the job ID alongside the user ID and action. When the job completes or fails, log the outcome. This creates a thread you can follow in your application logs even when the job dashboard is not the first place you look. BullMQ jobs have a numeric ID; Inngest and Trigger.dev use UUIDs. Treat them as first-class correlation IDs.
Dead-letter queues: All three have a mechanism for jobs that have exhausted their retries. In BullMQ, failed jobs move to the failed state and stay in Redis until you clear them. In Inngest, failed runs appear in the dashboard and you can replay them after fixing the underlying bug. In Trigger.dev, failed tasks can be retried from the dashboard or via API. In all three cases, make sure someone is watching the failure count — a job queue that silently accumulates failures is worse than one that alerts loudly.
Testing Background Jobs
Background jobs are notoriously undertested in SaaS codebases. They run asynchronously, they have side effects, and they're easy to mock in a way that provides false confidence.
The right testing strategy depends on which tool you use:
Inngest testing: Inngest ships @inngest/test utilities that let you run functions in a synchronous test environment. You pass the trigger event and assert on the steps that ran and the outputs they produced. This is the best background job testing experience of the three because the step model makes each assertion point explicit.
BullMQ testing: Unit-test the job processor function in isolation — it's just a regular function that takes a job object. Integration tests are more valuable: spin up a local Redis (or use a Redis mock library), enqueue a job, and assert on the side effects. Testcontainers is useful here for spinning up a real Redis in CI.
Trigger.dev testing: Trigger.dev v3 supports local development with a dev server that executes tasks synchronously. For unit tests, test the task function directly by calling it with a mock payload. The structured input/output model makes assertions straightforward.
The common mistake across all three: mocking the entire job queue and never testing that the job actually gets enqueued when the trigger happens, or that the enqueued job produces the correct side effects. End-to-end tests that cover the full path from trigger to side effect are worth the setup cost for your most critical job flows (payment processing, email sending, data export). See the best boilerplates with testing infrastructure for starters that include job testing patterns out of the box.
Email and Background Jobs
Email is the most common first use case for background jobs in a SaaS. Moving email sending out of the request cycle is the right call from day one — email sending can fail, takes 100–500ms, and should retry on transient failures.
The pattern is the same regardless of which background job tool you use: enqueue a job with the user ID and email type, look up the user in the job handler, render the email template, and send via your email provider. Background job retries handle transient SMTP or API failures without user impact.
Where the tools differ is in the email job patterns they enable. Inngest's step.sleep() makes delayed and sequenced emails natural — send a welcome email, sleep three days, check if the user onboarded, send a nudge if not. This is harder to implement correctly in BullMQ (you need delayed jobs and a follow-up check job). Trigger.dev handles this with its orchestration primitives.
If your boilerplate's email system is not yet set up, the best boilerplate email systems guide covers transactional email configuration across the major starters — which ones include React Email, which use Nodemailer, and how each connects to Resend, Postmark, and Sendgrid.
Conclusion
Background jobs are not an advanced topic — they are a fundamental part of any SaaS that handles file uploads, sends emails, processes payments, or runs scheduled operations. The question is not whether to add them but which tool fits your infrastructure and team.
For most indie SaaS on Vercel, start with Inngest. The zero-infrastructure model, step-level retries, and excellent DX justify the slight cost overhead compared to self-hosting BullMQ. If you later hit the scale where BullMQ's throughput and concurrency controls are necessary, the migration is straightforward because your job logic is already isolated.
BullMQ is the right default if you are already running a persistent server on Railway or Render and want maximum control over job prioritization and concurrency. The mature API and ecosystem of monitoring tools make it reliable at scale.
Trigger.dev is the right choice when observability is a product requirement, not just an operational concern — when your team or your customers need to see what happened in a job run as part of their workflow.
Choose the simplest tool that handles your current job volume and complexity, and treat the migration to a more powerful tool as a known, low-risk operation you can do when the signals are clear.
Check out this boilerplate
View Inngeston StarterPick →