Best Node.js Background Job Libraries 2026: BullMQ vs Inngest vs Trigger.dev
TL;DR
It depends on your infrastructure. BullMQ is the Redis-based standard — battle-tested, self-hosted, millions of production deployments, but requires a Redis server. Inngest runs on your existing serverless infrastructure (no Redis) — define functions that run reliably even on Vercel Edge. Trigger.dev is a fully managed cloud platform where jobs are durable, retryable, and observable without running your own infrastructure. For Vercel/serverless: Inngest. For self-hosted Node.js: BullMQ. For cloud-first with great observability: Trigger.dev.
Key Takeaways
- BullMQ: 1M downloads/week, Redis-backed, producer-consumer pattern, mature ecosystem
- Inngest: 300K downloads/week, serverless-native, no Redis needed,
step.run()for durability - Trigger.dev: 150K downloads/week, fully managed, excellent dashboard, free tier
- Vercel deployments: Inngest or Trigger.dev (no long-running processes on serverless)
- Self-hosted Node.js: BullMQ wins (cheapest, most control)
- Cost at scale: BullMQ (Redis $20/mo) < Inngest ($10/mo) ≈ Trigger.dev ($10/mo)
Downloads
| Package | Weekly Downloads | Trend |
|---|---|---|
bullmq | ~1M | ↑ Growing |
inngest | ~300K | ↑ Fast growing |
@trigger.dev/sdk | ~150K | ↑ Fast growing |
BullMQ: The Redis Standard
npm install bullmq ioredis
# Requires Redis: docker run -d -p 6379:6379 redis:alpine
// Queue producer:
import { Queue } from 'bullmq';
import { Redis } from 'ioredis';
const connection = new Redis(process.env.REDIS_URL!);
export const emailQueue = new Queue('email', { connection });
export const reportQueue = new Queue('reports', { connection });
// Add a job:
await emailQueue.add('welcome-email', {
userId: user.id,
email: user.email,
name: user.name,
}, {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: 100, // Keep last 100 completed jobs
removeOnFail: 200, // Keep last 200 failed jobs
});
// Worker (separate process or same process):
import { Worker } from 'bullmq';
const emailWorker = new Worker('email', async (job) => {
const { userId, email, name } = job.data;
console.log(`Processing job ${job.id}: ${job.name}`);
await job.updateProgress(10);
switch (job.name) {
case 'welcome-email':
await sendWelcomeEmail({ email, name });
break;
case 'password-reset':
await sendPasswordReset({ email, token: job.data.token });
break;
default:
throw new Error(`Unknown job type: ${job.name}`);
}
await job.updateProgress(100);
return { sent: true, timestamp: new Date() };
}, {
connection,
concurrency: 5, // Process 5 jobs simultaneously
limiter: {
max: 10, // Max 10 jobs per...
duration: 60000, // ...minute (rate limiting)
},
});
emailWorker.on('failed', (job, err) => {
console.error(`Job ${job?.id} failed: ${err.message}`);
});
BullMQ Scheduling
import { QueueScheduler } from 'bullmq';
// Recurring jobs:
await reportQueue.add('weekly-report', { type: 'weekly' }, {
repeat: {
pattern: '0 9 * * 1', // Every Monday 9am (cron)
},
});
// Delayed job (send in 30 minutes):
await emailQueue.add('follow-up', { userId }, {
delay: 30 * 60 * 1000,
});
Inngest: Serverless-First
npm install inngest
# Local dev server (simulates Inngest cloud):
npx inngest-cli@latest dev
// lib/inngest.ts:
import { Inngest } from 'inngest';
export const inngest = new Inngest({ id: 'my-saas' });
// Define durable functions — each `step.run` is retried independently:
import { inngest } from '@/lib/inngest';
export const processSignup = inngest.createFunction(
{ id: 'process-signup' },
{ event: 'user/signed-up' },
async ({ event, step }) => {
const { userId, email, name } = event.data;
// Each step is retried independently if it fails:
const user = await step.run('create-user-profile', async () => {
return await db.profile.create({ data: { userId, bio: '' } });
});
await step.run('send-welcome-email', async () => {
return await sendWelcomeEmail({ email, name });
});
// Wait then send follow-up (no cron needed):
await step.sleep('wait-7-days', '7d');
await step.run('send-onboarding-email', async () => {
const userData = await db.user.findUnique({ where: { id: userId } });
if (!userData?.hasCompletedSetup) {
await sendOnboardingEmail({ email, name });
}
});
return { userId, emailsSent: 2 };
}
);
// Fan-out pattern — run multiple things in parallel:
export const processOrderAsync = inngest.createFunction(
{ id: 'process-order' },
{ event: 'order/placed' },
async ({ event, step }) => {
const [shipment, invoice, notification] = await Promise.all([
step.run('create-shipment', () => createShipment(event.data.orderId)),
step.run('generate-invoice', () => generateInvoice(event.data.orderId)),
step.run('notify-customer', () => sendOrderConfirmation(event.data)),
]);
return { shipment, invoice, notification };
}
);
// Serve Inngest in Next.js App Router:
// app/api/inngest/route.ts:
import { serve } from 'inngest/next';
import { inngest } from '@/lib/inngest';
import { processSignup, processOrderAsync } from '@/lib/functions';
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [processSignup, processOrderAsync],
});
// Trigger an event from anywhere:
await inngest.send({ name: 'user/signed-up', data: { userId, email, name } });
Trigger.dev: Cloud-Managed Jobs
npm install @trigger.dev/sdk @trigger.dev/nextjs
npx trigger.dev@latest init # Sets up project on Trigger.dev cloud
// trigger/process-signup.ts:
import { task, wait } from '@trigger.dev/sdk/v3';
export const processSignupTask = task({
id: 'process-signup',
maxDuration: 300, // 5 minutes max
run: async (payload: { userId: string; email: string; name: string }) => {
const { userId, email, name } = payload;
// Create profile:
await db.profile.create({ data: { userId, bio: '' } });
// Send welcome email:
await sendWelcomeEmail({ email, name });
// Wait 7 days then send follow-up:
await wait.for({ days: 7 });
const userData = await db.user.findUnique({ where: { id: userId } });
if (!userData?.hasCompletedSetup) {
await sendOnboardingEmail({ email, name });
}
return { emailsSent: 2 };
},
});
// Trigger from anywhere:
await processSignupTask.trigger({ userId, email, name });
// Batch trigger:
await processSignupTask.batchTrigger([
{ payload: { userId: '1', email: 'a@example.com', name: 'Alice' } },
{ payload: { userId: '2', email: 'b@example.com', name: 'Bob' } },
]);
Trigger.dev Dashboard
The standout feature: a real-time job dashboard showing every run, its status, inputs, outputs, logs, and retry history. Available on Trigger.dev cloud or self-hosted.
Comparison Table
| BullMQ | Inngest | Trigger.dev | |
|---|---|---|---|
| Infrastructure | Redis required | Serverless (your server) | Cloud managed |
| Vercel compatible | ❌ (needs Redis) | ✅ | ✅ |
| Self-hosted | ✅ | ✅ (partial) | ✅ (paid) |
| Step-based durability | Manual | ✅ step.run() | ✅ wait.for() |
| Dashboard | BullBoard (DIY) | ✅ | ✅ (excellent) |
| Scheduling | ✅ cron | ✅ | ✅ |
| Free tier | Self-hosted | 3M events/mo | 50K runs/mo |
| Paid pricing | ~$20/mo (Redis) | $10/mo | $10/mo |
| Maturity | High (2021) | Medium | Growing |
Decision Guide
Use BullMQ if:
→ Self-hosted Node.js server (not serverless)
→ Already have Redis
→ Maximum control and portability
→ High-volume queuing (millions of jobs/day)
→ Cost-sensitive at scale
Use Inngest if:
→ Vercel or serverless deployment
→ Long-running workflows (sleep, wait)
→ Fan-out/orchestration patterns
→ Want step-level retry without Redis
Use Trigger.dev if:
→ Want best-in-class observability dashboard
→ Team needs job history and debugging
→ Managed infrastructure (no Redis to maintain)
→ Starting fresh with complex async workflows
Compare BullMQ, Inngest, and Trigger.dev on PkgPulse.