Webhook Infrastructure for SaaS
TL;DR
Inngest for most SaaS builders — serverless-friendly, great DX, handles fan-out and retries without Redis. Trigger.dev for long-running jobs that need durable execution (AI pipelines, data processing, multi-step workflows). BullMQ for high-throughput queues where you already have Redis and need battle-tested reliability. Build your own only if your use case is simple and you can't justify another dependency. The main thing boilerplates miss: they handle webhooks in the route handler itself — that's the wrong pattern. Webhook processing always belongs in a background queue.
Key Takeaways
- Inngest: serverless-native, no Redis needed, TypeScript-first, free tier generous (50K steps/month)
- Trigger.dev: durable execution (survives deploys), ideal for multi-hour AI jobs, self-hostable
- BullMQ: Redis-backed, battle-tested, 1M+ jobs/day capable, requires Redis infrastructure
- Custom (Postgres queue): zero dependencies, works at modest scale (<10K jobs/day)
- Never process webhooks synchronously — always enqueue and return 200 immediately
- Boilerplate gap: ShipFast/T3/Supastarter ship no background job infrastructure
Why Webhooks Need Background Processing
The wrong pattern that most boilerplates ship:
// ❌ WRONG: Processing Stripe webhook synchronously
export async function POST(request: Request) {
const event = stripe.webhooks.constructEvent(body, sig, secret);
// This all runs in the HTTP handler — if it fails or times out,
// Stripe will retry the webhook, causing duplicate processing:
await db.user.update({ where: { stripeCustomerId: event.data.object.customer } });
await sendWelcomeEmail(user.email); // Slow! Email API call in request handler
await updateMetrics(user); // Multiple DB writes
await notifySlack(user); // External API call
return new Response('OK');
}
The right pattern:
// ✅ RIGHT: Enqueue immediately, process in background
export async function POST(request: Request) {
const event = stripe.webhooks.constructEvent(body, sig, secret);
// Just enqueue — return 200 in under 100ms:
await inngest.send({ name: 'stripe/webhook', data: { event } });
return new Response('OK'); // Stripe considers this successful
}
// Background function handles the actual work:
inngest.createFunction(
{ id: 'stripe-webhook-handler' },
{ event: 'stripe/webhook' },
async ({ event, step }) => {
// Retried automatically on failure, never blocks the HTTP response
await step.run('update-user', async () => {
await db.user.update({ ... });
});
await step.run('send-email', async () => {
await resend.emails.send({ ... });
});
}
);
Inngest: Serverless-Native Background Jobs
Best for: Next.js apps on Vercel/serverless, teams that don't want to manage Redis, fan-out patterns
npm install inngest
// lib/inngest.ts
import { Inngest } from 'inngest';
export const inngest = new Inngest({ id: 'my-saas' });
// app/api/inngest/route.ts — single endpoint for all functions
import { serve } from 'inngest/next';
import { inngest } from '@/lib/inngest';
import { handleStripeWebhook } from '@/inngest/stripe';
import { sendWelcomeEmail } from '@/inngest/emails';
import { processAiJob } from '@/inngest/ai';
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [handleStripeWebhook, sendWelcomeEmail, processAiJob],
});
Multi-Step Functions
Inngest's killer feature: steps run durably, retrying independently on failure:
// inngest/stripe.ts
import { inngest } from '@/lib/inngest';
export const handleStripeWebhook = inngest.createFunction(
{
id: 'stripe-webhook',
retries: 5,
throttle: { limit: 100, period: '1m' },
},
{ event: 'stripe/webhook' },
async ({ event, step }) => {
const stripeEvent = event.data.event;
if (stripeEvent.type !== 'checkout.session.completed') return;
// Each step retries independently — if step 2 fails, step 1 doesn't re-run:
const user = await step.run('update-subscription', async () => {
const session = stripeEvent.data.object;
return db.user.update({
where: { id: session.metadata.userId },
data: {
plan: 'pro',
stripeCustomerId: session.customer,
subscriptionStatus: 'active',
},
});
});
await step.run('send-welcome-email', async () => {
await resend.emails.send({
to: user.email,
subject: 'Welcome to Pro!',
html: render(ProWelcomeEmail({ name: user.name })),
});
});
await step.run('notify-slack', async () => {
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: 'POST',
body: JSON.stringify({ text: `New Pro subscription: ${user.email}` }),
});
});
}
);
Fan-Out Pattern (Parallel Steps)
export const weeklyReportJob = inngest.createFunction(
{ id: 'weekly-reports', concurrency: { limit: 10 } },
{ cron: '0 9 * * MON' }, // Every Monday 9am UTC
async ({ step }) => {
// Fetch all users who need reports:
const users = await step.run('get-users', async () => {
return db.user.findMany({
where: { plan: 'pro', weeklyReports: true },
});
});
// Fan out — send one event per user, processed in parallel:
await step.sendEvent('send-reports', users.map((user) => ({
name: 'report/weekly',
data: { userId: user.id },
})));
}
);
export const sendWeeklyReport = inngest.createFunction(
{ id: 'send-weekly-report', concurrency: { limit: 20 } },
{ event: 'report/weekly' },
async ({ event, step }) => {
const userId = event.data.userId;
// Process each user's report independently:
const stats = await step.run('get-stats', async () => {
return getWeeklyStats(userId);
});
await step.run('send-email', async () => {
await sendReportEmail(userId, stats);
});
}
);
Inngest Pricing
Free: 50,000 steps/month
Starter ($25/month): 500,000 steps/month
Growth ($100/month): 5M steps/month
A "step" = one step.run() call or one event send.
A typical Stripe webhook with 3 steps = 3 steps consumed.
Trigger.dev: Durable Long-Running Jobs
Best for: AI pipelines, multi-minute/hour jobs, jobs that must survive deploys
npm install @trigger.dev/sdk@v3
// trigger/ai-pipeline.ts
import { task, wait } from '@trigger.dev/sdk/v3';
export const processDocumentTask = task({
id: 'process-document',
maxDuration: 300, // 5 minutes max
retry: {
maxAttempts: 3,
minTimeoutInMs: 1000,
maxTimeoutInMs: 10000,
factor: 2,
},
run: async (payload: { documentId: string; userId: string }) => {
const { documentId, userId } = payload;
// Step 1: Download and extract text
const document = await fetchDocument(documentId);
const text = await extractText(document);
// Step 2: Generate embeddings (slow API call)
const embeddings = await generateEmbeddings(text);
// Step 3: Store in vector DB
await storeEmbeddings(documentId, embeddings);
// Step 4: Notify user
await notifyUser(userId, 'Document processed successfully');
return { documentId, chunkCount: embeddings.length };
},
});
// Trigger from your API route:
import { tasks } from '@trigger.dev/sdk/v3';
import { processDocumentTask } from '@/trigger/ai-pipeline';
export async function POST(req: Request) {
const { documentId } = await req.json();
const session = await auth();
// Enqueue the job — returns immediately with a handle:
const handle = await tasks.trigger(processDocumentTask, {
documentId,
userId: session!.user.id,
});
return Response.json({ runId: handle.id });
}
// Poll job status from client:
import { runs } from '@trigger.dev/sdk/v3';
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const runId = searchParams.get('runId')!;
const run = await runs.retrieve(runId);
return Response.json({
status: run.status, // 'QUEUED' | 'EXECUTING' | 'COMPLETED' | 'FAILED'
output: run.output,
});
}
When Trigger.dev Wins Over Inngest
Use Trigger.dev when:
→ Jobs take minutes or hours (AI processing, batch imports)
→ You need to cancel running jobs mid-execution
→ Jobs must survive deploys (CI/CD shouldn't kill in-flight work)
→ You want full self-hosting control
→ Real-time job monitoring is required (streaming logs)
BullMQ: Redis-Backed High-Throughput Queues
Best for: high-volume jobs (1M+/day), teams already using Redis, existing Node.js workers
npm install bullmq ioredis
// lib/queue.ts
import { Queue, Worker, QueueEvents } from 'bullmq';
import Redis from 'ioredis';
const connection = new Redis(process.env.REDIS_URL!, {
maxRetriesPerRequest: null,
});
// Define queues:
export const emailQueue = new Queue('email', { connection });
export const webhookQueue = new Queue('webhooks', { connection });
// Worker processes jobs:
const emailWorker = new Worker(
'email',
async (job) => {
switch (job.name) {
case 'welcome':
await sendWelcomeEmail(job.data.userId);
break;
case 'weekly-report':
await sendWeeklyReport(job.data.userId);
break;
default:
throw new Error(`Unknown job type: ${job.name}`);
}
},
{
connection,
concurrency: 5,
}
);
// Error handling:
emailWorker.on('failed', (job, error) => {
console.error(`Job ${job?.id} failed:`, error);
// Alert Sentry, update DB status, etc.
});
// Enqueue from API route:
export async function POST(req: Request) {
const { userId } = await req.json();
await emailQueue.add('welcome', { userId }, {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
removeOnComplete: { count: 1000 },
removeOnFail: { count: 5000 },
});
return new Response('OK');
}
// Scheduled jobs with BullMQ:
import { Queue } from 'bullmq';
await emailQueue.add(
'weekly-report',
{ batchId: Date.now() },
{
repeat: {
pattern: '0 9 * * MON', // Every Monday 9am
},
}
);
BullMQ requires running a worker process — this is the biggest operational difference from Inngest/Trigger.dev. You need a separate Node.js process (or container) running your workers. On Vercel, you'd need a separate worker service.
Custom: Postgres-Backed Job Queue
Best for: simple use cases, no Redis, modest volume (<10K jobs/day)
// lib/job-queue.ts — minimal Postgres queue
import { db } from './db';
export type JobStatus = 'pending' | 'processing' | 'completed' | 'failed';
export async function enqueueJob(
type: string,
data: Record<string, unknown>,
options?: { scheduledFor?: Date; maxAttempts?: number }
) {
return db.backgroundJob.create({
data: {
type,
data,
status: 'pending',
scheduledFor: options?.scheduledFor ?? new Date(),
maxAttempts: options?.maxAttempts ?? 3,
},
});
}
// Polling worker (run via cron or long-running process):
export async function processNextJob() {
// Atomic claim using DB transaction:
const job = await db.$transaction(async (tx) => {
const job = await tx.backgroundJob.findFirst({
where: {
status: 'pending',
scheduledFor: { lte: new Date() },
attempts: { lt: tx.backgroundJob.fields.maxAttempts },
},
orderBy: { createdAt: 'asc' },
});
if (!job) return null;
return tx.backgroundJob.update({
where: { id: job.id },
data: { status: 'processing', startedAt: new Date() },
});
});
if (!job) return false;
try {
await executeJob(job);
await db.backgroundJob.update({
where: { id: job.id },
data: { status: 'completed', completedAt: new Date() },
});
} catch (error) {
const nextAttempt = job.attempts + 1;
const isExhausted = nextAttempt >= job.maxAttempts;
await db.backgroundJob.update({
where: { id: job.id },
data: {
status: isExhausted ? 'failed' : 'pending',
attempts: nextAttempt,
lastError: (error as Error).message,
// Exponential backoff:
scheduledFor: isExhausted
? undefined
: new Date(Date.now() + Math.pow(2, nextAttempt) * 5000),
},
});
}
return true;
}
Which to Choose
| Inngest | Trigger.dev | BullMQ | Custom | |
|---|---|---|---|---|
| Setup complexity | Low | Medium | High | Low |
| Redis required | ❌ | ❌ | ✅ | ❌ |
| Serverless compatible | ✅ | ✅ | ❌ | ✅ |
| Long-running jobs | Limited | ✅ (hours) | ✅ | Limited |
| Self-hostable | Partial | ✅ | ✅ | ✅ |
| Volume ceiling | 5M steps/mo | Custom | Unlimited | ~10K/day |
| Monitoring dashboard | ✅ | ✅ | Limited | ❌ |
| Free tier | 50K steps | 25K runs | - | - |
Choose Inngest if:
→ Vercel/serverless deployment
→ Stripe webhooks, email sending, fan-out patterns
→ Want job monitoring without running infrastructure
Choose Trigger.dev if:
→ AI pipelines or multi-minute jobs
→ Need to cancel in-flight jobs
→ Want full self-hosting control
→ Durable execution across deploys required
Choose BullMQ if:
→ Already have Redis
→ Need 1M+ jobs/day
→ Building on non-serverless infra (Railway, Fly.io, AWS)
Choose Custom (Postgres) if:
→ Simple needs: <10K jobs/day, basic retries
→ Can't add another dependency
→ Already have Postgres (which you do if using Prisma)
Find SaaS boilerplates with pre-built background job infrastructure at StarterPick.