When your server needs to "do this later," you used to set up Redis, BullMQ, a worker process, and a deploy story for all of it. Inngest replaces that with a function and an event. Durable retries built in; no infrastructure to manage; works on serverless.
Things that should run after the user's request returns:
Classic stack: Redis + BullMQ + a worker process. Modern serverless answer: Inngest, Trigger.dev ↗, or Temporal ↗. This tutorial uses Inngest because the developer experience is the most beginner-friendly.
Sign up at app.inngest.com ↗. Free tier: 100K events/month + 1K runs concurrency. Real for early-stage projects.
Dashboard → Apps → your app gets created automatically when you first connect. We'll come back to this.
Inngest functions are plain async functions, triggered by named events.
// inngest/functions.js
import { Inngest } from "inngest";
export const inngest = new Inngest({ id: "my-app" });
export const sendWelcomeEmail = inngest.createFunction(
{ id: "send-welcome-email" },
{ event: "user/signed-up" },
async ({ event, step }) => {
const { email } = event.data;
// Each `step.run` is durable — retried independently on failure
const customer = await step.run("create-customer", async () => {
return createStripeCustomer(email);
});
await step.run("send-email", async () => {
return sendResendEmail({
to: email,
subject: "Welcome",
body: `Hi! Customer ID: ${customer.id}`,
});
});
}
);
Add an HTTP endpoint where Inngest calls your functions. Framework-specific:
Next.js (App Router):
// app/api/inngest/route.ts
import { serve } from "inngest/next";
import { inngest, sendWelcomeEmail } from "@/inngest/functions";
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [sendWelcomeEmail],
});
Express:
import { serve } from "inngest/express";
import express from "express";
const app = express();
app.use(express.json());
app.use("/api/inngest", serve({
client: inngest,
functions: [sendWelcomeEmail],
}));
Full framework list: Serving functions ↗. Vercel, Cloudflare Workers, Hono, Fastify, Remix, Astro, NestJS, AWS Lambda — all supported.
From anywhere in your app — an API route, a webhook handler, a cron job:
import { inngest } from "@/inngest/functions";
await inngest.send({
name: "user/signed-up",
data: { email: "[email protected]" },
});
Returns immediately — within a few ms. Your user's request returns fast; Inngest takes over running the function.
Inngest ships a local dev server — Docker-free, runs in your terminal:
npx inngest-cli@latest dev
Opens at http://localhost:8288. The dev server auto-discovers your /api/inngest endpoint, lists your functions, lets you trigger events from a UI, and shows step-by-step execution traces with retry behavior. Real-time debugging without leaving your laptop.
Deploy your app. Then in the Inngest dashboard → Apps → Sync new app → paste your production URL ending in /api/inngest. Inngest pings it, discovers your functions, and starts routing events.
Add env vars to production:
INNGEST_EVENT_KEY=... # for inngest.send() to authenticate
INNGEST_SIGNING_KEY=... # for Inngest to verify it's calling YOUR app
Both are visible in the dashboard under Settings → Keys.
Sleep / delay. Wait inside a function for hours or days; Inngest doesn't keep your server warm during the wait.
await step.sleep("wait-a-day", "24h");
await step.run("send-reminder", () => sendEmail(...));
Wait for event. Pause until a specific event arrives. Useful for "if user doesn't confirm in 24h, send a reminder."
const confirmation = await step.waitForEvent("wait-for-confirm", {
event: "user/email-confirmed",
timeout: "24h",
match: "data.userId",
});
if (!confirmation) {
await step.run("send-nudge", () => sendNudgeEmail(...));
}
Scheduled (cron).
export const dailyDigest = inngest.createFunction(
{ id: "daily-digest" },
{ cron: "0 8 * * *" }, // every day at 08:00 UTC
async ({ step }) => {
await step.run("send-digest", () => sendDigestToAllUsers());
}
);
Fan-out. One event triggers N parallel functions:
// Two functions, both listening for the same event
export const a = inngest.createFunction({ id: "a" }, { event: "order/paid" }, ...);
export const b = inngest.createFunction({ id: "b" }, { event: "order/paid" }, ...);
// One send triggers both
await inngest.send({ name: "order/paid", data: {...} });
Concurrency limits + throttling. Limit how many runs execute simultaneously (e.g., for rate-limited third-party APIs):
inngest.createFunction(
{
id: "process-image",
concurrency: { limit: 5 }, // at most 5 at once globally
throttle: { limit: 100, period: "1m" }, // and no more than 100/min
},
{ event: "image/uploaded" },
async ({ event, step }) => { /* ... */ }
);
If a step.run throws, Inngest retries it with exponential backoff (default 4 retries over ~10 minutes). Successful steps don't re-run — only the failing step does. Configure per function:
inngest.createFunction(
{ id: "syncs", retries: 10 },
{ event: "user/created" },
async ({ event, step }) => { ... }
);
Throw NonRetriableError to fail immediately without retries (e.g., invalid input — no point retrying).
step.run — code outside step.run re-runs on every retry. Side effects (DB writes, API calls) belong in steps.step.run calls; Inngest will run them as separate invocations.user/signed-up, order/paid, image/uploaded are the recommended shape. Functions react to events; events are the API. This is good for reasoning but takes a turn to internalize. Events guide ↗.
Free tier: 100K events/month + 1K concurrent runs. Plenty for early projects. Paid starts at $50/mo. Inngest pricing ↗.
Self-hosted: Inngest open-sources a community edition ↗. Production self-hosting is real work.