If one of your SaaS jobs has more than one step that can fail on its own, count those steps before you pick a tool. That count decides whether a plain queue is enough or whether a durable workflow platform like Inngest earns its place. A single webhook handler that syncs one field needs no such platform. A flow that enriches a lead, waits for a human approval and then sends an email probably does.
BookBed sits at the simple end. Its case study lists Stripe webhook handlers and a trial lifecycle on a Flutter, Firebase and Stripe stack, and in my judgment none of that needs a durable-execution platform. This post covers where Inngest fits for background jobs, what its pricing page says today, and how to move one job over without a big-bang rewrite.
What are Inngest background jobs, and when does a SaaS need them?

Inngest background jobs are functions that run in response to an event or a cron schedule, with every step.run() call retried on its own, so a failure partway through repeats only that step and not the whole function. The Inngest background jobs guide describes the model: you send an event with inngest.send(), Inngest finds the functions triggered by that event and runs them in the background, and you don't create queues, workers or subscriptions yourself.
Two details from the docs change how you write code. First, when step.run() finishes successfully, its response is saved in the function run state and the step doesn't run again. Second, by default Inngest retries a function or a step up to 4 times in addition to the initial attempt, with exponential backoff and jitter, and each step keeps its own retry counter.
The third piece is waiting. step.sleep() and step.sleepUntil() pause a run without consuming compute, and step.waitForEvent() pauses until a matching event arrives, with an optional timeout. The same steps page says functions can sleep for at most a year, and seven days on the free plan. That's enough for a trial-expiry reminder or a human approval, and it removes the polling table most queue-based systems eventually grow.
Why do queues guarantee delivery but not completion?

A queue guarantees a job reaches a worker, and it says nothing about whether the multi-step process behind that job finishes. Inngest's own engineering blog makes this argument and names BullMQ, Celery and Sidekiq as queues that guarantee delivery. It also offers a test: if you can describe the work as a single verb, such as send, process, sync or generate, it's probably job-shaped. That's the vendor's heuristic, so treat it as a starting point and not a verdict.
For atomic work, a plain queue is the right tool. Send a receipt email, write one row, ping a webhook, and if it fails, retry the whole thing cheaply. Adding step-level durability to a job with no intermediate state to lose is ceremony.
The distinction matters once a job has a middle. Consider a two-person team building lead outreach (a hypothetical, not a client). The flow enriches a lead, scores it, drafts an email, waits for a human to approve it, then sends it. On a raw queue that's five or six chained jobs, your own state table tracking which one is done, and a poller watching for the approval. As one Inngest function, each step.run() retries independently and the approval wait holds no worker. The same Inngest post warns about the catch: if step one charged a payment and step four failed, you may charge twice on resume. Durable retries only work when each step is safe to run twice.
When do you not need Inngest at all?
You don't need Inngest when the whole job is one short action with a clear retry story. Pizzeria Bestek is the example I'd point to. Per its case study, three Supabase Edge Functions run the admin flow, including one that sends the order emails and one that the CONFIRM and DECLINE buttons call, at five to ten orders a day. At that volume and job shape, a durable platform would solve a problem the business doesn't have.
The same logic applies if you already run Supabase Edge Functions for latency-sensitive endpoints. Keep them there. Inngest is for the durable, multi-step, can-wait work next to them, and a job triggered by a third-party webhook works the same way as one triggered by an internal event, only the trigger changes.
Inngest vs BullMQ: which one fits your stack?

Inngest fits serverless deployments with multi-step or long-waiting workflows, and BullMQ fits teams that already run Redis and want direct control over queue behaviour. Both solve real problems, and neither is better in the abstract. The comparison below sticks to what each vendor's own docs state.
| Inngest | BullMQ | |
|---|---|---|
| What it is | Event-driven platform, cloud-orchestrated or self-hosted as a single binary | A Node.js library built on Redis |
| Infrastructure | No queue or worker to run yourself on the cloud plan | Redis you run or rent, plus your own worker processes |
| Retries | Per step, default 4 retries plus the initial attempt | Retries of failed jobs |
| Long waits | step.sleep(), step.sleepUntil(), step.waitForEvent() | Delayed jobs |
| Multi-step work | Steps inside one function | Flows with parent-child dependencies |
| Extras | Cron triggers, per-step state | Rate limiting, priorities |
| Pricing | Free plan at $0, then paid tiers | Library plus your own Redis bill |
| Best fit | Serverless, multi-step or AI workflows | Existing Redis, high-throughput, priority control |
Notice that BullMQ isn't limited to single-step work, since its docs list flows with parent-child dependencies. The real difference is where job state lives and who operates it. With BullMQ, you own Redis and the worker fleet. With Inngest cloud, the platform holds the run state, and you pay for that with metered executions. If you already have Redis and a team that's comfortable running it, BullMQ is a sound default. If you deploy to serverless and your jobs wait on people or external systems, Inngest removes a whole layer.
How much does Inngest cost, and what does self-hosting give you?
Inngest's pricing page lists a Free plan at $0 with 50k executions a month and 5 concurrent steps, Pro from $99 a month with 1M executions, and Business from $499 a month with 10M. Free also carries a 256 KiB event size limit and 24-hour trace history, per the same page (checked 30 September 2026).
Why does the execution count catch people out?
Because an execution is metered per function run and per step inside that run. The pricing page's own example: a function with 5 step.run() calls uses 6 executions, one for the run and five for the steps. Take a five-step function that runs 300 times a day. That's 1,800 executions a day, and you pass the 50,000 free monthly allowance around day 28. Sketch your real step count per function before you assume the free plan covers an MVP.
Two more free-plan details are worth knowing. The cron docs say a scheduled function that fails 20 times in a row is automatically paused on the free plan, and they recommend UTC over local time zones because schedules near daylight-saving changes can run zero, one or two times in a day.
Is self-hosting Inngest a real escape hatch?
It's a real option with clear limits you should read before you commit. The self-hosting docs describe a single binary that includes all Inngest services. By default it uses SQLite for persistence and an in-memory Redis server for the queue and state store, and it needs an event key and a signing key to start. The same page states that Inngest's support team doesn't guarantee direct support for self-hosted instances, and it offers a Helm chart described as production-ready Kubernetes deployments.
My reading, which is an inference: the in-memory default suits local development, and the Helm chart is the path for anything that must survive restarts. Check the persistence options in the docs before you put durable jobs on a default install. If you self-host expecting Inngest Cloud minus the bill, you'll be operating the platform yourself.
How do you migrate a BullMQ job to Inngest?
Migrate one job at a time: pick a job with real multi-step state, wrap each unit of work in its own step.run(), and run the old and new paths side by side before you cut over. This works on most Next.js or serverless backends, and it's a recommendation, not a guarantee.
- Pick a job with real intermediate state. A single-step email send proves nothing about whether the migration pays off.
- Replace the queue's
add()call with an Inngest event send, keeping your existing payload shape so the rest of the app stays untouched. - Wrap each unit that has its own try/catch and its own "mark this step done" flag in a separate
step.run(). - Turn any manual delay, cron poll or "check again in an hour" loop into
step.sleep()orstep.sleepUntil(), and delete the polling table it replaces. - Run the old queue path and the new function in parallel behind a flag for a full billing cycle, and compare completion rates.
- Retire the old worker only after the parallel run shows equal or better completion.
Don't migrate everything at once. You'd spend a week debugging which system owns a job's state.
Idempotency comes first for anything that touches money. Stripe's API supports idempotency keys so a repeated request returns the saved result of the first one instead of performing the operation twice, and all POST requests accept them. Pass a key derived from your step's identity, and a retried step can't double-charge.
The Callidus case study shows the current alternative. On React, Firebase and Stripe Connect, a coordinator Cloud Function runs daily at 09:00 UTC and fans out one Cloud Task per trialing tenant, and a second scheduled function at 09:30 UTC escalates failed payments. That works without Inngest. Here's a labelled hypothetical for where a durable workflow would change things in a B2B SaaS: a nightly per-tenant reconciliation where one tenant's Stripe call gets rate-limited. With per-step retries, only that tenant's step repeats. This isn't something Callidus runs, and you should weigh it against the tools you already have.
Still deciding what belongs where? Settle the rest of your SaaS MVP stack first, since auth, billing and data isolation shape this choice more than the job runner does.
Open your most fragile background job and count its steps. How many can fail independently, and does anything in your code mark which of them already finished?
