Every application starts with background work: sending email, handling payment webhooks, or syncing data from third-party APIs. Those jobs become critical when failures mean lost revenue, inconsistent data, or broken user state.
That is when you need a fault-tolerant event-driven architecture—not just a queue and a worker, but systems that keep running when steps fail, APIs flake, or infrastructure restarts.
This guide defines fault tolerance in event-driven systems, then shows how to build it with Inngest using retries, idempotency, exactly-once effects, event replay, and clearer recovery than a traditional dead letter queue.
What is a fault-tolerant event-driven system?
A fault-tolerant event-driven system is an architecture where events trigger work, and that work continues correctly even when individual components fail.
In practice, that means:
- Events are durable facts: once accepted, they are not silently dropped.
- Handlers can fail and retry without restarting the entire workflow from scratch.
- Side effects are safe under retries through idempotent event processing.
- Operators can recover from incidents with event replay instead of lost or stranded messages.
- Failures are observable: you can see which step failed, why, and what to fix.
Fault tolerant means the system absorbs faults—network blips, rate limits, crashes, deploys—and still reaches a correct outcome, or gives you a clear path to recover when automatic retries are not enough.
Why do event-driven systems need fault tolerance?
Application-critical tasks show up everywhere:
- Ingesting uploads or third-party data, transforming it, and notifying users
- User actions that must propagate across databases, systems, or integrations
- Transaction processing for payments sent and received
As tasks grow, they split into workflows: validate, enrich, wait for review, write to a database, notify. One flaky API call should not force you to re-run every earlier step. Without fault tolerance, teams invent ad hoc retries, custom dead letter queues, and one-off recovery scripts—usually after the first painful outage.
Developers need confidence that critical workflows run reliably and durably, with status and logs grouped by workflow type and individual runs.
How do you build fault-tolerant workflows with events?
You can chain queues and workers, or adopt a heavyweight orchestration framework. Both add overhead and often split related logic across jobs or DSLs.
Inngest’s approach is to write multi-step workflows as ordinary code. Each step.run() is independently retried; completed steps are memoized so execution resumes where it left off. You can also wait for additional events with step.waitForEvent().
Example: a CRM import triggered by api/contact_list.uploaded. The workflow validates the CSV, enriches contacts via third-party APIs, waits for api/contact_list.reviewed, then writes approved rows into the CRM.
import { inngest } from "./client";inngest.createFunction({ id: "contacts-import-and-enrichment", name: "Contacts Import and enrichment" },{ event: "api/contact_list.uploaded" },async ({ event, step }) => {const { isValid, errors } = await step.run("Validate upload contents", async () => {// Download the csv file, validate columns and data in each rowconst { isValid, errors } = downloadAndValidateCSV(event.data.filename);return { isValid, errors };});if (!isValid) {return await step.run("Notify user of invalid contents", async () =>await sendContactsImportFailedEmail(event.user.id, errors));}// Enrichment may fail at times due to networking blipawait step.run("Enrich contacts information", async () => {// Call a third party API service to enrich each contact's info// then uploads the data to an object store when complete});const listReviewedEvent = await step.waitForEvent("api/contact_list.reviewed", {timeout: "7d",match: "data.upload_id", // data.upload_id is in both events and must match to proceed});if (listReviewedEvent.data.is_approved === false) {return await step.run("Delete uploaded contact lists", () => { /* ...*/ });}const { totalUsersAdded } = await step.run("Create contacts in CRM", async () => {const contacts = await downloadEnrichedContactList(event.data.filename);const filteredContacts = applyFilters(listReviewedEvent.data.filters);return await insertContactsIntoCRMDatabase(event.data.account_id, filteredContacts);});await step.run("Notify user of successful import", async () =>await sendContactsImportSuccessEmail(event.user.id, totalUsersAdded));});
What this gives you toward a fault-tolerant event-driven architecture:
- Code inside each
step.run()retries independently and resumes from where the function left off. step.waitForEvent()coordinates follow-up user actions via event coordination.- Step results, failures, and retries are visible in the Inngest dashboard for observability.
Deploy the same functions locally or to any supported platform. More on step tools is in the steps docs.
How do idempotency and exactly-once processing protect workflows?
Most event buses and queues provide at-least-once delivery. Retries and duplicate publishes are normal. True broker-level exactly-once delivery is rare; what product systems need is exactly-once effects—the business outcome happens once.
That requires:
- Idempotent event processing in your handlers (upserts, deterministic IDs, check-before-write).
- Platform help: Inngest idempotency keys can prevent duplicate events from triggering the same function more than once within a window, and memoized steps avoid re-running successful side effects on retry.
Together, idempotency and step memoization let you design for exactly-once outcomes even when the transport retries. See error handling and retries for how failed steps retry without replaying completed work.
How do dead letter queues and event replay recover from failures?
A classic dead letter queue (DLQ) holds messages that exhausted retries. Someone must later inspect, fix, and reprocess them—often with custom scripts and incomplete context.
Event replay is a stronger recovery model for fault-tolerant systems: keep the history of events and function runs, fix the bug or outage, then re-run the failed work over a time range. Inngest Replay does this in the product UI so you do not maintain a separate DLQ pipeline. For the product story behind that approach, see Announcing Replay.
Use automatic retries for transient faults; use event replay for incidents that last hours or days. Always pair replay with idempotent handlers so recovered runs do not duplicate side effects.
How do you observe fault-tolerant event-driven systems?
Fault tolerance without visibility is guesswork. You need per-step status, failure reasons, and the ability to query event and run history.
Query workflow data with Insights
Insights lets you query events and runs with SQL in the dashboard:
SELECTevent_name,COUNT(*) as event_countFROM eventsWHERE created_at > NOW() - INTERVAL '7 days'GROUP BY event_nameORDER BY event_count DESC;
Use Insights for event analysis, run performance tracking, exports, and schema discovery—especially useful for AI workflows where you track tokens, model calls, and agent outcomes.
Export metrics to Datadog
Teams on Datadog can export Inngest metrics for centralized alerting (inngest_function_run_scheduled_total, inngest_function_run_started_total, inngest_function_run_ended_total, and more).
FAQ: fault-tolerant event-driven architecture
What is a fault-tolerant event-driven system?
A system where events trigger durable work that survives failures: retries, idempotent handlers, persisted step state, and recovery via event replay rather than dropped messages.
What does fault tolerant mean?
It means failures are expected and handled. Progress is kept, unsafe duplicates are avoided through idempotency, and operators can restore correctness after outages.
How is this different from a dead letter queue workflow?
A DLQ parks failed messages for later manual handling. With Inngest, failed runs stay queryable; after you fix the cause, event replay reprocesses them without a separate dead letter queue workflow.
How do you get near exactly-once behavior?
Assume at-least-once delivery, write idempotent steps, and use Inngest idempotency keys plus memoized step.run() results so retries do not repeat successful side effects.
Next steps
For critical background work, you need guarantees and observability without a custom reliability stack.
- Sign up for Inngest (free to start)
- Read the error handling guide and idempotency guide
- Learn function replay
- Join Discord if you want feedback on a production workflow
The execution engine and SDKs are open source: inngest/inngest, inngest/inngest-js.


