Web Engineering ·

Durable Execution in TypeScript: Temporal, Inngest, and Cloudflare Workflows for Reliable Long-Running Processes

Long-running processes break when servers restart mid-execution. This article compares three TypeScript-friendly durable execution frameworks (Temporal, Inngest, and Cloudflare Workflows), covering retries, compensation, state persistence, and when to choose each.

Durable Execution in TypeScript: Temporal, Inngest, and Cloudflare Workflows for Reliable Long-Running Processes

A payment flow hits step three of seven, your pod gets evicted, and the charge has already been captured. Now what? You have no record of where you were, the retry starts from scratch, and the customer gets double-charged.

This is the core problem that durable execution frameworks solve. They persist workflow state at each step so that when infrastructure fails (and it will), execution can resume exactly where it stopped rather than restart from zero.

The problem is not unique to payments. Onboarding sequences that send emails, provision infrastructure, and update CRMs across multiple services; data pipelines that fan out to third-party APIs with rate limits; AI agents that run multi-step reasoning loops all share the same failure mode: they are long enough to fail partway through, and naive retries cause duplicate side effects.

This article compares three TypeScript-friendly approaches: Temporal (self-hosted, full workflow engine), Inngest (managed, event-driven), and Cloudflare Workflows (edge-native, Durable Objects-based). Each makes different tradeoffs on operational complexity, state model, and where code runs.


The Problem with Naive Retry Logic

Before reaching for a framework, it helps to understand exactly what breaks.

A typical long-running process looks like this:

async function processOrder(orderId: string) {
  await chargePayment(orderId);       // step 1: external API
  await reserveInventory(orderId);    // step 2: database write
  await sendConfirmationEmail(orderId); // step 3: email provider
  await notifyWarehouse(orderId);     // step 4: internal service
}

If the process crashes after step 2, a simple retry will call chargePayment again. If your payment provider does not enforce idempotency keys on your side, the customer gets charged twice.

You can add idempotency keys, but then you need to store them somewhere. You can add database checkpoints, but then you need logic to read and restore state. You can add dead-letter queues, but then you need alerting, replay tooling, and operational runbooks. By the time you have built all of this, you have reinvented a workflow engine badly.

Durable execution frameworks provide the checkpoint, state, and retry logic as infrastructure. You write sequential code; they handle the persistence.


Temporal: Full Workflow Engine

Temporal is a workflow orchestration platform originally built at Uber. You define workflows as TypeScript functions; Temporal records every step as an event in a history log. When a worker crashes, a new worker picks up the workflow from the last committed step.

How it works

Temporal separates workflow code from activity code. Workflows are deterministic orchestrators. Activities are the actual side effects (API calls, database writes, emails). The Temporal server stores the event history; workers replay it to restore state.

import {
  proxyActivities,
  sleep,
  defineSignal,
  setHandler,
} from "@temporalio/workflow";
import type * as activities from "./activities";

const { chargePayment, reserveInventory, sendConfirmationEmail, notifyWarehouse } =
  proxyActivities<typeof activities>({
    startToCloseTimeout: "30 seconds",
    retry: {
      maximumAttempts: 3,
      initialInterval: "1 second",
      backoffCoefficient: 2,
    },
  });

// Signal for cancellation
const cancelSignal = defineSignal("cancel");

export async function orderWorkflow(orderId: string): Promise<void> {
  let cancelled = false;
  setHandler(cancelSignal, () => {
    cancelled = true;
  });

  const chargeResult = await chargePayment(orderId);

  if (cancelled) {
    // Compensate: refund the charge
    await activities.refundPayment(chargeResult.chargeId);
    return;
  }

  await reserveInventory(orderId);
  await sendConfirmationEmail(orderId);
  await notifyWarehouse(orderId);
}

Activities are plain async functions:

// activities.ts
export async function chargePayment(orderId: string) {
  const result = await stripe.paymentIntents.create({
    amount: await getOrderAmount(orderId),
    currency: "usd",
    idempotency_key: `charge-${orderId}`,
  });
  return { chargeId: result.id };
}

export async function refundPayment(chargeId: string) {
  await stripe.refunds.create({ payment_intent: chargeId });
}

