Skip to main content

Best Node.js Background Job Libraries 2026: BullMQ vs Inngest vs Trigger.dev

·PkgPulse Team

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

PackageWeekly DownloadsTrend
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

BullMQInngestTrigger.dev
InfrastructureRedis requiredServerless (your server)Cloud managed
Vercel compatible❌ (needs Redis)
Self-hosted✅ (partial)✅ (paid)
Step-based durabilityManualstep.run()wait.for()
DashboardBullBoard (DIY)✅ (excellent)
Scheduling✅ cron
Free tierSelf-hosted3M events/mo50K runs/mo
Paid pricing~$20/mo (Redis)$10/mo$10/mo
MaturityHigh (2021)MediumGrowing

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.

Comments

Stay Updated

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