Inngest: The Workflow Engine That Calls You (Instead of the Other Way Around)
Hook
Most workflow engines force you to run workers that poll for jobs. Inngest flips this model entirely: it calls your serverless functions over HTTPS, eliminating worker infrastructure while enabling workflows that pause for days.
Context
Building reliable background jobs has always involved uncomfortable tradeoffs. Simple queues like Redis or RabbitMQ make you manually handle retries, state persistence, and complex patterns like debouncing or waiting for external events. Orchestration platforms like Temporal solve these problems but require running dedicated worker processes, managing connection pools, and learning complex programming models.
Serverless functions offered a reprieve from infrastructure management, but they came with hard timeout limits that made multi-step workflows nearly impossible. You couldn't pause a Lambda function for an hour waiting for a webhook, then resume where you left off. This pushed developers toward fragile state machines built with Step Functions or Durable Functions—solutions that traded deployment simplicity for vendor lock-in and limited language support. Inngest emerged from this gap: what if you could write durable, resumable workflows as normal functions in your existing codebase, without managing any worker infrastructure or sacrificing the deployment flexibility of serverless?
Technical Insight
Inngest's core innovation is its callback-based execution model combined with step primitives. Unlike traditional queue workers that pull jobs, Inngest pushes execution requests to your functions via HTTPS POST. Each function you write exposes an endpoint that Inngest calls when events match your triggers. Here's what a multi-step workflow looks like:
import { inngest } from './client';
export default inngest.createFunction(
{ id: 'process-video-upload' },
{ event: 'video/uploaded' },
async ({ event, step }) => {
const transcoded = await step.run('transcode-video', async () => {
return await transcodeService.process(event.data.url);
});
const thumbnail = await step.run('generate-thumbnail', async () => {
return await thumbnailService.create(transcoded.url);
});
await step.sleep('wait-for-processing', '5m');
const analysis = await step.run('analyze-content', async () => {
return await mlService.analyze(transcoded.url);
});
await step.sendEvent('send-complete-event', {
name: 'video/processed',
data: { videoId: event.data.id, analysis }
});
}
);
Each step.run() call creates a resumption point. When the function executes, Inngest calls your endpoint and runs the first step. It stores the result, then calls your function again with that memoized output. The function replays up to the second step, executes it, and exits. This continues until completion. If any step fails, only that step retries—not the entire function. The step.sleep() primitive tells Inngest to pause execution and schedule a callback in five minutes, enabling workflows that span hours or days without keeping connections open.
The architecture separates concerns elegantly. The Event API receives events from your application via SDK calls or webhooks. These flow into an internal event stream (backed by Redis or Kafka in production). The Runner component reads this stream and evaluates trigger conditions against registered functions. When a match occurs, the Runner schedules an execution request in the Queue system—a multi-tenant queue that handles concurrency limits, rate limiting, throttling, and debouncing declaratively:
export default inngest.createFunction(
{
id: 'send-notification',
concurrency: [
{ scope: 'account', limit: 5 }, // Max 5 per account
{ scope: 'fn', limit: 100 } // Max 100 across all accounts
],
rateLimit: {
limit: 10,
period: '1m',
key: 'event.data.userId'
},
debounce: {
period: '5s',
key: 'event.data.userId'
}
},
{ event: 'user/action' },
async ({ event, step }) => {
// This only runs respecting all flow control rules
await step.run('send', () => sendEmail(event.data.userId));
}
);
The Queue hands jobs to the Executor, which makes HTTPS POST requests to your function endpoints. Your function returns the step results, which the Executor persists to the state store. This state includes not just return values but also execution history, enabling the replay mechanism. The platform tracks which steps completed successfully, so retries skip already-finished work.
Event-driven coordination takes this further with waitForEvent patterns. You can pause mid-execution until another event arrives:
await step.waitForEvent('wait-for-approval', {
event: 'workflow/approved',
timeout: '7d',
match: 'data.workflowId', // Match against original event
if: 'async.data.approved == true'
});
This enables complex state machines without explicit state management. A user signup workflow can wait for email verification, timing out after 24 hours if unverified. An approval workflow can pause indefinitely until a manager clicks "approve" in your UI, which sends an event that resumes execution. The cancelOn option lets external events terminate in-progress runs, useful for canceling payment processing if a user cancels their order.
The development experience mirrors production. Running npx inngest-cli dev starts a local event server and dashboard at localhost:8288. Your application sends events to this local endpoint, and you see real-time execution logs, step outputs, and retry history in the UI. This eliminates the typical workflow testing problem where you can't see what's happening inside orchestration logic without deploying to staging environments.
Gotcha
The HTTPS callback requirement creates practical constraints. Your functions must be publicly accessible or reachable by Inngest's infrastructure. This works seamlessly for Vercel, Netlify, or traditional servers, but complicates scenarios like VPC-only lambdas or internal-only Kubernetes services. You'll need ingress controllers, VPNs, or Inngest's self-hosted option. The callback overhead also adds latency—each step involves a round-trip HTTPS call plus state persistence. For high-frequency operations (thousands per second), this becomes expensive compared to in-memory processing in worker-based systems.
The replay mechanism, while powerful, requires understanding its implications. Steps must be deterministic and idempotent. Generating random UUIDs, reading current timestamps, or making non-memoized API calls inside step functions (but outside step.run() blocks) will produce different values on replay, causing subtle bugs. You must wrap all side effects in step primitives. Self-hosting eliminates the callback restriction but introduces operational complexity—you're running an event stream, queue system, state store, cache, and API server. For small teams, the managed cloud offering's simplicity often outweighs self-hosting benefits unless you have strict data residency requirements.
Verdict
Use if: You're building SaaS workflows where reliability matters more than millisecond latency—user imports, report generation, onboarding sequences, integration syncs, or approval flows. Your team values deploying functions alongside application code rather than managing separate worker infrastructure. You need sophisticated flow control (per-customer concurrency, debouncing, rate limiting) without writing custom queue logic. You want production-quality observability and retry behavior without building it yourself. Skip if: You need sub-100ms execution latency for high-frequency jobs where callback overhead is prohibitive. Your functions run in isolated networks where exposing HTTPS endpoints is impractical and self-hosting isn't an option. You're building simple single-step jobs where BullMQ or SQS would suffice. You need the extreme durability guarantees and local worker model of Temporal for mission-critical financial or healthcare workflows where eventual consistency isn't acceptable.