Temporal handles retry on activity failure. If chargePayment throws, Temporal retries it up to three times with exponential backoff. The workflow itself is paused, not retried from the start.

Where Temporal is the right choice

Temporal shines when you need signals (external events that modify in-flight workflows), queries (read workflow state without advancing it), and long-running workflows that span days or weeks. Its deterministic replay model is strict: you cannot use Math.random(), Date.now(), or non-deterministic imports directly inside workflow functions. This is enforced via a sandboxed runtime, and violating it causes silent bugs that only surface during replay.

Where it breaks down: self-hosting Temporal requires running a Temporal server (or using Temporal Cloud at $0.01-$0.05 per action) plus at minimum one worker process. For teams without Kubernetes or a managed Temporal deployment, the operational surface is non-trivial.


Inngest: Managed, Event-Driven

Inngest is a managed platform that treats durable execution as a serverless concern. You deploy functions to your existing hosting (Vercel, Railway, any Node.js server), register them with Inngest, and Inngest handles orchestration by calling your functions over HTTP.

The state model is different from Temporal. Inngest does not replay history. Instead, each step.run() call executes once and its return value is stored. On retry, completed steps are skipped by returning their stored results.

import { inngest } from "./inngest-client";

export const processOrder = inngest.createFunction(
  {
    id: "process-order",
    retries: 3,
    throttle: {
      limit: 10,
      period: "1m",
    },
  },
  { event: "order/created" },
  async ({ event, step }) => {
    // Each step.run() is durable: if the function crashes here,
    // chargePayment will not run again on retry.
    const chargeResult = await step.run("charge-payment", async () => {
      return await chargePayment(event.data.orderId);
    });

    const inventoryResult = await step.run("reserve-inventory", async () => {
      return await reserveInventory(event.data.orderId);
    });

    // step.waitForEvent() pauses execution until an event arrives,
    // up to the timeout. No polling, no cron.
    const confirmationEvent = await step.waitForEvent(
      "wait-for-warehouse-confirmation",
      {
        event: "warehouse/order-confirmed",
        match: "data.orderId",
        timeout: "24h",
      }
    );

    if (!confirmationEvent) {
      // Compensate: warehouse did not confirm within 24 hours
      await step.run("refund-on-timeout", async () => {
        await refundPayment(chargeResult.chargeId);
        await releaseInventory(inventoryResult.reservationId);
      });
      return { status: "cancelled", reason: "warehouse-timeout" };
    }

    await step.run("send-confirmation-email", async () => {
      await sendConfirmationEmail(event.data.orderId);
    });

    return { status: "completed" };
  }
);

The step.waitForEvent() call is where Inngest earns its keep. Your function literally pauses execution and does not consume compute during the wait. When the warehouse/order-confirmed event arrives (or the 24h timeout fires), Inngest resumes the function from that point.

Saga compensation in Inngest

The saga pattern for distributed transactions is straightforward: run steps in order, on failure compensate in reverse. Inngest does not have a built-in compensation primitive, but you can structure it explicitly:

export const durableOrderFlow = inngest.createFunction(
  { id: "durable-order-flow", retries: 5 },
  { event: "order/checkout" },
  async ({ event, step }) => {
    const completed: string[] = [];

    const charge = await step.run("charge", async () => {
      const result = await chargePayment(event.data.orderId);
      completed.push("charge");
      return result;
    });

    try {
      const reservation = await step.run("reserve", async () => {
        const result = await reserveInventory(event.data.orderId);
        completed.push("reserve");
        return result;
      });

      await step.run("notify", async () => {
        await notifyWarehouse(event.data.orderId);
      });
    } catch (err) {
      // Compensate in reverse order
      for (const step_name of completed.reverse()) {
        if (step_name === "charge") {
          await step.run("compensate-charge", () =>
            refundPayment(charge.chargeId)
          );
        }
        if (step_name === "reserve") {
          await step.run("compensate-reserve", () =>
            releaseInventory(event.data.orderId)
          );
        }
      }
      throw err;
    }
  }
);

Where Inngest is the right choice

Inngest fits teams already on serverless hosting who want durable execution without running additional infrastructure. The free tier is generous (50K steps/month), and the managed nature means no worker processes to scale or maintain. The development experience is strong: inngest dev gives you a local UI for inspecting function runs, retries, and event payloads.

Where it breaks down: because orchestration happens over HTTP, each step invocation is a new function call. Functions must return and be called again; you cannot hold in-memory state across steps. This is a different mental model than Temporal’s continuous process, and it means some patterns (streaming results to a client, real-time state queries) require extra architecture.


Cloudflare Workflows: Edge-Native Durable Execution

Cloudflare Workflows runs on top of Durable Objects. Each workflow instance is a Durable Object, which means state is persisted in Cloudflare’s globally distributed storage and the workflow resumes close to where it was interrupted.

The API uses step.do() for durable steps and step.sleep() for time-based pauses. Steps are committed to durable storage before execution, so if a Worker crashes mid-step, the step re-executes on restart (steps must be idempotent).

import { WorkflowEntrypoint, WorkflowStep, WorkflowEvent } from "cloudflare:workers";

type OrderParams = {
  orderId: string;
  customerId: string;
  amount: number;
};

export class OrderWorkflow extends WorkflowEntrypoint<Env, OrderParams> {
  async run(event: WorkflowEvent<OrderParams>, step: WorkflowStep) {
    const { orderId, customerId, amount } = event.payload;

    // step.do() is durable: committed before execution,
    // will not re-run if it already completed.
    const chargeResult = await step.do(
      "charge-payment",
      {
        retries: {
          limit: 3,
          delay: "5 seconds",
          backoff: "exponential",
        },
        timeout: "30 seconds",
      },
      async () => {
        const response = await fetch("https://api.stripe.com/v1/payment_intents", {
          method: "POST",
          headers: {
            Authorization: `Bearer ${this.env.STRIPE_KEY}`,
            "Content-Type": "application/x-www-form-urlencoded",
            "Idempotency-Key": `charge-${orderId}`,
          },
          body: new URLSearchParams({
            amount: String(amount),
            currency: "usd",
          }),
        });

        if (!response.ok) {
          throw new Error(`Stripe error: ${response.status}`);
        }

        return response.json<{ id: string }>();
      }
    );

    await step.do("reserve-inventory", async () => {
      // Database write via D1 or Hyperdrive
      await this.env.DB.prepare(
        "INSERT INTO reservations (order_id, reserved_at) VALUES (?, ?)"
      )
        .bind(orderId, new Date().toISOString())
        .run();
    });

    // Sleep for 2 hours: no compute consumed during wait
    await step.sleep("wait-before-email", "2 hours");

    await step.do("send-confirmation", async () => {
      await fetch("https://api.resend.com/emails", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${this.env.RESEND_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          to: [customerId],
          subject: "Order confirmed",
          text: `Your order ${orderId} has been confirmed.`,
        }),
      });
    });
  }
}

Triggering a workflow from a Worker:

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const body = await request.json<OrderParams>();

    const instance = await env.ORDER_WORKFLOW.create({
      id: `order-${body.orderId}`,
      params: body,
    });

    return Response.json({ instanceId: instance.id });
  },
};

You can query workflow status:

const instance = await env.ORDER_WORKFLOW.get(instanceId);
const status = await instance.status();
// { status: "running" | "complete" | "errored" | "paused" | "terminated" }

Where Cloudflare Workflows is the right choice

Cloudflare Workflows is a natural fit if you are already on the Cloudflare stack: Workers, D1, R2, KV. The billing is usage-based with no worker-process overhead, and the edge-native execution means low latency for globally distributed workloads.

Where it breaks down: Cloudflare Workflows is newer than Temporal or Inngest, and some features (signals, cross-workflow communication, complex fan-out patterns) require more manual construction via Durable Objects directly. Maximum workflow duration is currently 30 days, and very long-running workflows (multi-week approval processes) may hit limits that Temporal handles natively.


Comparison

