How to Add Background Jobs to Your SaaS Starter 2026
·StarterPick Team
bullmqinngestbackground-jobsredissaas-boilerplate2026
TL;DR
Inngest is the simplest way to add background jobs to a Next.js SaaS in 2026 — no Redis needed, works in serverless, and has a great local dev experience. BullMQ is the choice when you need maximum control (priority queues, rate limiting, job chaining). For scheduled jobs: Inngest cron() or Vercel Cron + an API route.
Key Takeaways
- Inngest: Serverless-native, no Redis, runs in Next.js API routes, great DX
- BullMQ: Redis-backed, battle-tested, priority queues, best for high throughput
- Trigger.dev v3: Cloud-hosted job runner, long-running tasks, no infrastructure
- Vercel Cron: Simple scheduled tasks, free, 1-minute resolution
- Use cases: Email sending, PDF generation, AI jobs, Stripe billing, data sync
Option 1: Inngest (Serverless-Native)
npm install inngest
// inngest/client.ts:
import { Inngest } from 'inngest';
export const inngest = new Inngest({ id: 'my-saas' });
// inngest/functions.ts — define background functions:
import { inngest } from './client';
import { sendWelcomeEmail } from '@/lib/email';
import { generateInvoicePDF } from '@/lib/pdf';
// Welcome email after signup:
export const sendWelcomeEmailFn = inngest.createFunction(
{ id: 'send-welcome-email', retries: 3 },
{ event: 'user/signed-up' },
async ({ event, step }) => {
const { userId, email, name } = event.data;
// step.run() wraps each step with automatic retry:
await step.run('send-email', () =>
sendWelcomeEmail({ to: email, name })
);
// Delay 24 hours, then send onboarding tip:
await step.sleep('wait-24h', '24h');
await step.run('send-onboarding-tip', () =>
sendOnboardingTip({ to: email, name })
);
}
);
// Invoice generation (can be slow):
export const generateInvoiceFn = inngest.createFunction(
{ id: 'generate-invoice', retries: 2 },
{ event: 'invoice/created' },
async ({ event, step }) => {
const { invoiceId } = event.data;
const pdfUrl = await step.run('generate-pdf', () =>
generateInvoicePDF(invoiceId)
);
await step.run('email-invoice', () =>
sendInvoiceEmail({ invoiceId, pdfUrl })
);
}
);
// Weekly digest cron:
export const weeklyDigestFn = inngest.createFunction(
{ id: 'weekly-digest' },
{ cron: '0 9 * * MON' }, // Every Monday 9am UTC
async ({ step }) => {
const users = await step.run('fetch-users', () =>
db.user.findMany({ where: { plan: 'pro' } })
);
await step.run('send-digests', () =>
Promise.all(users.map(u => sendWeeklyDigest(u)))
);
}
);
// app/api/inngest/route.ts — register functions:
import { serve } from 'inngest/next';
import { inngest } from '@/inngest/client';
import { sendWelcomeEmailFn, generateInvoiceFn, weeklyDigestFn } from '@/inngest/functions';
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [sendWelcomeEmailFn, generateInvoiceFn, weeklyDigestFn],
});
// Trigger an event from anywhere in your app:
import { inngest } from '@/inngest/client';
// After user signs up:
await inngest.send({
name: 'user/signed-up',
data: { userId: user.id, email: user.email, name: user.name },
});
// After invoice created:
await inngest.send({
name: 'invoice/created',
data: { invoiceId: invoice.id },
});
Option 2: BullMQ (High Throughput)
npm install bullmq ioredis
// lib/queues.ts — queue definitions:
import { Queue, Worker, QueueEvents } from 'bullmq';
const redisConnection = {
host: process.env.REDIS_HOST ?? 'localhost',
port: 6379,
};
// Define queues:
export const emailQueue = new Queue('email', { connection: redisConnection });
export const pdfQueue = new Queue('pdf', { connection: redisConnection });
export const syncQueue = new Queue('sync', {
connection: redisConnection,
defaultJobOptions: {
attempts: 5,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: 100,
removeOnFail: 200,
},
});
// Email worker:
export const emailWorker = new Worker(
'email',
async (job) => {
const { type, to, data } = job.data;
switch (type) {
case 'welcome': await sendWelcomeEmail({ to, ...data }); break;
case 'invoice': await sendInvoiceEmail({ to, ...data }); break;
case 'digest': await sendWeeklyDigest({ to, ...data }); break;
}
},
{ connection: redisConnection, concurrency: 5 }
);
// Add jobs from API routes:
await emailQueue.add('send-welcome', {
type: 'welcome',
to: user.email,
data: { name: user.name },
});
// Delayed job (send after 1 hour):
await emailQueue.add(
'send-onboarding',
{ type: 'onboarding', to: user.email, data: { name: user.name } },
{ delay: 60 * 60 * 1000 }
);
// Repeatable cron job:
await emailQueue.add(
'weekly-digest',
{ type: 'digest' },
{ repeat: { pattern: '0 9 * * MON' } }
);
Vercel Cron (Simple Scheduled Jobs)
// vercel.json:
// {
// "crons": [
// { "path": "/api/cron/daily-cleanup", "schedule": "0 2 * * *" },
// { "path": "/api/cron/weekly-digest", "schedule": "0 9 * * MON" }
// ]
// }
// app/api/cron/daily-cleanup/route.ts:
import { NextRequest } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
// Verify Vercel cron secret:
const authHeader = request.headers.get('authorization');
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response('Unauthorized', { status: 401 });
}
// Your cleanup logic:
const deleted = await db.session.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
return Response.json({ deleted: deleted.count });
}
Decision Guide
Use Inngest if:
→ Deploying on Vercel/Netlify (serverless)
→ Want great local dev experience (Inngest Dev Server)
→ Complex multi-step workflows with delays
→ Don't want to manage Redis
Use BullMQ if:
→ Already have Redis (Upstash, Railway)
→ High-volume jobs (1000+ per minute)
→ Need priority queues or rate limiting
→ Running on a server (Railway, Fly.io, EC2)
Use Vercel Cron if:
→ Simple scheduled tasks (cleanup, reports)
→ Already on Vercel, free tier (60 invocations/day)
→ No complex retry logic needed
Find SaaS boilerplates with job queues pre-configured at StarterPick.