Web Development · Backend
Inngest: Background Jobs Without the Queue Infrastructure
Inngest handles background job scheduling, retries, and observability with no Redis or worker process to run. How it works, and when it beats BullMQ or Temporal.
Anurag Verma
7 min read
Sponsored
Every web app eventually needs to do work outside the request-response cycle. Send an email after signup. Process an uploaded file. Sync data to a third-party API. The moment you need that, you also need background jobs, and background jobs come with infrastructure: a Redis instance, a worker process, retry handling, a dead letter queue, and something to watch when jobs fail silently at 2am.
Inngest is a managed platform that handles that infrastructure so you don’t have to. You write TypeScript (or JavaScript) functions and deploy them alongside your app. Inngest handles scheduling, retry logic, observability, and fan-out.
What Inngest Actually Does
Inngest works through events, per Inngest’s own function reference. Your application sends events, JSON payloads with a name and data, and Inngest routes them to the functions that handle them. Functions can be triggered by events, by cron schedules, or by webhooks from external services.
The simplest example is a function that runs after a user signs up:
import { inngest } from "./client";
export const sendWelcomeEmail = inngest.createFunction(
{ id: "send-welcome-email" },
{ event: "user/signed-up" },
async ({ event, step }) => {
const user = event.data;
await step.run("send-email", async () => {
await emailService.send({
to: user.email,
template: "welcome",
data: { name: user.name },
});
});
}
);
Triggering it from your application:
await inngest.send({
name: "user/signed-up",
data: {
id: user.id,
email: user.email,
name: user.name,
},
});
That’s the basic pattern. Your app fires an event; Inngest runs the function. If the email service times out or throws, Inngest retries automatically with exponential backoff. If it fails permanently, you see it in the Inngest dashboard with the full event payload so you can debug and replay it.
Step Functions: The Part That Changes How You Write Jobs
The step.run primitive is where Inngest becomes more than just a job queue. Each step is a discrete unit that’s retried independently.
Without steps, if a function with three operations fails on the third one, the retry re-runs all three. With steps, the first two are remembered, Inngest replays them from the checkpoint and retries only the failed step.
export const processNewOrder = inngest.createFunction(
{ id: "process-new-order" },
{ event: "order/created" },
async ({ event, step }) => {
const { orderId } = event.data;
// Step 1: charge the card. Only retried if it fails.
const charge = await step.run("charge-card", async () => {
return await stripe.charges.create({
amount: event.data.amount,
currency: "usd",
customer: event.data.customerId,
});
});
// Step 2: create fulfillment record. Only runs after step 1 succeeds.
await step.run("create-fulfillment", async () => {
await db.fulfillments.create({
orderId,
chargeId: charge.id,
status: "pending",
});
});
// Step 3: send confirmation email.
await step.run("send-confirmation", async () => {
await email.send({ to: event.data.email, template: "order-confirmed" });
});
}
);
If the fulfillment database is down and step 2 fails, Inngest won’t charge the card again on retry. It’s already checkpointed that step 1 completed successfully.
Cron Scheduling
For scheduled work, which brings its own idempotency concerns covered in our guide to reliable cron jobs in production, Inngest functions accept a cron expression instead of an event:
export const dailyReportJob = inngest.createFunction(
{ id: "daily-report" },
{ cron: "0 8 * * *" }, // 8am UTC every day
async ({ step }) => {
const data = await step.run("fetch-metrics", async () => {
return await analytics.getDailyMetrics();
});
await step.run("send-report", async () => {
await email.send({
to: "team@company.com",
template: "daily-report",
data,
});
});
}
);
No external cron service. No node-cron running in your server process and getting killed during deploys.
Setting Up with Next.js
Inngest has a Next.js integration that adds one route to your app:
npm install inngest
Create a client:
// lib/inngest/client.ts
import { Inngest } from "inngest";
export const inngest = new Inngest({ id: "my-app" });
Add the route handler:
// app/api/inngest/route.ts
import { serve } from "inngest/next";
import { inngest } from "@/lib/inngest/client";
import { sendWelcomeEmail } from "@/lib/inngest/functions";
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [sendWelcomeEmail],
});
Inngest calls your /api/inngest endpoint when it needs to run a function. Your functions run in your own infrastructure (Vercel functions, your own server), not inside Inngest’s cloud. Inngest only handles orchestration and scheduling.
This distinction matters for compliance. Your code, and the data it touches, stays in your deployment.
Local Development
The Inngest dev server runs locally and intercepts events during development:
npx inngest-cli@latest dev
It starts a local dashboard at http://localhost:8288. Events sent from your local app appear there, functions run, and you can inspect the full execution trace, each step, the return value, timing, and any failures.
Replaying a failed function for debugging is a matter of clicking “Replay” in the dashboard. This is significantly faster than adding console.log statements and rerunning whatever caused the failure.
Fan-out and Parallel Execution
One common pattern in job processing is fan-out: trigger one event, run N functions in response. Inngest handles this without configuration, multiple functions can listen to the same event:
// Three separate functions all triggered by the same event:
export const sendWelcomeEmail = inngest.createFunction(
{ id: "send-welcome-email" },
{ event: "user/signed-up" },
async ({ event }) => { /* email */ }
);
export const createDefaultProject = inngest.createFunction(
{ id: "create-default-project" },
{ event: "user/signed-up" },
async ({ event }) => { /* setup */ }
);
export const trackSignupInAnalytics = inngest.createFunction(
{ id: "track-signup" },
{ event: "user/signed-up" },
async ({ event }) => { /* analytics */ }
);
All three run in parallel when user/signed-up fires. Each has its own retry state.
For parallel work within a single function, step.run calls not awaited individually can run concurrently:
const [analytics, inventory] = await Promise.all([
step.run("fetch-analytics", async () => analytics.get(orderId)),
step.run("fetch-inventory", async () => inventory.check(orderId)),
]);
Pricing
Inngest charges by “runs” (function invocations), and “steps” per month. The free tier includes 50,000 function runs per month, enough for most side projects and small apps. Paid plans start at $20/month for 1 million runs.
At high scale, this can get expensive compared to self-hosted BullMQ. The trade-off is that you’re also not running a Redis instance, paying for worker compute separately, or maintaining the queue infrastructure.
When to Use Inngest vs. Alternatives
| Scenario | Better choice |
|---|---|
| Side project or small app | Inngest (free tier, low ops) |
| Already using Redis heavily | BullMQ (shares the Redis instance) |
| Complex long-running workflows | Temporal (more expressive) |
| High-volume processing (millions/day) | BullMQ + self-hosted workers |
| No Redis, want managed | Inngest |
| Need durable execution, compliance guarantees | Temporal |
The main reason to choose Inngest over BullMQ is operational simplicity. You don’t need to run a Redis instance, monitor queue depth, handle worker process crashes, or debug why jobs are stuck. The main reason to choose BullMQ over Inngest is cost at scale and existing Redis infrastructure.
The Tradeoff Worth Knowing
Inngest functions run in your deployment environment, but they’re triggered by Inngest’s servers making HTTP calls to your /api/inngest endpoint. This means your functions need to be publicly accessible, they won’t work in a local-only development environment unless you use the dev server, and they need to respond within your platform’s function timeout limits.
On Vercel, the default timeout is 10 seconds for the Hobby plan, which is too short for many background tasks. You need a paid plan (300 second timeout) for anything that does meaningful work. This cost is separate from Inngest’s own pricing.
The pattern to work around short timeouts: break work into multiple small steps that each complete quickly, relying on Inngest’s step checkpointing to maintain state between them.
For teams that want background jobs without standing up infrastructure, Inngest is the lowest-friction option available. For teams already deep in Redis or running hundreds of thousands of jobs per day, the self-hosted path makes more sense.
Frequently asked questions
- How is Inngest different from BullMQ?
- BullMQ is a self-hosted job queue built on Redis: you run and monitor the Redis instance and your own worker processes. Inngest is a managed platform, your functions deploy alongside your app and Inngest's servers handle scheduling and retries by calling your app's API route. BullMQ tends to be cheaper at high volume; Inngest trades that for far less operational overhead.
- Do Inngest functions run inside Inngest's own infrastructure?
- No. Inngest's servers trigger your functions by making HTTP calls to your app's `/api/inngest` endpoint, so the function code and any data it touches run inside your own deployment (Vercel, your own server, and so on). Inngest only handles orchestration, scheduling, and retry logic, not execution.
- What happens if a step fails partway through an Inngest function?
- Only that step retries. Inngest checkpoints the result of each `step.run` call, so if a three-step function fails on step three, a retry replays steps one and two from their saved results instead of re-executing them. This is why a payment charge in step one won't be repeated just because a later step, like sending a confirmation email, failed.
- Why do I need a paid Vercel plan to use Inngest with background jobs?
- Because Inngest triggers your functions through an HTTP endpoint that must complete inside your platform's function timeout. Vercel's Hobby plan caps that at 10 seconds, too short for many real background tasks; the paid plan raises it to 300 seconds. That timeout limit is separate from and in addition to Inngest's own pricing.
- When should I choose Temporal instead of Inngest?
- Temporal is the better fit for complex, long-running workflows that need strong durable-execution guarantees, for example multi-day processes with many conditional branches. Inngest's step functions cover simpler retry-and-checkpoint needs well, but Temporal's workflow model is more expressive for genuinely complex orchestration.
Sponsored
More from this category
More from Web Development
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored