Why durable workflows exist
Sharing a resource with 300 people is a chain of side effects across services. A database transaction can't protect that chain. A durable workflow can.
Some software concepts only click once you have run something in production and watched it fail. Durable workflows are one of them. On a laptop, a request handler that does five things in a row works every time. In production, the interesting question is what happens when it stops halfway.
The chain
Take a small feature: share a private resource with 300 people. That is not one operation. It is a chain that all has to succeed: write the share rows, grant 300 permissions in your permission system, invalidate the cache, and send 300 emails.
Put that chain inside a request handler and it runs fine, right up until the process dies partway through. A deploy, an OOM kill, a node getting recycled. Now the shares are in the database, half the permissions are granted, and nobody gets an email. Nothing is set up to notice, let alone resume.
A transaction is not enough
The first instinct is "wrap it in a transaction". A database transaction protects the changes inside it: either all the share rows land or none do. It says nothing about the permission system, the cache, or the email provider. Those are other services with their own state, and your rollback never reaches them.
Say you stretch the transaction around the whole handler and the crash hits during the permission grants. The database undoes its rows. The 140 permissions that were already granted, the stale cache and the unsent emails are exactly where the crash left them.
Durable workflow
This is where a durable workflow comes in. You get one from a workflow engine such as Inngest or Temporal. The idea is simple: the workflow remembers how far it got, and it stores that progress durably, outside your application process. If the process dies, the progress does not.
The request no longer does the work. It hands the job to the engine and returns an acknowledgement right away, with an ID the client can use to check progress.
The chain itself becomes a function made of steps. Each step is checkpointed once it finishes.
Now assume the server crashes during the permission updates. When it comes back, the engine schedules a retry. It does not start from zero. The database step is already recorded as done, so only the unfinished permission step runs again, then the rest of the chain continues.
outside the process
Don't make the client wait for emails
The work is now recoverable, but there is still a problem. If you share with thousands of people, why should the client wait for every email? Once the database, permissions and cache are done, access is ready. Notifications can keep going in the background.
So you durably hand that part to a separate notification workflow. The handoff is itself a step, so it cannot get lost either.
export const shareResource = inngest.createFunction({ id: "share-resource" },{ event: "resource/share" },async ({ event, step }) => {const { resourceId, userIds } = event.dataawait step.run("write-db", () =>db.insert(shares).values(rows(userIds)))await step.run("grant-permissions", () =>grantAll(userIds, resourceId))await step.run("warm-cache", () =>cache.invalidate(resourceId))await step.run("send-emails", () =>emailAll(userIds, "You got access"))})
The email workflow owns its own problems: sending, waiting out the provider's rate limits, and retrying temporary failures. None of that touches the request that started it.
Inside it, every email is its own step. If the worker dies after the fifth email, the retry picks up at the sixth. Nobody gets the same email twice.
Next time: across microservices
Everything above assumed one application talking to a few providers. What changes when the sharing, permission and notification logic live in different services, each with its own database and deploys? That is the next one.