Skip to content

Web Development · Backend

Inngest: Background Jobs Without the Queue Infrastructure

Most background job solutions require you to run and monitor a Redis instance, manage worker processes, and wire up your own retry logic. Inngest skips all of that. Here's how it works and when it's the right call.

Anurag Verma

Anurag Verma

7 min read

Inngest: Background Jobs Without the Queue Infrastructure

Sponsored

Share

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. 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, 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

ScenarioBetter choice
Side project or small appInngest (free tier, low ops)
Already using Redis heavilyBullMQ (shares the Redis instance)
Complex long-running workflowsTemporal (more expressive)
High-volume processing (millions/day)BullMQ + self-hosted workers
No Redis, want managedInngest
Need durable execution, compliance guaranteesTemporal

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.

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored