Durable Functions Orchestrators, Activities, and Entities: Which Form Do You Actually Need?

What This Article Covers (and What It Doesn’t)

## What This Article Covers (and What It Doesn’t)

This article walks through all three Durable Functions forms — orchestrators, activities, and entities — with working TypeScript code using the **v3 SDK async/await syntax**. No generator functions. No `yield`-based patterns from the legacy SDK. If you’re on v1 or v2 and wondering why none of the syntax matches your codebase, that’s why.

**What’s out of scope here:** local setup, Azurite configuration, and getting the Azure Functions Core Tools wired up. That’s covered separately in [Getting Started with Durable Functions Locally](#). Starting there first will save you 45 minutes of environment debugging before you write a single function.

### Runtime Prerequisites

Before anything else:

– **Node 20 LTS.** Node 18 hit EOL in April 2025. If you’re still on 18, the code here will likely run, but you’re carrying forward a dead runtime. Don’t do that.
– **.NET 8 LTS or .NET 9** with the **isolated worker model**. Not the WebJobs-based in-process extension. .NET 7 EOL’d in May 2024 — if you’re on it, you’re on borrowed time and your upgrade path only gets longer.

The isolated worker model matters here because the programming model for Durable Functions changed meaningfully between in-process and isolated. Mixing docs from both is a reliable way to waste an afternoon.

### A Note on Internal Links

Two links that appeared in an earlier version of this article have been removed:

The “SaaS Tools for Small Business” reference had no business being here. If you’re evaluating infrastructure decisions around Durable Functions at scale, the relevant read is [Choosing a State Backend for Durable Functions at Scale](#) — specifically the tradeoffs between Azure Storage, Netherite, and MSSQL providers.

There was also an F# code block embedded in this article. That’s been cut entirely. TypeScript and F# share almost nothing in how they surface the Durable Functions programming model — the syntax, the SDK packages, and several behavioral constraints differ. If an F# version of this walkthrough exists, it should live as its own article and link back here as a **language-specific alternative**, not sit inline in a JS/TS piece where it confuses both audiences.

Form 1: The Orchestrator — Long-Running Coordination Without Polling

## Form 1: The Orchestrator — Long-Running Coordination Without Polling

The orchestrator is the brain, not the hands. It decides what runs, in what order, and what to do when something fails — but it never touches a database, never calls an API, never reads a file. That distinction isn’t stylistic. It’s load-bearing.

Here’s why: the Durable Functions runtime replays the orchestrator function from scratch every time it needs to resume after an await point. If your orchestrator calls `Date.now()` or `Math.random()` or fires an HTTP request directly, you’ll get different results on each replay, which corrupts the execution history. The runtime catches some of this and throws determinism errors, but not all of it. Silent corruption is worse.

### What an Orchestrator Actually Does

It schedules activities. It waits for external events. It handles timeouts and compensation logic. All the actual work — I/O, computation, external calls — happens in activities that the orchestrator invokes.

Think of it like a director on a film set. The director calls “action” and waits for the take to finish. The director doesn’t hold the camera.

### Full Working Example (v3 Async/Await Syntax)

typescript
import * as df from “durable-functions”;
import { OrchestrationContext, OrchestrationHandler } from “durable-functions”;

const orderFulfillmentOrchestrator: OrchestrationHandler = async function (
context: OrchestrationContext
) {
const orderId: string = context.df.getInput();

// Step 1: Validate the order — runs in an activity, not here
const validationResult = await context.df.callActivity(
“ValidateOrder”,
orderId
);

if (!validationResult.isValid) {
return { status: “rejected”, reason: validationResult.reason };
}

// Step 2: Charge payment
const paymentResult = await context.df.callActivity(“ChargePayment”, {
orderId,
amount: validationResult.total,
});

// Step 3: Wait for warehouse confirmation (could take hours)
const warehouseEvent = await context.df.waitForExternalEvent(
“WarehousePickConfirmed”,
// Optional timeout — 48 hours before we escalate
df.Task.withDeadline(
new Date(Date.now() + 48 * 60 * 60 * 1000),
context.df.createTimer(new Date(Date.now() + 48 * 60 * 60 * 1000))
)
);

if (!warehouseEvent) {
// Timeout path — compensate
await context.df.callActivity(“CancelPayment”, paymentResult.chargeId);
return { status: “timed_out”, orderId };
}

// Step 4: Ship it
const shipmentId = await context.df.callActivity(“CreateShipment”, {
orderId,
warehousePickId: warehouseEvent.pickId,
});

return { status: “fulfilled”, orderId, shipmentId };
};

df.app.orchestration(“OrderFulfillment”, orderFulfillmentOrchestrator);

A few things to notice: no `await fetch()`, no `new Date()` raw calls outside of the context wrapper, no logging with side effects. The orchestrator body is pure coordination logic.

> **v3 Migration Callout — If You’re Seeing `function*` and `yield`, You’re Reading Old Docs**
>
> A lot of Azure Durable Functions examples still floating around — including some on Stack Overflow and older Microsoft docs pages — use the generator function pattern:
>
> typescript
> // v2 SDK — DO NOT copy this pattern into a new project
> const myOrchestrator = df.orchestrator(function* (context) {
> const result = yield context.df.callActivity(“MyActivity”, input);
> });
> >
> That’s the v2 SDK. In v3+, orchestrators are plain `async` functions that use `await`. The generator syntax still works for backward compatibility, but mixing patterns in the same codebase causes subtle bugs and makes the code harder to read.
>
> If you’re migrating an existing project, Microsoft published a [v2-to-v3 migration guide](https://learn.microsoft.com/en-us/azure/azure-functions/durable/durable-functions-node-migration) that covers the breaking changes, including how the app registration model changed. Read it before touching a production orchestrator.

### The History Replay Problem — When It Actually Bites You

Every `await context.df.callActivity()` call writes an event to the orchestration history stored in Azure Table Storage (or your configured backend). On replay, the runtime reads that history and fast-forwards through already-completed steps instead of re-executing them. That’s how it knows not to charge the payment twice.

The problem: that history table grows indefinitely for a single orchestration instance. Microsoft documents a soft degradation threshold around 50,000 events per instance. Past that, checkpoint reads slow down, replays take longer, and you’ll see orchestrator execution times climb even when your activities are fast.

**When does this actually happen?** High-throughput fan-out. If your orchestrator spawns 10,000 parallel activities with `context.df.Task.all([…])`, each activity result writes multiple events. A few rounds of that and you’re in trouble.

For typical CRUD workflows — validate, process, confirm, notify — you might generate 20-30 events per orchestration. You’d need to run millions of those before the history size becomes a concern at the per-instance level.

The workaround for fan-out scenarios is the eternal orchestration pattern: break the work into sub-orchestrations, each handling a bounded chunk of activities, and coordinate them from a parent orchestrator. Each sub-orchestration has its own history. Keeps individual history sizes manageable.

If you genuinely need to fan out to hundreds of thousands of items, reconsider whether Durable Functions is the right tool. A purpose-built batch processing system with explicit state tracking in a database might serve you better.

### When NOT to Use an Orchestrator

If your workflow finishes in under five seconds, has no external wait points, and doesn’t need automatic retry or compensation on failure — skip the orchestrator entirely.

A plain queue trigger with a single activity is cheaper, simpler, and doesn’t carry the checkpoint overhead. Durable Functions is priced on storage transactions and execution time. A high-volume simple task processor wrapped in an orchestrator will cost more than the same logic running as a direct queue consumer with retry policies set at the trigger level.

The orchestrator earns its complexity when you need: cross-activity state, external event waiting, human-in-the-loop approval steps, or multi-step compensation on partial failure. For “take this message, process it, write to DB” — use a queue trigger.

Form 2: The Activity — Where the Real Work Happens

## Form 2: The Activity — Where the Real Work Happens

Activities are the workhorses. If orchestrators are the traffic controllers, activities are the trucks actually moving the cargo. Everything that touches the outside world — HTTP calls, database writes, file I/O, SDK invocations — belongs in an activity. Not in the orchestrator. Not “just this once” in a helper function called from the orchestrator. In the activity.

The contract is strict by design: activities are stateless, they run once per scheduled execution, and the runtime can replay orchestrator code around them without re-running them (because their results get checkpointed). That checkpoint behavior is exactly why you must keep activities idempotent. The runtime will retry a failed activity. If your activity charged a card, sent an email, or inserted a row before throwing on the response-parsing step, you will do it again on retry unless you’ve built a guard against it.

### A Working Activity: Charging via the Stripe SDK

Here’s a concrete TypeScript activity that calls Stripe. Note the API version: Stripe’s Node SDK accepts an `apiVersion` string. Do not copy a stale version from a tutorial. Check your Stripe Dashboard under **Developers → API version** and pin to whatever your account is defaulted to, or to a version you’ve explicitly tested against. Pinning to an unverified version is how you get silent behavioral differences between test and production.

typescript
import * as df from “durable-functions”;
import Stripe from “stripe”;

// Initialize outside the function to reuse the client across warm invocations
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
// Replace with your account’s current API version — verify in Stripe Dashboard
apiVersion: “2024-06-20”, // example only; confirm yours before deploying
});

df.app.activity(“chargeStripe”, {
handler: async (input: { customerId: string; amountCents: number; idempotencyKey: string }) => {
const { customerId, amountCents, idempotencyKey } = input;

const paymentIntent = await stripe.paymentIntents.create(
{
amount: amountCents,
currency: “usd”,
customer: customerId,
confirm: true,
automatic_payment_methods: { enabled: true, allow_redirects: “never” },
},
{
// This is your idempotency guard — pass the orchestration instanceId + step name
idempotencyKey,
}
);

return {
paymentIntentId: paymentIntent.id,
status: paymentIntent.status,
};
},
});

Two things worth calling out here. First, the `idempotencyKey` parameter: you should derive this from the orchestration instance ID plus some stable step identifier, then pass it into the activity input. Stripe will return the same `PaymentIntent` object if you retry with the same key, which makes your retry-safe. Second, the Stripe client is initialized outside the handler so it’s reused across warm executions — minor but it avoids unnecessary TLS handshakes on every invocation.

### Retry Policies: Don’t Accept the Defaults Silently

The default retry behavior will bite you in production if you don’t think about it. Here’s how you configure it when calling the activity from your orchestrator:

typescript
const retryPolicy: df.RetryPolicy = {
maxNumberOfAttempts: 4,
firstRetryIntervalInMilliseconds: 2000, // 2 seconds before first retry
backoffCoefficient: 2, // doubles: 2s, 4s, 8s
maxRetryIntervalInMilliseconds: 30000, // cap at 30 seconds
retryTimeoutInMilliseconds: 120000, // give up entirely after 2 minutes
};

const chargeResult = yield context.df.callActivity(
“chargeStripe”,
{
customerId: order.customerId,
amountCents: order.totalCents,
idempotencyKey: `${context.df.instanceId}-charge`,
},
{ retryPolicy }
);

The `backoffCoefficient` matters for external APIs. Hitting Stripe four times in four seconds because your retry interval is 1000ms is a good way to get rate-limited and make the situation worse. The configuration above gives Stripe breathing room while still completing within your overall timeout budget. Adjust based on the SLA of whatever downstream system you’re calling — a fast internal service might tolerate 500ms intervals; a third-party billing API should get more room.

### The Non-Transaction Problem

This is the most common production incident pattern with activities, and it’s subtle.

Activities are not transactional with each other. The Durable Functions runtime does not wrap your activity calls in a distributed transaction. There is no two-phase commit happening behind the scenes. If your orchestrator calls three activities in sequence and the third one fails after the first two succeeded, those first two have already fired against real systems.

Consider this sequence:

1. `reserveInventory` — succeeds, inventory row decremented
2. `chargeStripe` — succeeds, card charged
3. `sendConfirmationEmail` — fails on transient SMTP error

The orchestrator retries `sendConfirmationEmail`. That’s fine, and with idempotency on the email side it resolves cleanly. But if `chargeStripe` had failed instead, you now have reserved inventory and a potentially charged card depending on at what point in the Stripe call the failure occurred. The orchestrator will retry `chargeStripe`, and without that `idempotencyKey`, you charge the card twice.

The pattern for handling this is explicit compensation: if a later activity fails and you can’t safely retry, you call a compensating activity — `releaseInventory`, `refundStripe` — to undo what already fired. Some teams implement this as a dedicated error-handling branch in the orchestrator. Others use a saga pattern with explicit rollback steps. Either way, you have to design for it. The framework won’t do it for you.

### When an Activity Is the Wrong Tool

A common mistake: using an activity backed by a database row to coordinate shared mutable state across multiple orchestrators.

The scenario looks like this: you have an `updateAccountBalance` activity. It reads a balance, adds or subtracts an amount, and writes it back. One orchestrator calls it. Fine. Now two orchestrators call it concurrently for the same account. You’ve built a read-modify-write race condition, and your database’s row-level locking either serializes them (and you’re paying for that contention) or your app-level logic produces incorrect balances.

If the state you’re managing needs to be read and mutated by multiple callers concurrently — whether that’s multiple orchestration instances, external HTTP triggers, or timer-based signals — that is an entity problem. Entities serialize access to a single logical unit of state. The activity abstraction has no mechanism for that serialization. Using an activity plus a database row means you’re manually reimplementing what entities already give you, but worse and with more surface area for bugs.

The signal to look for: if you catch yourself adding `SELECT FOR UPDATE` or optimistic concurrency version checks inside an activity specifically to handle multi-orchestrator access, stop and ask whether an entity is the right shape for this piece of state.

Form 3: The Entity Function — The One Nobody Explains Well (Now With Actual Code)

## Form 3: The Entity Function — The One Nobody Explains Well (Now With Actual Code)

Most Durable Functions content spends two paragraphs on entities and then moves on. That’s a mistake, because entities solve a genuinely hard distributed systems problem — shared mutable state with no race conditions — and they do it without you writing a single lock. But they’re also the easiest form to overuse, and the documentation does a poor job explaining when not to bother.

Let’s fix that.

### The Plain-Language Model

An entity function is a **named, addressable piece of durable state with explicit operations**. The address is an `EntityId` — a tuple of entity name plus an instance key you define. Think of it as a tiny actor: the Durable Functions runtime queues all operations targeting a given entity ID and processes them one at a time. No concurrent writes, no dirty reads, no need for optimistic locking on your end.

The critical detail is that “serialization” here is not performance serialization — it’s **ordering serialization**. Two orchestrators can both signal the same entity simultaneously, and the runtime guarantees the operations run sequentially against the same state object. That guarantee is what makes entities worth the added complexity. Without it, you’re back to coordinating concurrent writes yourself.

One clarification that trips people up: an entity is not an orchestration. It doesn’t have a timeline or a completion point. It exists until you explicitly delete it, and it responds to operations on demand.

### Full Working Example: TypeScript Shopping Cart Entity (SDK v3 Class Syntax)

The class-based syntax is the right choice for anything non-trivial. It keeps your operations grouped, gives you type-safe state, and reads like a normal service class.

typescript
import * as df from “durable-functions”;
import { EntityContext } from “durable-functions”;

interface CartItem {
productId: string;
quantity: number;
pricePerUnit: number;
}

interface CartState {
items: CartItem[];
ownerId: string;
}

class ShoppingCart {
private items: CartItem[] = [];
private ownerId: string = “”;

addItem(item: CartItem): void {
const existing = this.items.find(i => i.productId === item.productId);
if (existing) {
existing.quantity += item.quantity;
} else {
this.items.push(item);
}
}

removeItem(productId: string): void {
this.items = this.items.filter(i => i.productId !== productId);
}

getState(): CartState {
return {
items: […this.items],
ownerId: this.ownerId,
};
}

setOwner(ownerId: string): void {
this.ownerId = ownerId;
}
}

df.entity(ShoppingCart);

A few things to notice here:

– `df.entity(ShoppingCart)` is the registration call. The class name becomes the entity name — so `ShoppingCart` becomes addressable as `”ShoppingCart”` in your `EntityId`.
– State is the instance itself. The runtime serializes and deserializes the class instance between operations, so what you store as `this.items` persists across calls. Don’t store anything that can’t round-trip through JSON.
– `getState()` returns a copy. That `[…this.items]` spread is intentional — you don’t want callers mutating the internal array reference.

### Constructing an EntityId and Signaling From an Orchestrator

typescript
import * as df from “durable-functions”;

const orchestrator = df.orchestrator(function* (context) {
const userId = context.df.getInput();

// Address this specific cart by user ID
const cartId = new df.EntityId(“ShoppingCart”, userId);

// Fire-and-forget: signal the entity to add an item
context.df.signalEntity(cartId, “addItem”, {
productId: “SKU-9921”,
quantity: 2,
pricePerUnit: 14.99,
});

// Do other work — the signal was queued, not awaited
yield context.df.callActivity(“sendConfirmationEmail”, userId);

// Now read entity state synchronously — this DOES await
const cartState = yield context.df.callEntity(cartId, “getState”);

if (cartState.items.length === 0) {
return { status: “empty_cart”, userId };
}

yield context.df.callActivity(“processCheckout”, cartState);

return { status: “complete”, itemCount: cartState.items.length };
});

### `callEntity` vs `signalEntity`: The Actual Difference

This distinction matters more than the docs let on.

**`signalEntity`** is fire-and-forget. The orchestrator queues the operation and immediately continues. The entity will process it eventually, but you get no return value and no confirmation of when. Use this for mutations where you don’t need to verify the result before proceeding — adding to a log, incrementing a counter, updating a status field.

**`callEntity`** awaits a response. The orchestrator suspends at that point (just like `callActivity`) and resumes when the entity operation completes and returns a value. Use this whenever you need to read state, validate a condition, or branch on what the entity currently contains.

A common failure mode: using `callEntity` for every operation because it feels “safer.” Every `callEntity` adds a round-trip through the task hub storage. If you’re firing ten mutations and you don’t need intermediate results, signal all ten and then call once at the end to read final state. The performance difference at scale is meaningful.

The other failure mode: signaling a mutation and then immediately signaling a read-dependent action. The signal is queued — there’s no guarantee the mutation has processed before your next activity reads external state. If ordering matters, use `callEntity` so you wait for the mutation to complete before proceeding.

### Python: The Honest Assessment

Python entity support in the Durable Functions SDK is limited as of 2026, and that’s not a minor footnote — it’s a real architectural constraint you need to account for before you start designing.

The concrete workaround options are:

**Option 1 — Orchestrator-as-actor with `ContinueAsNew`:**
Model your stateful entity as a long-running orchestrator that processes one event, updates its in-memory state, and calls `context.continue_as_new()` with the updated state as its new input. This gives you serialized operations and durable state without native entity support. The tradeoff: each state transition creates a new orchestration history checkpoint, which adds latency and storage churn for high-frequency updates.

python
# Rough pattern — not production-ready without error handling
def orchestrator_function(context: df.DurableOrchestrationContext):
state = context.get_input() # dict carrying current state

event = yield context.wait_for_external_event(“operation”)

if event[“type”] == “addItem”:
state[“items”].append(event[“item”])
elif event[“type”] == “removeItem”:
state[“items”] = [i for i in state[“items”]
if i[“productId”] != event[“productId”]]

context.continue_as_new(state)

**Option 2 — Externalized state with activity accessors:**
Put your state in Redis or Azure Table Storage. Write activity functions that act as a read/write layer — one activity to get state, one to apply a mutation. Your orchestrator calls these activities sequentially, which gives you the same serialization guarantee (orchestrators don’t run activity calls concurrently unless you explicitly fan out). The tradeoff: you’re coupling your workflow to an external store, and you need to handle consistency edge cases if that store doesn’t have atomic operations.

This isn’t a “good enough” workaround that you should feel fine about. Choosing Python for a workflow that genuinely needs shared mutable actor state is a real cost. If your system is Python-first and you have multiple orchestrators writing to the same entity-equivalent, weight that seriously when choosing between the two options above — or reconsider whether a TypeScript function host alongside your Python host is worth the operational overhead.

### When NOT to Use an Entity

This is the part nobody puts in tutorials, and it matters.

**Skip the entity if:**

– The state is only relevant to a single orchestration run. If your shopping cart lives and dies with one checkout workflow, there’s no reason to externalize it into an entity. Store it in the orchestration’s own local variables and activity return values. Entities add storage round-trips and external state management for zero benefit here.

– Only one caller ever touches the state at a time. The core value of an entity is operation serialization across multiple concurrent callers. If you have exactly one orchestration instance reading and writing a piece of state, you’re paying the entity overhead for a concurrency guarantee you didn’t need.

– The state is a simple scalar that you read once and write once. An entity for “has this user confirmed their email: true/false” is overkill. Pass the flag as activity output, store it in orchestration state, done.

– You’re treating an entity as a database query layer. Entities are not a replacement for a real database when you need filtering, sorting, or aggregation. An entity’s `getState` returns the whole thing — there’s no query language. If you need to find all carts with more than three items, you’re fetching all entities individually and filtering in application code, which is a bad time.

Use entities when: multiple orchestrations need to read and write the same state concurrently, that state needs to survive beyond any single orchestration’s lifetime, and the operations are discrete enough to model as named methods. Shopping carts, user session data, rate limit counters, and game state are the canonical good fits. Everything else — evaluate honestly before you commit.

Which Form Do I Use? A Decision Table for Real Scenarios

## Which Form Do I Use? A Decision Table for Real Scenarios

The answer is almost never obvious from the feature description alone. It becomes obvious once you ask: *who calls this, what does it need to remember, and for how long?*

| Scenario | Wrong Choice | Right Form | Why |
|—|—|—|—|
| User approval step in an order flow | Activity | Orchestrator | The workflow needs to pause, wait for an external event (the approval signal), and resume. An activity runs once and exits — it has no mechanism to suspend mid-execution and wait days for a human. |
| Shopping cart shared between a web app and a background job | Orchestrator | Entity | The cart outlives any single workflow run and gets written to from multiple independent callers. An orchestrator’s in-memory state vanishes on replay; an entity has a persistent, addressable state store that any caller can signal directly. |
| Sending a single transactional email | Orchestrator or Entity | Activity | This is a discrete unit of work with no coordination required. Wrap the SMTP or API call in an activity, let the retry policy handle transient failures, and move on. Pulling an orchestrator in here is pure overhead. |
| Fan-out processing of 10,000 records | Single activity in a loop | Orchestrator + Activity (sub-orchestrations or `Task.WhenAll`) | The orchestrator spawns child activities in parallel. Doing this inside one activity gives you no visibility, no per-record retry, and a single point of failure that restarts from zero on any crash. |
| Rate-limited API polling with exponential backoff | Entity | Orchestrator | The orchestrator uses `CreateTimer` between retries, replays cleanly, and exposes its current state through the instance status API. There is no persistent shared state needed here — just coordination logic with built-in timer support. |
| Distributed counter incremented from multiple services | Orchestrator | Entity | Multiple external services need to call `increment` without owning a workflow run. An entity accepts signals from any caller, applies operations serially to avoid race conditions, and holds the count across restarts. An orchestrator cannot safely accept arbitrary external writes during replay. |

### The Three-Line Heuristic

When the table still doesn’t resolve it, apply this in order:

– **If it coordinates** → orchestrator.
– **If it does work** → activity.
– **If it owns state that outlives a single workflow run** → entity.

Most designs fit cleanly into one bucket. The ambiguous cases are usually orchestrator vs. entity, and the confusion is worth addressing directly.

### The Orchestrator-vs-Entity Confusion

This is the most common structural mistake in Durable Functions codebases: using an orchestrator with in-memory variables to track something that multiple external callers need to update.

It looks like it works in development. It fails in production in a specific, hard-to-diagnose way.

Orchestrators replay their entire history every time they wake up. Any variable you set inside an orchestrator function is *re-derived* from that replay — it is not read from durable storage directly. If an external caller tries to send a concurrent signal that mutates “state” the orchestrator is holding, the ordering is fragile, the replay can drop or misapply it, and you get inconsistent results.

Entity state is different. An entity persists its state object to storage independently of any workflow run. When you signal `myCart@user-123` from a web API handler, a background job, *and* an orchestrator simultaneously, the entity runtime serializes those operations and applies them one at a time against the stored state. No replay issue, no lost update.

The practical signal that you’ve made this mistake: your orchestrator has a `Dictionary` or `List` variable that grows as the workflow runs, and you’re passing its contents out-of-band to other services. That data structure belongs in an entity.

The other direction — using an entity when you need sequencing with external events — is less common but does happen. Entities don’t have native timer support or the event-waiting primitives (`WaitForExternalEvent`) that orchestrators expose. If your “stateful thing” needs to wait for a webhook callback before proceeding to a next step, it probably needs to be an orchestrator, not an entity with a flag that a polling job checks every 30 seconds.

Debugging a Stuck Orchestrator: The HTTP Management API

## Debugging a Stuck Orchestrator: The HTTP Management API

“Stuck” means one of two things in practice. Either the orchestrator is parked on a `waitForExternalEvent` call that never received its signal — the webhook that was supposed to fire it got dropped, the upstream service timed out, whatever — or an activity is caught in a retry loop because someone set `maxNumberOfAttempts` to a large number and `firstRetryIntervalInMilliseconds` to something like 30 seconds, and now you have an instance that will sit there retrying for hours before it finally fails cleanly.

Both look identical from the outside: `runtimeStatus` is `Running`, nothing is moving, your logs show no recent activity. The HTTP management API is how you figure out which one you’re dealing with.

### Check Instance State First

Every Durable Functions app exposes a management HTTP surface. The base URL depends on your host, but locally it’s `http://localhost:7071`. In Azure it’s your function app URL. The system key (`taskHub`) gets appended automatically if you’re using the default task hub name.

bash
curl -s \
“http://localhost:7071/runtime/webhooks/durabletask/instances/{instanceId}?taskHub=DurableFunctionsHub&connection=Storage&code={systemKey}” \
| jq .

The response shape looks like this:

json
{
“name”: “OrderOrchestrator”,
“instanceId”: “abc123def456”,
“runtimeStatus”: “Running”,
“input”: { “orderId”: “ORD-9921” },
“output”: null,
“createdTime”: “2024-11-14T09:22:10Z”,
“lastUpdatedTime”: “2024-11-14T09:22:45Z”,
“historyEvents”: []
}

The `runtimeStatus` field carries the signal you care about:

| Value | What it means |
|—|—|
| `Pending` | Scheduled but hasn’t started executing yet |
| `Running` | Active — could be mid-activity or waiting on external event |
| `Completed` | Finished normally, `output` field has the return value |
| `Failed` | Threw an unhandled exception, `output` contains the error |
| `Terminated` | Killed manually via the terminate endpoint |

`lastUpdatedTime` is the useful field when `runtimeStatus` is `Running`. If it hasn’t moved in 20 minutes and the orchestration normally completes in under a minute, something is parked. Check the history to confirm whether it’s waiting on `waitForExternalEvent` or spinning inside an activity retry. You get full history by adding `&showHistory=true&showHistoryOutput=true` to the query string.

### Terminate a Stuck Instance

If the instance is stuck and you just need it gone — retry loop running up your storage bill, for instance — terminate it directly:

bash
curl -s -X POST \
“http://localhost:7071/runtime/webhooks/durabletask/instances/abc123def456/terminate?reason=ManualKill-RetryLoopMisconfigured&taskHub=DurableFunctionsHub&connection=Storage&code={systemKey}”

The `reason` string gets recorded in the history. Use it. Logging `reason=test` in production means you’ll have no idea why you killed that instance three months later. Write something specific.

After termination, `runtimeStatus` flips to `Terminated`. The orchestrator’s cleanup handler won’t run — this is a hard stop, not a graceful shutdown. If you have compensating logic or rollback steps, termination bypasses them entirely. That’s the trade-off.

### Unblock a Waiting Orchestrator by Raising an External Event

The cleaner situation is when the orchestrator is healthy and just waiting on an event that was never delivered. A webhook callback got dropped. A human approval queue went silent. The third-party API never called back. In those cases you don’t want to terminate — you want to inject the missing signal so the orchestration can continue normally.

bash
curl -s -X POST \
“http://localhost:7071/runtime/webhooks/durabletask/instances/abc123def456/raiseEvent/ApprovalReceived?taskHub=DurableFunctionsHub&connection=Storage&code={systemKey}” \
-H “Content-Type: application/json” \
-d ‘{“approved”: true, “approvedBy”: “ops-team”}’

The event name in the URL — `ApprovalReceived` here — must match exactly what your orchestrator passed to `waitForExternalEvent`. Case matters. A common failure mode is sending `approvalReceived` when the orchestrator is listening for `ApprovalReceived` and wondering why nothing happens.

The payload in the request body becomes the value returned by `waitForExternalEvent`. Your orchestrator picks up execution on the next poll cycle, usually within a few seconds.

This is genuinely useful in staging. Instead of wiring up a full callback chain just to test the post-approval flow, you raise the event manually and drive the orchestration wherever you need it.

### Durable Functions Monitor for When curl Gets Tedious

Raw HTTP calls work, but they get old fast when you’re inspecting 15 instances across three task hubs. The [Durable Functions Monitor](https://github.com/scale-tone/DurableFunctionsMonitor) (open-source, maintained by scale-tone on GitHub) gives you a browser UI for all of this — instance list, history timeline, the ability to raise events or terminate instances without constructing curl commands manually.

It runs as a standalone Azure Function you deploy alongside your own app, or locally via the VS Code extension. The history view is the genuinely useful part: it renders the orchestrator’s event sequence as a timeline, so you can see exactly where execution is parked without parsing raw JSON. For local debugging or staging environments, it’s a practical shortcut. In production, treat the HTTP API as authoritative — the monitor is a read/write UI on top of the same endpoints, which means it carries the same risk of accidentally terminating a live instance if you click the wrong button.

Storage Backend: Why Table Storage Is Not the Only Option in 2026

## Storage Backend: Why Table Storage Is Not the Only Option in 2026

The default setup for Durable Functions is Azure Storage — specifically a combination of Table Storage for history and instance state, Blob Storage for large payloads, and Queue Storage for orchestration messages. For low-volume workflows this is genuinely fine. You deploy, it works, you move on.

The problem shows up when history grows. The 50,000-event ceiling on a single orchestration instance is not theoretical — once you hit it, query latency degrades noticeably and the default storage backend starts working against you. High-throughput fan-out patterns, long-running loops that emit many events, or orchestrations that aggregate large numbers of activity results all push toward that ceiling faster than most people expect.

### The SQL Backend Is the Realistic Alternative

The `Microsoft.DurableTask.SqlServer` NuGet package (the DurableTask-SqlServer extension) replaces Azure Storage with a proper relational backend. It works against Azure SQL, a self-hosted SQL Server instance, or anything SQL Server-compatible. The history and instance tables become real SQL tables, which means you can run arbitrary queries across running and completed instances without going through the Durable Functions management API.

That queryability matters in practice. With Table Storage you are largely limited to what the SDK exposes — filtered queries by instance ID, custom status, or creation time. With SQL you can join, aggregate, and inspect however you want. Debugging a class of failures across thousands of instances becomes a SELECT statement instead of a loop through SDK calls.

To switch, you configure `storageProvider` in `host.json`:

json
{
“version”: “2.0”,
“extensions”: {
“durableTask”: {
“storageProvider”: {
“type”: “mssql”,
“connectionStringName”: “SQLDB_Connection”,
“createDatabaseIfNotExists”: true,
“schemaName”: “dt”
}
}
}
}

The `connectionStringName` points to an entry in your application settings or `local.settings.json`. The `createDatabaseIfNotExists` flag is useful during development but you should disable it in production and handle schema creation explicitly — the extension ships migrations you run via the `dt` schema setup scripts in the package.

### The Actual Trade-off

Table Storage is zero-config. You point Durable Functions at a storage account and it builds what it needs. There is nothing to provision, no schema to manage, no migration to run on deploy. For most workflows that do not hit scale ceilings, that simplicity has real value.

SQL costs you operational overhead. Someone needs to own the database, run schema migrations when the extension version changes, and make sure connection limits are not a problem under burst load. A common failure mode is treating the SQL backend like Table Storage — deploying the extension update without checking whether the schema migration is included — and then hitting broken history writes in production.

The decision is not complicated: if you are already running Azure SQL or a self-hosted SQL Server and your orchestrations need cross-instance querying or your event counts are pushing against the Table Storage ceiling, switch. If your workflows are straightforward and bounded in history depth, stay on Table Storage and skip the operational surface area.

One edge case worth flagging: teams moving off Azure entirely toward self-hosted infrastructure often pick the SQL backend specifically to eliminate the Azure Storage dependency. That is a legitimate path — the SQL backend has no hard coupling to Azure services.

FAQ

## FAQ

**Q: Can an orchestrator call another orchestrator?**

Yes. This is called a sub-orchestration, and you trigger it with `context.df.callSubOrchestrator`. The pattern is useful when you want to break a large workflow into independently managed phases, or when you need a different retry policy for each phase without tangling all that logic into one orchestrator function.

One practical use: you have an order processing workflow where payment, fulfillment, and notification each have different failure tolerances. Wrap each in its own sub-orchestrator with its own retry config, then call them from a parent.

The thing to watch: history overhead compounds. Every sub-orchestration maintains its own execution history, and the parent tracks each child’s completion event. Go more than 2–3 levels deep and you will start seeing history replay times that surprise you, especially under load. The runtime has to reconstitute state by replaying all recorded events on each wake-up. Keep the hierarchy flat unless decomposition genuinely buys you something.

**Q: What happens if my activity throws an uncaught exception?**

The activity fails, control returns to the orchestrator, and the runtime wraps the error in a `TaskFailedException`. From there, the configured retry policy takes over — if you specified retry intervals and max attempts, the orchestrator will schedule retries automatically without any additional code on your part.

Once retries are exhausted, the orchestrator itself faults unless your orchestrator code catches the exception explicitly. This is the point where compensation logic lives: if step 3 failed after steps 1 and 2 succeeded, you need to decide whether to roll back, alert, or park the instance for manual intervention. None of that happens automatically. A common failure mode is assuming the retry policy handles everything and discovering that a permanently failed activity silently kills the entire orchestration instance with no cleanup.

The practical pattern: wrap `callActivity` calls in try/catch at the orchestrator level for any activity that mutates external state. Retries are for transient failures; compensation is for the case where retries are not enough.

**Q: Do entities work in the Durable Functions isolated worker model on .NET?**

Yes. Entity support in the isolated worker model — via `Microsoft.Azure.Functions.Worker.Extensions.DurableTask` — reached parity with the in-process model. This is the recommended path for .NET 8 and .NET 9, since the in-process model is on a deprecation timeline.

If you hit documentation that implies entities are in-process only, it is outdated. The programming model is slightly different in isolated: you define entity operations using the `TaskEntity` base class rather than the `IDurableEntityContext` interface pattern. The underlying behavior — serialized operation dispatch, persistent state snapshot — is identical.

**Q: Is there a cost difference between using an entity vs an orchestrator for state?**

Yes, and it matters at scale. Both store state in the configured backend (Azure Storage or the SQL provider), but their storage patterns are different.

An orchestrator accumulates history. Every activity call, every external event, every timer fires an entry into the history table. The longer an orchestration runs and the more events it processes, the larger that history grows. On each wake-up, the runtime replays the full history to reconstruct current state.

An entity stores only the current state snapshot. When the entity processes an operation, the snapshot is updated in place. There is no replay of past operations. For state that gets read and written frequently — a counter, a session record, a running aggregate — this is meaningfully cheaper both in storage space and in the CPU time spent on reconstitution.

The practical threshold: if you are updating a piece of state more than a handful of times over its lifetime, an entity will be more efficient than an orchestrator modeling the same thing via accumulated events. If your “stateful thing” is really a long-running process with conditional branching and sequenced steps, that is still an orchestrator’s job.


Eric Woo

Written by Eric Woo

Lead AI Engineer & SaaS Strategist

Eric is a seasoned software architect specializing in LLM orchestration and autonomous agent systems. With over 15 years in Silicon Valley, he now focuses on scaling AI-first applications.

Leave a Comment