Inngest is the event-driven background job queue Oolvay ships with. For how it compares to scheduled jobs, see Background Jobs.
The motivating example in Oolvay today is bulk email. When a blog post is published, every eligible user who wants an email needs one sent. Doing that synchronously, inside the request that published the post, means the request does not finish until every email is sent, which does not scale once your user base grows past a handful of people. Inngest exists to take that work off the request thread entirely.
Inngest itself is general purpose. Nothing about the queue, the event, or the worker function is specific to email. The same pattern, send an event, have a worker react to it, works for anything you want to defer or run without blocking a response.
Every Inngest integration in this codebase follows the same shape.
export const postPublishedEvent
0 / 2,000 characters
The event, postPublishedEvent, defines both the event’s name ("post.published") and the shape of its payload, PostPublishedData, typed and validated through staticSchema. The client, inngest, is what you send events through, and what workers register against.
const retries = siteConfig.backgroundJobs.inngest.retries
export const sendPostNotificationsWorker = inngest.createFunction(
{
id: "send-post-notifications",
retries,
triggers: [postPublishedEvent],
},
async ({ event }) => {
await sendPostNotifications(event.data.postId)
return { status: "completed" }
}
)The worker is what actually does the work, in this case calling the exact same sendPostNotifications() function used elsewhere in the notifications system. retries controls Inngest’s own retry mechanism, separate from anything you might write yourself. By default, Oolvay reads this value from siteConfig.backgroundJobs.inngest.retries. If the worker throws, Inngest retries the whole function up to that number of times before giving up.
Workers are registered with Inngest’s own route handler, which both serves local development tooling and receives real invocations in production:
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [sendPostNotificationsWorker],
})dispatchPostNotification() is where a post publish actually triggers this whole chain. It does not call the worker function. It sends an event, and Inngest is what decides when and how the worker runs.
if (siteConfig.backgroundJobs.inngest.enabled) {
try {
await inngest.send(
postPublishedEvent.create({
postId: publishedPost.id,
})
)
} catch (error) {
logger.error("Failed to enqueue Inngest job", { ...context, error })
Sentry.withScope((scope) => {
scope.setFingerprint(["inngest", "dispatchPostNotification"])
scope.setTag("action", "dispatchPostNotification")
scope.setTag("postId", publishedPost.id)
scope.setTag("notificationType", publishedPost.notificationType)
scope.setContext("inngest", context)
Sentry.captureException(error)
})
}
} else {
await sendPostNotifications(publishedPost.id)
}This is the entire fallback story in one place. If inngest.enabled is true, the function fires off an event and returns immediately, regardless of whether the actual email sending takes two seconds or two minutes. If inngest.send() itself fails, that failure is logged and reported to Sentry, but the function does not throw, the publish action that called this is not blocked by a queueing failure.
If inngest.enabled is false, none of the above runs. sendPostNotifications() is called directly and awaited, the same function the worker would have called, just synchronously, on the calling request, with no queue involved.
Notice what is, and is not, covered by inngest.retries. That setting only
applies after an event has been successfully sent and picked up by Inngest. If
inngest.send() itself fails, for example because Inngest is unreachable or
misconfigured, that failure is caught separately in
dispatchPostNotification() and is not retried. A failed send is logged and
dropped, not requeued.
With inngest.enabled: false, every post publish that should trigger notifications still triggers them, correctly, every time. What changes is that the publishing request now waits for sendPostNotifications() to fully finish, including every batch of emails it sends, before the request completes. For a small number of recipients this is unnoticeable. For a large one, this is exactly the blocking behavior Inngest exists to remove.
This mirrors the same additive pattern as analytics and realtime delivery elsewhere in Oolvay. Disabling Inngest changes how work happens, not whether it happens.
backgroundJobs: {
inngest: {
enabled: true,
retries: 3,
},
},Controls whether dispatchPostNotification(), and any future code following the same pattern, sends an event to Inngest or runs the underlying work synchronously and in line. This is the only flag that matters for this decision. The three environment variables covered in Environment variables below govern how Inngest itself behaves once you have chosen to use it, not whether you are using it.
Controls the default number of times Inngest retries a worker when it throws an error.
backgroundJobs: {
inngest: {
enabled: true,
retries: 3,
},
},This value is used by the workers that ship with Oolvay, but it is not a hard requirement. Individual workers can use a different retry count if a particular job has different reliability or side effect requirements.
export const billingWorker = inngest.createFunction(
{
id: "billing-worker",
retries: 1,
triggers: [billingEvent],
},
async ({ event }) => {
// ...
}
)Use the global value when a worker should follow the application’s default retry policy. Override it when a specific job needs different behavior.
Three separate environment variables exist for Inngest. INNGEST_DEV decides which Inngest environment your app talks to. INNGEST_EVENT_KEY authenticates events going out. INNGEST_SIGNING_KEY authenticates invocations coming in.
INNGEST_DEV=1
INNGEST_EVENT_KEY=
INNGEST_SIGNING_KEY=INNGEST_DEV switches the SDK to your local Inngest dev server instead of Inngest’s hosted cloud, regardless of whether real credentials are present. It is a development convenience, not a feature flag, and it is unrelated to siteConfig.backgroundJobs.inngest.enabled, which controls whether Oolvay’s own code routes through Inngest at all. Enabling or disabling Inngest in config/site.ts does not change which Inngest environment you are talking to. INNGEST_DEV does.
INNGEST_EVENT_KEY authenticates outbound calls, your application sending events to Inngest. It is used wherever inngest.send() is called.
INNGEST_SIGNING_KEY authenticates inbound calls, Inngest invoking your worker functions through app/api/inngest/route.ts. It is checked by Inngest’s serve() handler, not by your own application code directly.
INNGEST_EVENT_KEY and INNGEST_SIGNING_KEY serve opposite directions of the same conversation and are not interchangeable.
INNGEST_SIGNING_KEY in particular should never be a dev or test value in
production. It is what proves an incoming request to /api/inngest actually
came from Inngest, not from anyone who discovers the route.
Create an account at inngest.com if you do not already have one, and create an app for this project.
Get your event key and signing key, and set dev mode.
In the Inngest dashboard, open your app’s settings and find the Event Keys and Signing Keys tabs. Copy the default keys, or create new ones, then add them to your environment variables alongside INNGEST_DEV.
INNGEST_DEV=1
INNGEST_EVENT_KEY=
INNGEST_SIGNING_KEY=Enable Inngest in config/site.ts.
backgroundJobs: {
inngest: {
enabled: true,
retries: 3,
},
},Run the Inngest dev server locally and start your app.
These two can run in either order, since the dev server keeps scanning for your app until it finds it. Run both, in separate terminals.
# Install the CLI, follow the steps to complete install
curl -sSfL https://cli.inngest.com/install.sh | sh
# Run the dev server
inngest devIf you are using Bun, prefer the bash installer over bunx. Bun skips the lifecycle script the npm package relies on to fetch the CLI binary, which causes bunx inngest-cli@latest dev to fail outright. See Common issues if the installer itself fails to fetch a binary.
In a separate terminal, start your app.
Confirm the two are connected.
Open http://localhost:8288/apps. Your app should appear there, auto-detected, with at least one function found. If it does not appear, confirm INNGEST_DEV=1 was set before you started your app, since environment variables are only read once, at startup, and a value added afterward requires a restart.
Publish a blog post that calls dispatchPostNotification(), and confirm the event appears in the Inngest dev server dashboard and the worker runs.
The shape is the same every time, regardless of what the job actually does.
Define the event and its payload shape in lib/inngest/client.ts.
type ReportGeneratedData = {
reportId: string
requestedBy: string
}
export const reportGeneratedEvent = eventType("report.generated", {
schema: staticSchema<ReportGeneratedData>(),
})Write the worker function in lib/inngest/functions.ts, calling whatever logic should actually run.
const retries = siteConfig.backgroundJobs.inngest.retries
export const generateReportWorker = inngest.createFunction(
{
id: "generate-report",
retries,
triggers: [reportGeneratedEvent],
},
Register the new worker in app/api/inngest/route.ts.
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [sendPostNotificationsWorker, generateReportWorker],
})A worker that exists in lib/inngest/functions.ts but is not added to this array will never run, regardless of how many events are sent to it.
Send the event from wherever the triggering action happens, with the same enabled and disabled branches as the existing pattern.
if (siteConfig.backgroundJobs.inngest.enabled) {
try {
await inngest.send(
reportGeneratedEvent.create({
reportId: report.id,
requestedBy: userId,
})
)
} catch (error) {
INNGEST_DEV=1 and the local dev server are both strictly for local development. Production does not use either.
Remove INNGEST_DEV from your production environment entirely, or set it explicitly to 0. If neither the environment variable nor the equivalent client config option is specified, the SDK defaults to cloud mode, so an unset INNGEST_DEV in production is safe, but an INNGEST_DEV=1 left over from a copied .env file is not, since it would point your production app at a dev server that does not exist there.
If you deploy on Vercel, install Inngest’s official Vercel integration rather than copying INNGEST_EVENT_KEY and INNGEST_SIGNING_KEY into Vercel’s environment variables yourself. The integration sets both automatically, and resyncs your app to Inngest on every deploy, so your keys never drift out of date the way a manually copied value can.
If you deploy somewhere other than Vercel, set both manually instead.
INNGEST_EVENT_KEY=
INNGEST_SIGNING_KEY=Vercel enables Deployment Protection by default on both preview and production URLs. Inngest’s servers cannot reach your /api/inngest endpoint while this is on, since Inngest authenticates using your signing key rather than a Vercel-issued session.
On Vercel’s free tier, disable Deployment Protection for the project. On Vercel’s Pro plan, configure Protection Bypass for Automation instead, and add the resulting secret to the Inngest Vercel integration’s settings so Inngest can use it to reach your app without disabling protection entirely.
Add maxDuration directly to your existing route handler.
export const runtime = "nodejs"
export const maxDuration = 300
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [sendPostNotificationsWorker],
})Then set maxRuntime on the client itself, twenty to forty percent below maxDuration. With maxDuration at 300 seconds, a maxRuntime of 200 seconds sits comfortably inside that range.
export const inngest = new Inngest({
id: "blog-notifications",
checkpointing: {
maxRuntime: "200s",
},
})Checkpointing is enabled by default on the v4 SDK, and lets multiple steps run inside a single request instead of round-tripping to Inngest after every step. Without a maxRuntime set below your platform’s actual timeout, a long-running function risks hitting Vercel’s own timeout before Inngest gets a chance to checkpoint and hand off cleanly.
Double check INNGEST_DEV specifically when promoting a .env.local file or
copying environment variables between environments. A stray INNGEST_DEV=1 in
production silently misroutes events to a server that is not running, and the
failure is easy to miss since inngest.send() itself will still appear to
succeed.
Confirm the worker is actually included in the functions array passed to serve() in app/api/inngest/route.ts. A worker defined but not registered there receives nothing.
Check INNGEST_SIGNING_KEY specifically. This is the credential Inngest uses to authenticate its calls into your application, and a missing or incorrect value in production will cause invocations to fail even though sending events from your application appears to succeed.
This is expected, not a bug. With inngest.enabled: false, the work that would have run on Inngest’s infrastructure now runs synchronously, inside the request that triggered it. See Disabling Inngest does not lose functionality, it loses concurrency above.
This is correct, and worth understanding precisely. Inngest’s dashboard only shows jobs it actually received. If inngest.send() itself fails, the event never reached Inngest, so there is nothing for Inngest to show. That failure is reported through your own logger and Sentry instead, inside dispatchPostNotification(), not inside Inngest’s infrastructure.
curl -sSfL https://cli.inngest.com/install.sh | shcan fail with curl: (22) The requested URL returned error: 404, pointing at a release zip that does not exist for that version. This is an issue with Inngest’s own installer script or release assets for that specific release, not with your project, and is not confirmed to be limited to any one operating system.
If you already have an inngest binary installed from a previous attempt, inngest dev still works despite the failed install, the script’s only job is to fetch a newer binary, and an older one already on your PATH is unaffected by that failure.
If you have no binary installed yet, skip the script and download the Windows zip directly from Inngest’s GitHub releases, using whichever release actually has a Windows asset attached. Extract inngest.exe and add it to your PATH.
Background jobs, like analytics, realtime delivery, and logging, are additive. Disabling Inngest does not change what gets sent, generated, or processed, only whether that work happens off the request thread or on it.
bun devFollowing this branching pattern, rather than calling inngest.send() unconditionally, is what keeps a new job working correctly even for a founder who has not enabled Inngest at all.