Inngest: Build Durable, Event-Driven Functions and Workflows Without Managing Queues
Hey friends, if you have ever built an application that needs to run work in the background, like sending an email after a user signs up, running billing every month, or processing a long video job, you have surely hit the classic problem: how do you make the process reliable? What happens if the server dies mid-way? What if one step fails and needs a retry, but you do not want the earlier steps to run again? The usual answer is to spin up a message queue like RabbitMQ, Redis, or SQS, then build workers, then manage retry logic yourself, then worry about dead letter queues, and so on. Exhausting, right?
In this tutorial I want to introduce you to Inngest. Inngest is a platform for building durable, event-driven functions and workflows without you having to manage queue infrastructure yourself. The core idea is simple: you send an event, that event triggers a function, and inside the function you break the work into steps that are each durable and retried independently. So if the third step fails, the first and second steps are not re-run, their results are already saved by Inngest. Pretty cool, right?
I am going to cover it from the basic concept, how to install it with Python, how to define a function, how to use steps for durability, sleeping, waiting for another event, sending events, all the way to running the dev server for local testing. Let's get started.
Why Do You Need Inngest?
Before jumping into code, I want you to understand the problem Inngest is trying to solve. Imagine you have a user onboarding flow like this: a user signs up, then you want to send a welcome email, then wait three days, if the user has not completed their profile send a reminder, then wait seven days, if they are still inactive send a special email. If you build this the traditional way, you need cron jobs, a database table to store state, logic to check which stage a user has reached, and error handling at every point. It is messy and easy to leak.
With Inngest, all of that becomes a single function that looks like ordinary code from top to bottom. You write "send email, sleep three days, check profile, send reminder", and Inngest handles the persistence. When your function "sleeps" for three days, it does not actually hold the process for three days. Inngest saves the state, the function stops, and three days later Inngest wakes the function back up from exactly the right spot. This is what is called durable execution.
A few things that make Inngest appealing to me:
- No need for your own queue infra. You do not have to set up Redis or RabbitMQ. Inngest is the orchestrator.
- Automatic per-step retries. If a step throws an exception, Inngest retries just that step with backoff, not the whole function from scratch.
- Built-in flow control. There is throttling, rate limiting, debounce, concurrency limits, all configurable via decorators.
- Event-driven. Functions are triggered by events, so your architecture becomes loosely coupled. One event can trigger many functions.
- Great local dev. There is a dev server that runs on your laptop with a UI to see events and function runs in real time.
Installation
Alright, let's install it. Inngest has an official SDK for Python, and installing it is totally standard with pip. I recommend always using a virtual environment to keep things tidy.
# Create a virtual environment first
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
Install the Inngest SDK
pip install inngest
We also need a web framework and server to serve functions.
Here I use FastAPI + uvicorn, but Flask/Django are supported too.
pip install fastapi uvicorn
Besides the Python SDK, you need the Inngest Dev Server for local development. The dev server runs through npx (requires Node.js installed), and it is the brain that will trigger your functions during development. We will cover how to run it later in the dev server section. For now, make sure two things are installed: the Python SDK and Node.js for npx.
To check the installed Inngest version, you can run:
pip show inngest
Basic Usage
Now we get to the fun part. I am going to show you how to create an Inngest client, define your first function, and serve that function via FastAPI. This is the foundation you must understand before moving on to the advanced features.
Create the Inngest Client
The first step is to create an instance of the Inngest client. This client is the control center: it is used to define functions and to send events. You only create it once and use it everywhere.
import inngest
appid is the unique identity of your application in Inngest
inngestclient = inngest.Inngest(
appid="myapp",
isproduction=False, # False so it connects to the local dev server
)
appid is important because Inngest uses it to group the functions you own. isproduction=False tells the SDK that we are in development, so it will connect to the dev server on localhost rather than Inngest Cloud.
Define Your First Function
A function in Inngest is defined with the @inngestclient.createfunction decorator. You tell Inngest three things: the function's ID, which event triggers it, and the logic inside. Let's build a function that is triggered when a new user signs up.
@inngestclient.createfunction(
fn
id="welcome-email",
trigger=inngest.TriggerEvent(event="app/user.signup"),
)
async def welcomeemail(ctx: inngest.Context) -> str:
# ctx.event.data holds the payload we send when triggering the event
email = ctx.event.data["email"]
name = ctx.event.data["name"]
ctx.logger.info(f"Sending welcome email to {email}")
# (we will replace this with a step to make it durable)
sendemail(to=email, subject=f"Hi {name}!", body="Welcome!")
return f"Email sent to {email}"
Notice a few things here. fnid is a unique ID for the function, used to identify it in the dashboard. trigger=inngest.TriggerEvent(event="app/user.signup") says this function runs every time an event named app/user.signup occurs. The function receives one argument ctx (context), which contains the event data, a logger, and the step object we will use in a moment.
The event naming convention is usually the format namespace/noun.verb, for example app/user.signup, billing/invoice.paid, or video/upload.completed. This is not a hard rule, but it makes your events tidier and easier to read.
Serve Functions via FastAPI
The function is defined, but Inngest does not know it exists yet. We need to "serve" the function to our web server, so the Inngest dev server can discover and call it over HTTP. Here is how with FastAPI:
import inngest.fastapi
from fastapi import FastAPI
app = FastAPI()
Register all functions with FastAPI at the /api/inngest endpoint
inngest.fastapi.serve(
app,
inngestclient,
[welcomeemail], # list of all functions to serve
)
Now run the server:
uvicorn main:app --reload --port 8000
Inngest will create an /api/inngest endpoint in your app. This endpoint is what the dev server uses for discovery (figuring out which functions you have) and for calling functions when an event comes in. So the architecture is: an event arrives at Inngest, Inngest calls your endpoint over HTTP to execute the function step by step.
Sending Events
Our function is triggered by an event, so now we need to know how to send events. You send an event using inngestclient.send. Usually you call this from another route in your app, for example in your registration endpoint.
@app.post("/signup")
async def signup(email: str, name: str):
# Save the user to the database first (your business logic)
# ...
# Then send the event to Inngest
await inngestclient.send(
inngest.Event(
name="app/user.signup",
data={"email": email, "name": name},
)
)
return {"status": "ok"}
As soon as the app/user.signup event is sent, Inngest will automatically trigger our welcomeemail function. The cool part is that sending the event is fire-and-forget. Your /signup route returns a fast response immediately, and the email processing runs in the background. The user does not have to wait.
You can also send many events at once by passing a list:
await inngestclient.send([
inngest.Event(name="app/user.signup", data={"email": "a@mail.com", "name": "Andy"}),
inngest.Event(name="app/user.signup", data={"email": "b@mail.com", "name": "Bob"}),
])
Advanced Usage
Now we get to the heart of Inngest: steps. This is what makes Inngest different from an ordinary background job. I will cover step.run, step.sleep, and step.waitforevent.
step.run: Creating a Durable Step
The most important concept in Inngest is the step. A step is a chunk of work whose result is saved by Inngest. If your function runs again because of a retry, the steps that already succeeded will not be re-run. Their results are pulled from cache. This is what is called durability.
Imagine a function with three jobs: fetch user data, charge a credit card, send a receipt. If sending the receipt fails because the email server is down, you do not want to charge the card twice, right? With steps, the charge only runs once, its result is saved, and on retry only the send-receipt step is repeated.
@inngestclient.createfunction(
fn
id="process-order",
trigger=inngest.TriggerEvent(event="shop/order.created"),
)
async def processorder(ctx: inngest.Context) -> dict:
orderid = ctx.event.data["orderid"]
# Step 1: fetch order details from the database
order = await ctx.step.run(
"fetch-order",
lambda: getorderfromdb(orderid),
)
# Step 2: charge payment. If step 3 fails, this is NOT repeated.
charge = await ctx.step.run(
"charge-payment",
lambda: chargecard(order["customerid"], order["total"]),
)
# Step 3: send the receipt email
await ctx.step.run(
"send-receipt",
lambda: sendemail(order["email"], f"Receipt #{charge['id']}"),
)
return {"orderid": orderid, "chargeid": charge["id"]}
An important rule you must remember: anything with a side effect (calling an API, writing to a database, sending an email) must be wrapped in step.run. Why? Because code outside a step can run multiple times each time the function is re-invoked by Inngest. Only code inside a step is guaranteed to run once and have its result saved. The first argument of step.run is the step ID which must be unique within the function, and the second argument is the function you want to run.
If a step throws an exception, Inngest automatically retries that step with exponential backoff. By default it retries up to 4 times. You can adjust the number of retries at the function level:
@inngestclient.createfunction(
fn
id="process-order",
trigger=inngest.TriggerEvent(event="shop/order.created"),
retries=2, # retry at most 2 times per step
)
async def processorder(ctx: inngest.Context) -> dict:
...
step.sleep: Delaying Without Holding the Process
Sometimes you want to delay something. For example, send a follow-up email three days after a user signs up. With Inngest you just use step.sleep, and the magical part is that this does not hold any process or resource during that time.
import datetime
@inngestclient.createfunction(
fnid="onboarding-drip",
trigger=inngest.TriggerEvent(event="app/user.signup"),
)
async def onboardingdrip(ctx: inngest.Context) -> None:
email = ctx.event.data["email"]
# Send the welcome email immediately
await ctx.step.run("welcome", lambda: sendemail(email, "Welcome!"))
# Sleep for 3 days. The function stops, state saved by Inngest.
await ctx.step.sleep("wait-3-days", datetime.timedelta(days=3))
# 3 days later, Inngest wakes the function from here
await ctx.step.run("tips", lambda: sendemail(email, "Tips for using our product"))
# Sleep another week
await ctx.step.sleep("wait-1-week", datetime.timedelta(weeks=1))
await ctx.step.run("checkin", lambda: sendemail(email, "How's it going?"))
When the function reaches step.sleep, Inngest saves the execution position and stops. There is no thread waiting, no connection kept open. When the time comes, Inngest calls your endpoint again and the function resumes from exactly the right spot. So you can build flows that run for days or weeks without thinking about cron or schedulers.
Besides step.sleep which uses a duration, there is also step.sleepuntil if you want to sleep until a specific absolute time:
target = datetime.datetime(2026, 12, 25, 9, 0, 0)
await ctx.step.sleepuntil("wait-until-christmas", target)
step.waitforevent: Waiting for Another Event
This is a feature I really love. Sometimes your workflow needs to wait for something to happen elsewhere. For example, after sending an invoice you want to wait for a payment event. If it is not paid within 24 hours, send a reminder. step.waitforevent makes this easy.
@inngestclient.createfunction(
fnid="invoice-flow",
trigger=inngest.TriggerEvent(event="billing/invoice.sent"),
)
async def invoiceflow(ctx: inngest.Context) -> str:
invoiceid = ctx.event.data["invoiceid"]
# Wait for the payment event, at most 24 hours
payment = await ctx.step.waitforevent(
"wait-for-payment",
event="billing/invoice.paid",
timeout=datetime.timedelta(hours=24),
# only match the event with the same invoiceid
ifexp=f"async.data.invoiceid == '{invoiceid}'",
)
if payment is None:
# timeout: no payment within 24 hours
await ctx.step.run(
"send-reminder",
lambda: sendemail("payment reminder"),
)
return "reminder sent"
# payment received
await ctx.step.run("send-thanks", lambda: sendemail("thanks for paying"))
return "payment received"
A few important things about waitforevent. The event parameter is the name of the event being waited for. timeout is how long to wait before giving up. If the timeout is reached without the event, the return value is None, so you must check for that. The ifexp parameter is an expression for matching, so this function waits for the correct event, not just any payment event. In the example above, we only want the payment event whose invoiceid matches the invoice we sent.
step.sendevent: Sending Events From Inside a Function
You can also send events from inside a function using step.sendevent. This is useful for making functions trigger each other, building a truly loosely coupled event-driven architecture. Unlike the regular inngestclient.send, step.sendevent is durable, so it is safe from duplication during retries.
@inngestclient.createfunction(
fnid="handle-signup",
trigger=inngest.TriggerEvent(event="app/user.signup"),
)
async def handlesignup(ctx: inngest.Context) -> None:
userid = ctx.event.data["userid"]
await ctx.step.run("create-profile", lambda: createprofile(userid))
# Trigger another function via an event
await ctx.step.sendevent(
"emit-profile-created",
inngest.Event(
name="app/profile.created",
data={"userid": userid},
),
)
Flow Control: Concurrency, Throttle, and Rate Limit
One of the strengths of Inngest is flow control that you just configure via decorators. For example, if you call an external API that only allows 5 concurrent requests, you can limit concurrency:
@inngestclient.createfunction(
fn
id="call-external-api",
trigger=inngest.TriggerEvent(event="api/task.queued"),
concurrency=[
inngest.Concurrency(limit=5), # at most 5 concurrent runs
],
throttle=inngest.Throttle(
limit=100,
period=datetime.timedelta(minutes=1), # at most 100 per minute
),
)
async def callexternalapi(ctx: inngest.Context) -> None:
await ctx.step.run("call", lambda: hitexternalapi(ctx.event.data))
This way you do not need to build your own semaphore or rate limiter. Inngest manages the queueing and pacing for you.
Running the Dev Server
During development, you need the Inngest Dev Server. This dev server simulates Inngest Cloud but runs locally on your laptop, complete with a UI to see events and function runs. Running it is easy, just use npx (requires Node.js):
npx inngest-cli@latest dev
By default the dev server runs at http://localhost:8288. Open that URL in your browser and you will see a dashboard showing all registered functions, incoming events, and the run history of each function complete with details of each step.
The development flow looks like this:
uvicorn main:app --reload --port 8000).npx inngest-cli@latest dev)./api/inngest endpoint in your app and registers all functions.If the dev server does not automatically find your app, you can tell it the URL explicitly:
npx inngest-cli@latest dev -u http://localhost:8000/api/inngest
The replay feature in the UI is also super handy. If a function fails, you can see which step errored, fix the code, then replay the same run without having to recreate the event from scratch. This makes debugging complex workflows much faster.
Best Practices
After using Inngest on a few projects, there are some lessons I want to share so you do not fall into the same holes I did.
Wrap all side effects in a step. This is rule number one. Code outsidestep.run can run multiple times each time the function is re-invoked. So if you call an API or write to a database outside a step, you risk doubling. Always wrap it in a step.
Make step IDs unique and descriptive. Step IDs are used by Inngest to save and retrieve results. If two steps have the same ID, their results can get swapped. Use clear IDs like charge-payment, send-welcome-email, not step1, step2.
Make your steps idempotent. Even though Inngest guarantees a step runs once under normal conditions, there are extreme cases (for example the server dying right after a step finishes but before its result is saved) where a step could run twice. So make your operations idempotent, for example using an idempotency key when charging a payment.
Break work into reasonable steps. Do not build one giant step that does everything. Break it into logical units so retries are granular and you can see progress on the dashboard. But also do not overdo it with tons of micro steps that make things complicated.
Always check the result of waitforevent. If the timeout is reached, waitforevent returns None. Forgetting to check this is a common source of bugs. Always handle the timeout case explicitly.
Do not store large data as a step return. Step results are saved by Inngest, so if you return a giant object, that is wasteful. Just store a reference (like an ID or URL) and fetch the actual data when needed.
Separate event data from logic. Event data should carry just enough info to trigger the work, not your entire application state. Send an ID, then fetch the full details inside a step.
Use flow control to protect downstream resources. If your function calls a third-party API with a rate limit, use throttle or concurrency so you do not get banned. This is far cleaner than building your own rate limiter.
Conclusion
Friends, Inngest is a really elegant solution to a problem that has long been a headache, namely building reliable background processes without having to manage queue infrastructure yourself. The core idea is just three things: events trigger functions, functions are broken into steps, and each step is durable with independent retries. From these three simple concepts you can build complex flows, from multi-day onboarding drips, to payment flows that wait for confirmation, to processing pipelines that are resilient.
What I love about Inngest is that the code looks straight from top to bottom, like writing an ordinary function, but under the hood it is durable and can survive a server restart. You no longer have to think about state tables, cron jobs, or dead letter queues. All of that is handled by Inngest. Add to that a nice local dev server for testing and debugging, and the developer experience really shines.
My advice is to start small. Build one simple function triggered by an event, add step.run for its side effects, then run the dev server and see how the run looks on the dashboard. Once you understand the basic pattern, add step.sleep and step.waitforevent to make your flows come alive. From there you will feel for yourself just how much boilerplate you used to write that is now unnecessary. Happy building, and I hope this tutorial helps you, friends!