Inngest: Event-Driven TypeScript Functions Without Infra
Inngest is a fresh take on serverless that lets you write event‑driven functions in TypeScript without wrestling with the underlying infrastructure. Think of it as a blend of webhooks, background jobs, and durable functions—all wrapped in a developer‑friendly SDK. In this article we’ll walk through the core concepts, set up a project from scratch, and build a couple of real‑world examples that showcase Inngest’s power. By the end, you’ll be ready to drop event‑driven logic into any Node.js or Next.js app with confidence.
What Makes Inngest Different?
Traditional serverless platforms (AWS Lambda, Vercel Functions, Cloudflare Workers) give you a way to run code on demand, but they often leave you to manage queues, retries, and state manually. Inngest abstracts those operational concerns into a single API that records every event, guarantees at‑least‑once delivery, and provides a built‑in UI for debugging.
Because Inngest is built on top of a managed event store, you can focus on business logic instead of scaling workers. The SDK automatically serialises your TypeScript payloads, persists them, and re‑invokes the appropriate function when the event is ready.
Core Concepts
Events
An event is a JSON‑serialisable object that describes something that happened in your system – a user signing up, an order being placed, or a file landing in S3. Events have a name, a data payload, and optional metadata like timestamps or correlation IDs.
Functions (or “Inngest Functions”)
Inngest functions are plain TypeScript functions that react to one or more event names. The SDK registers them with the Inngest platform, which then routes matching events to the correct handler. A function can be synchronous or asynchronous, and you can chain multiple steps inside a single function for complex workflows.
Steps & Durable Execution
Steps let you break a workflow into discrete, retry‑able units. Each step receives the output of the previous step, and Inngest persists the state after every step. If a step fails, only that step is retried – the rest of the workflow resumes where it left off.
This model is similar to Durable Functions or Temporal, but with far less boilerplate and zero self‑hosting.
Getting Started: Installation & Configuration
First, create a fresh Node.js project and install the SDK. Inngest works with any bundler, but the examples below assume a standard npm setup.
npm init -y
npm install inngest
npm install -D typescript @types/node
npx tsc --init
Next, grab an API key from the Inngest dashboard and add it to a .env file. The SDK reads INNGEST_SIGNING_KEY automatically.
# .env
INNGEST_SIGNING_KEY=sk_test_XXXXXXXXXXXXXXXXXXXXXXXX
Finally, create a small bootstrap file that registers your functions with the Inngest client. This file will be imported by your serverless runtime (e.g., Vercel, Netlify, or a plain Express server).
import { Inngest } from "inngest";
export const inngest = new Inngest({
// The name of your app – appears in the UI
name: "codeyaan-demo",
});
Example 1: A Simple “Hello, Event!” Function
Let’s start with the classic “Hello, World” but in an event‑driven fashion. We’ll emit an event called greeting/triggered and have Inngest log a friendly message.
Step 1: Define the Event
export const greetEvent = inngest.createEvent({
name: "greeting/triggered",
// Optional: schema validation can be added later
});
Step 2: Register the Function
import { inngest } from "./inngest";
export const greetFunction = inngest.createFunction(
{ name: "greeting/handler" },
{ event: "greeting/triggered" },
async ({ event }) => {
console.log(`👋 Received greeting: ${event.data.message}`);
}
);
Step 3: Emit the Event
You can fire the event from anywhere in your code – an API route, a cron job, or even a client‑side fetch.
import { greetEvent } from "./greet";
await greetEvent.send({
message: "Hello, Inngest!",
});
When you run the code, the Inngest UI will show the event, the function invocation, and the console output. No queues, no Lambda deployments, just pure TypeScript.
Real‑World Use Case #1: E‑Commerce Order Processing
Imagine an online store that needs to orchestrate several steps after a customer places an order: reserve inventory, charge the payment gateway, send a confirmation email, and finally update analytics. Each step can fail independently, and you want automatic retries without duplicating work.
Define the Order Event
export const orderCreated = inngest.createEvent({
name: "order/created",
});
Orchestrate the Workflow
import { inngest } from "./inngest";
import { reserveInventory } from "./inventory";
import { chargeCustomer } from "./payments";
import { sendConfirmation } from "./email";
import { trackAnalytics } from "./analytics";
export const orderWorkflow = inngest.createFunction(
{ name: "order/workflow" },
{ event: "order/created" },
async ({ step, event }) => {
// Step 1: Reserve inventory
const reservation = await step.run("reserve-inventory", async () => {
return await reserveInventory(event.data.orderId, event.data.items);
});
// Step 2: Charge the payment method
const charge = await step.run("charge-payment", async () => {
return await chargeCustomer(event.data.paymentInfo, reservation.total);
});
// Step 3: Send confirmation email
await step.run("send-email", async () => {
await sendConfirmation(event.data.email, {
orderId: event.data.orderId,
items: event.data.items,
total: reservation.total,
});
});
// Step 4: Update analytics (fire‑and‑forget)
await step.run("track-analytics", async () => {
await trackAnalytics("order_completed", {
orderId: event.data.orderId,
revenue: reservation.total,
});
});
return { success: true };
}
);
Notice how each step.run call is isolated. If the payment gateway times out, only the “charge‑payment” step is retried, while the inventory reservation remains intact. Inngest also guarantees exactly‑once execution for idempotent services, which we’ll discuss later.
Emitting the Order Event
await orderCreated.send({
orderId: "ord_12345",
items: [{ sku: "TSHIRT-XL", qty: 2 }],
paymentInfo: { token: "tok_abcdef" },
email: "customer@example.com",
});
Real‑World Use Case #2: Chat Application Notifications
Realtime chat apps often need to push notifications to mobile devices, email, and Slack when a message arrives. Instead of coupling the notification logic to the WebSocket server, you can delegate it to Inngest.
Message Event Schema
export const messageSent = inngest.createEvent({
name: "chat/message.sent",
});
Notification Function
import { inngest } from "./inngest";
import { pushToFCM } from "./fcm";
import { sendEmail } from "./email";
import { postToSlack } from "./slack";
export const notifyOnMessage = inngest.createFunction(
{ name: "chat/notify" },
{ event: "chat/message.sent" },
async ({ step, event }) => {
const { messageId, content, channelId, mentions } = event.data;
// Push notification to mentioned users
await step.run("push-fcm", async () => {
await pushToFCM(mentions, {
title: "New mention",
body: content.slice(0, 100),
data: { messageId, channelId },
});
});
// Email fallback for offline users
await step.run("email-fallback", async () => {
await sendEmail(mentions, {
subject: "You were mentioned in a chat",
html: `${content}
`,
});
});
// Optional: post a summary to a Slack channel for monitoring
await step.run("slack-summary", async () => {
await postToSlack("#chat-monitor", {
text: `Message ${messageId} in ${channelId} mentioned ${mentions.length} users.`,
});
});
return { notified: true };
}
);
This decouples the real‑time socket layer from heavy notification work, improves latency, and gives you a single place to monitor failures.
Advanced Patterns
Retries & Backoff
Inngest automatically retries failed steps with exponential backoff up to a configurable limit. You can customise the retry policy per step:
await step.run(
"charge-payment",
async () => await chargeCustomer(...),
{ retry: { maxAttempts: 5, backoff: "exponential" } }
);
If you need a custom delay, you can throw new inngest.RetryAfter(seconds) to tell the platform to pause for a specific interval.
Idempotency
Because steps may be retried, any external call should be idempotent. A common pattern is to include the Inngest event.id as a deduplication key when writing to databases or third‑party APIs.
await step.run("store-order", async () => {
await db.orders.insert({
id: event.id, // guarantees uniqueness
...event.data,
});
});
Rate Limiting & Concurrency Control
If you have a downstream API that enforces a request‑per‑second quota, you can use the step.run concurrency options:
await step.run(
"call-external-api",
async () => await externalApi.call(...),
{ concurrency: { limit: 10 } }
);
This tells Inngest to schedule at most ten of those steps in parallel, automatically throttling excess calls.
Testing & Debugging Inngest Functions
Inngest ships with a local dev server that mimics the cloud environment. Run npx inngest dev in a separate terminal; it watches your source files, hot‑reloads functions, and provides a UI at http://localhost:8288.
For unit testing, you can invoke a function directly with a mock event payload. The SDK exposes the handler as a plain async function, making it trivial to test with Jest or Vitest.
import { orderWorkflow } from "./orderWorkflow";
test("order workflow succeeds", async () => {
const mockEvent = {
id: "evt_test",
name: "order/created",
data: {
orderId: "ord_1",
items: [{ sku: "BOOK", qty: 1 }],
paymentInfo: { token: "tok_123" },
email: "test@example.com",
},
};
const result = await orderWorkflow.handler({ event: mockEvent, step: mockStep });
expect(result.success).toBe(true);
});
The mockStep object can be a simple stub that records each run call, letting you assert that all expected steps were executed.
Pro tip: Use the Inngest UI’s “Replay” button to re‑run a failed event with the same payload. It’s invaluable when you fix a bug and want to verify the correction without re‑creating the whole upstream trigger.
Performance & Cost Considerations
Since Inngest stores every event, you pay for both event ingestion and function execution. The platform is priced per million events, which is usually cheaper than running a dedicated queue service plus compute. For high‑throughput workloads, batch events together or use a “bulk” event type to reduce overhead.
Latency is typically sub‑second for simple steps, but complex workflows that involve external APIs will be bound by those APIs’ response times. Inngest’s step‑level retries mean you don’t need to implement your own back‑off loops, which reduces code complexity and improves overall reliability.
Deploying Inngest Functions
Because Inngest functions are just regular TypeScript files, you can deploy them on any serverless platform that supports Node.js. The most common patterns are:
- Vercel Edge Functions: Create a file under
/api/inngestthat exports the Inngest client and all functions. - Netlify Functions: Use the
netlify/functionsdirectory and expose a single handler that forwards to Inngest. - Express / Fastify: Mount the Inngest HTTP handler on a route, e.g.,
app.post("/api/inngest", inngest.handler()).
All you need to ensure is that the endpoint is reachable from the Inngest backend (public URL or via a tunnel like ngrok during development).
Security Best Practices
Inngest signs every webhook request with a HMAC using your signing key. Verify the signature in your HTTP handler to prevent spoofed events. The SDK does this automatically if you use inngest.handler(), but custom servers should call inngest.verifyRequest(req) before processing.
Never store raw secrets in the event payload. Instead, reference a secret identifier and fetch the value from a secret manager (AWS Secrets Manager, GCP Secret Manager, etc.) inside the step.
Pro tip: Leverage Inngest’s built‑in “metadata” field to attach correlation IDs that trace a request across multiple services. This makes debugging across microservices much easier.
Monitoring & Observability
The Inngest dashboard provides a timeline view of each event, step status, retry count, and execution duration. You can also export logs to external services via the “log sink” integration (Datadog, New Relic, etc.). For programmatic monitoring, query the Events API to fetch metrics like “events processed per minute” or “failed steps”.
Combine this with custom metrics emitted from your steps (e.g., using Prometheus) to get a full picture of both platform‑level and application‑level health.
Common Pitfalls & How to Avoid Them
- Unbounded Payloads: Large blobs (e.g., images) should be