DimensionTemporalInngestCloudflare Workflows
HostingSelf-hosted or Temporal CloudFully managedCloudflare (managed)
State modelDeterministic replay of event historyStep results stored, skipped on retryDurable Object storage, step committed before run
Pricing (approx.)Temporal Cloud: $0.01-0.05/action; self-hosted: infra costFree: 50K steps/mo; Pro: $30/mo + usage$0.001/workflow-second (Workers pricing)
Cold startNone (workers are always-on)Serverless: 50-500ms depending on hostSub-10ms (Workers runtime)
Max workflow durationUnlimitedUp to 1 year30 days
TypeScript DXStrong; strict determinism rulesExcellent; step.run() is intuitiveGood; newer API surface
Local developmenttemporal server start-devinngest dev (excellent UI)wrangler dev
External signalsNative signal/query APIstep.waitForEvent()Manual via Durable Object messaging
Saga/compensationManual with try/catch in workflowManual with step trackingManual with step logic
Operational overheadHigh (self-hosted) / Low (cloud)Very lowLow (within CF ecosystem)
Best forComplex orchestration, long-running, signalsServerless-first, event-driven, simpler workflowsCF stack, edge-native, globally distributed

Production Considerations

Idempotency is your responsibility, not the framework’s

All three frameworks guarantee that a step will eventually complete, not that the underlying operation will not be called more than once during a failure window. Pass idempotency keys to every external API call. For Stripe, the key pattern ${workflowId}-${stepName} gives you per-step idempotency that survives retries without double-charging.

Versioning running workflows

Temporal’s strict determinism means you cannot change workflow code while instances are in-flight: history replay will fail if the code path has changed. You need to version workflows explicitly:

import { patched } from "@temporalio/workflow";

export async function orderWorkflow(orderId: string) {
  if (patched("add-warehouse-step")) {
    // New code path for new workflow instances
    await notifyWarehouse(orderId);
  }
  // Old instances without this patch skip the step
}

Inngest and Cloudflare Workflows use a simpler model: steps that have already completed are skipped, so new code only affects steps not yet executed. This makes zero-downtime deploys easier but limits what you can change mid-workflow.

Timeout and retry policies

Set timeouts at two levels: per-step and per-workflow. A step that hangs indefinitely blocks forward progress. In Temporal, startToCloseTimeout is the per-activity wall-clock limit. In Inngest, you can set timeout per step.run(). In Cloudflare Workflows, timeout is a step-level option on step.do().

Exponential backoff with jitter prevents thundering herd on flaky external services. Cap retries at a finite count: unlimited retries on a permanently broken external service will consume execution budget indefinitely.

Observability

All three platforms provide workflow execution logs, but you need application-level instrumentation on top. Emit a structured log at each step boundary with workflowId, stepName, durationMs, and outcome. Aggregate these to track per-step failure rates. A spike in chargePayment failures is actionable; “workflow failed” is not.


Decision Framework

Start here: is this workflow longer than one HTTP request timeout?

If no, a regular async job queue (BullMQ, Upstash QStash) is simpler and cheaper.

If yes, pick based on your infrastructure and complexity:

Use Temporal if your workflows span multiple days or weeks, you need signals to inject external events into running workflows, or you are building complex orchestration with fan-out, compensation, and audit requirements. Accept the operational overhead or pay for Temporal Cloud.

Use Inngest if you are on serverless hosting (Vercel, Railway, Fly) and your workflows are event-triggered sequences with a moderate number of steps. The managed infrastructure and local dev tooling make it the fastest path to production for most teams.

Use Cloudflare Workflows if you are already committed to the Cloudflare stack and want edge-native execution. The low latency and usage-based pricing are genuine advantages; the newer API surface and 30-day limit are the real constraints to evaluate.

Avoid choosing a framework based on which has the best marketing. The state model is the thing that matters: do you need deterministic replay (Temporal), stored step results (Inngest), or Durable Object-backed commits (Cloudflare)? Pick the model that matches how you think about failure recovery.


Durable execution is not magic. It moves the complexity from “how do I recover from partial execution” to “how do I write idempotent steps and version workflows safely”. That is a better problem to have, but it is still a problem. The frameworks above give you the primitives; the discipline to use them correctly is yours.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.