A year ago, the answer to "how do I run background jobs" was BullMQ + Redis or Postgres tables. That still works. But jobs evolve. A single send_welcome_email job becomes a 5-step workflow: create user → verify email → send welcome → add to onboarding playlist → schedule trial expiry email. Each step depends on the previous. Some retry on failure. Some have human approval gates. Some run in parallel, others in sequence. Job queues don't model this well. You need workflow orchestration — a layer that tracks multi-step logic, handles retries at the step level, and lets you replay entire workflows if something breaks.
Enter Inngest, Trigger.dev, and Temporal. All three let you define workflows as code. All three handle retries, error recovery, and state tracking. But they differ wildly in complexity, pricing, and when to use them.
The Three Options
Inngest
Inngest is the Node.js default. Workflows are step functions with type-safe payloads. You define a workflow in your codebase, deploy it, and Inngest's serverless engine executes it. Steps are retryable, parallelizable, and time-delayadle. Great DX for SaaS startups. Pricing is per-invocation (~$0.50 per million). Free tier covers 10K invocations/month.
Trigger.dev
Trigger.dev V2 launched to compete directly with Inngest. Similar concept: define workflows in your codebase, ship them, Trigger.dev runs them. V2 emphasizes better onboarding, more integrations (Slack, Stripe, webhooks baked in), and a slicker dashboard. Pricing is also per-invocation, with a generous free tier. Both are SaaS-friendly and require minimal infra.
Temporal
Temporal is the heavyweight. It's designed for enterprise workloads: financial transactions, saga patterns, long-running orchestrations that span days. You deploy a Temporal cluster (or use their managed cloud), define workflows in Go/Java/Python/TypeScript, and Temporal handles the entire execution, durability, and scaling. Pricing is steep ($0.25 per workflow-second at scale). Overkill for most SaaS, but bulletproof for mission-critical async work.
Inngest: The SaaS Standard
Inngest is the easiest entry point. Install the SDK, define a function, and you have a workflow:
import { inngest } from './inngest.client';
// Define a workflow function
export const sendOnboarding = inngest.createFunction(
{ id: 'send-onboarding' },
{ event: 'user.signup' },
async ({ event, step }) => {
// Step 1: Verify email
const verified = await step.run('verify-email', async () => {
return await sendVerificationEmail(event.user.email);
});
// Step 2: Wait for verification (max 7 days)
await step.waitForEvent(
'email.verified',
{ match: 'data.userId', value: event.user.id },
{ timeout: '7d' }
);
// Step 3: Send welcome email
await step.run('send-welcome', async () => {
return await sendWelcome(event.user.id);
});
// Step 4: Add to onboarding playlist
await step.run('add-to-playlist', async () => {
return await addToPlaylist(event.user.id);
});
// Step 5: Schedule trial expiry email
await step.sleep('7d');
await step.run('send-trial-expiry', async () => {
return await sendTrialExpiry(event.user.id);
});
return { ok: true };
}
);
// Trigger it
inngest.send({ name: 'user.signup', data: { user: { id: '123', email: 'user@example.com' } } });
Inngest handles retry logic, durability (workflows survive server restarts), and step-level error handling automatically. If send-welcome fails, Inngest retries that step only — not the entire workflow. You can inspect runs in the dashboard, replay failed ones, and pause/resume workflows. For a SaaS with 10K–1M users, Inngest costs $50–500/month depending on invocation volume.
Inngest Setup: Copy-Paste Starter
In your Next.js or Remix app, create the Inngest client:
// lib/inngest.client.ts
import { Inngest } from 'inngest';
export const inngest = new Inngest({ id: 'my-app' });
Define your workflow:
// app/api/inngest/workflows.ts
import { inngest } from '@/lib/inngest.client';
export const userSignup = inngest.createFunction(
{ id: 'user-signup-onboarding' },
{ event: 'user/signup' },
async ({ event, step }) => {
const { userId, email } = event.data;
// Send verification email
const code = await step.run('send-verification', async () => {
const code = generateCode();
await resend.emails.send({
from: 'verify@example.com',
to: email,
subject: 'Verify your email',
html: `Verify`,
});
return code;
});
// Wait for user to click the link (timeout 7 days)
const verification = await step.waitForEvent(
'user/email-verified',
{ match: 'data.userId', value: userId },
{ timeout: '604800s' } // 7 days in seconds
);
if (!verification) {
// Retry or mark expired
return { status: 'expired' };
}
// Create user profile and settings
await step.run('create-profile', async () => {
const { data } = await supabase
.from('profiles')
.insert({ user_id: userId, verified: true })
.select();
return data;
});
// Send welcome email
await step.run('send-welcome', async () => {
await resend.emails.send({
from: 'hello@example.com',
to: email,
subject: 'Welcome aboard!',
html: 'Start building...
',
});
});
// Mark onboarding complete
await step.run('mark-onboarded', async () => {
await supabase
.from('profiles')
.update({ onboarded: true })
.eq('user_id', userId);
});
return { status: 'complete', userId };
}
);
// Trigger from anywhere in your app
export async function handleUserSignup(userId: string, email: string) {
await inngest.send({
name: 'user/signup',
data: { userId, email },
});
}
Wire up the handler endpoint:
// app/api/inngest/route.ts
import { serve } from 'inngest/next';
import { inngest } from '@/lib/inngest.client';
import { userSignup } from './workflows';
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [userSignup],
});
That's it. Deploy to Netlify or Vercel, and Inngest starts executing workflows. The free tier covers most indie projects.
Trigger.dev V2: The Challenger
Trigger.dev V2 is nearly identical in concept but with stronger integrations. You write workflows the same way:
import { task, logger } from '@trigger.dev/sdk/v3';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export const handlePaymentFailed = task({
id: 'handle-payment-failed',
run: async (event: Stripe.Event) => {
const charge = event.data.object as Stripe.Charge;
// Step 1: Log the failure
await task.log('Payment failed', { chargeId: charge.id });
// Step 2: Fetch user from DB
const user = await db.users.findFirst({
where: { stripe_customer_id: charge.customer },
});
// Step 3: Send email with retry
await task.triggerAndWait('send-payment-failed-email', {
userId: user.id,
amount: charge.amount,
});
// Step 4: Update subscription status
await db.subscriptions.update({
where: { user_id: user.id },
data: { status: 'payment_failed', retry_count: 0 },
});
return { ok: true };
},
});
// Stripe webhook handler
export const sendPaymentFailedEmail = task({
id: 'send-payment-failed-email',
run: async ({ userId, amount }: { userId: string; amount: number }) => {
// Trigger.dev has built-in Resend integration
return await resendTask.send({
to: user.email,
subject: 'Payment failed — update your card',
html: `We couldn't charge your card for $${amount / 100}. Update now
`,
});
},
});
The advantage: Trigger.dev bakes in integrations (Stripe, Slack, GitHub, etc.), so you can trigger workflows directly from webhooks without boilerplate. They also invest heavily in observability and replay. For teams that want a slicker dashboard and prefer webhooks over events, Trigger.dev wins.
Inngest vs Trigger.dev: Head-to-Head
| Feature | Inngest | Trigger.dev |
|---|---|---|
| TypeScript DX | Excellent | Excellent |
| Webhook Integrations | Manual event routing | Built-in (Stripe, Slack, etc) |
| Step-level Retries | Yes | Yes |
| Dashboard UX | Functional | Polished |
| Free Tier | 10K invocations/mo | 1M task runs/mo |
| Pricing at Scale | $0.50/M invocations | $0.35/M runs |
| Self-Host Option | No | No |
For most SaaS: pick either. Inngest if you build your own integrations. Trigger.dev if you want Stripe/Slack pre-wired. Both cost under $1K/month until you hit Stripe-processing scale.
Temporal: When You Need Enterprise
Temporal is a different beast. It's designed for workflows that last days, involve human approval, and can't afford data loss. Financial systems, insurance workflows, order fulfillment pipelines. You deploy a Temporal cluster (5 replicas minimum), define workflows in Go or TypeScript, and Temporal orchestrates them with millisecond timing precision.
// Temporal workflow (simplified)
import * as workflow from '@temporalio/workflow';
import * as activity from '@temporalio/activity';
export async function orderFulfillment(orderId: string) {
// Step 1: Charge card
const payment = await workflow.executeActivity(chargeCard, { orderId });
if (!payment.success) {
throw new Error('Payment declined');
}
// Step 2: Wait for warehouse to confirm (could be hours or days)
const shipmentConfirm = await workflow.waitForSignal('shipment-confirmed');
// Step 3: Send shipping notification
await workflow.executeActivity(sendShippingEmail, { orderId });
// Step 4: Wait for delivery (max 30 days)
const delivered = await workflow.waitForSignal('delivery-confirmed', '30d');
if (!delivered) {
// Escalate to support
await workflow.executeActivity(escalateToSupport, { orderId });
}
return { status: 'complete' };
}
Temporal's strength: durability. Workflows are written to disk. If your worker crashes, Temporal restarts it from the exact same state. No state is lost. It's built for systems where timing and consistency matter. But running Temporal requires ops skills (cluster deployment, monitoring, scaling). Temporal Cloud (managed) is expensive: $10–100K+ per year depending on scale.
Use Temporal if: you're processing financial transactions, insurance claims, long-running sagas, or anything where losing state is catastrophic. Use Inngest or Trigger.dev for SaaS onboarding, notification workflows, and anything that fits in minutes or hours.
Six FAQs
Can I start with Postgres jobs and upgrade to Inngest later?
Yes. Postgres jobs handle 95% of cases. If your workflows grow complex (parallel steps, wait events, retries at the step level), port to Inngest. It takes a day. Until then, stick with Postgres and keep the money in your pocket.
What if I need a self-hosted option?
Inngest and Trigger.dev are SaaS-only (no self-host). Temporal self-hosts but requires ops overhead. If self-hosting is non-negotiable, use BullMQ + Redis or roll your own. Most SaaS don't need to self-host workflows — the control isn't worth the operational burden.
Do I need both Inngest AND a job queue?
No. Inngest or Trigger.dev replace job queues. One source of truth for async work. If you're using Inngest, don't also use BullMQ or Postgres queues — it doubles your complexity.
How many steps is too many?
No hard limit, but aim for 5–15 per workflow. If you're nesting 50 steps, split it into multiple workflows that trigger each other. Workflows should be mentally digestible.
What if a webhook fails?
Inngest and Trigger.dev retry failed webhooks automatically (exponential backoff). Check the dashboard for failed attempts and replay if needed. Both have built-in observability.
Can I pause and resume workflows?
Yes, both platforms support pausing at any step. Useful for workflows waiting on human input or manual approvals. Temporal also supports long-running pauses (days) natively; Inngest and Trigger.dev handle it but are designed for shorter cycles.
The Verdict
Start with Postgres jobs. Graduate to Inngest or Trigger.dev when you need typed, step-based workflows. Inngest is the Node.js default and costs less. Trigger.dev V2 has better integrations and polish. Both are under $1K/month for most SaaS. Temporal is overkill unless you're processing financial transactions or multi-day sagas — then it's worth every penny. For more on building robust async systems, see Velocity X partnerships. And if you want to stay lightweight, revisit our Postgres jobs approach at Job Queues — Supabase Postgres Table vs BullMQ Redis.