Durable Functions are an extension of Azure Functions that allow you to write stateful functions in a serverless compute environment. The most surprising thing about them is that they don’t actually run your code continuously; instead, they orchestrate a series of small, stateless executions based on a history of events.

Let’s see this in action. Imagine you need to process a large batch of images, and each image takes a few minutes to process. A traditional approach might involve a single, long-running VM, which is expensive and inefficient. With Durable Functions, you can fan out this work.

Here’s a conceptual Orchestrator function:

[FunctionName("ImageProcessing_Orchestrator")]
public static async Task RunOrchestrator(
    [OrchestrationTrigger] IDurableOrchestrationContext context)
{
    var imageFileNames = context.GetInput<string[]>();
    var tasks = new List<Task>();

    foreach (var fileName in imageFileNames)
    {
        tasks.Add(context.CallActivityAsync("ProcessImage", fileName));
    }

    await Task.WhenAll(tasks);
    await context.CallActivityAsync("NotifyCompletion", null);
}

And an Activity function that does the actual work:

[FunctionName("ProcessImage")]
public static async Task ProcessImage([ActivityTrigger] string fileName, ILogger log)
{
    log.LogInformation($"Processing image: {fileName}");
    // Simulate image processing work
    await Task.Delay(TimeSpan.FromSeconds(30));
    log.LogInformation($"Finished processing image: {fileName}");
}

When you start the orchestrator, say with a list of 100 image files, Durable Functions doesn’t spin up 100 long-running tasks. Instead, it:

  1. Records that the orchestrator started and received the list of files.
  2. Schedules ProcessImage activities for the first batch of files (e.g., 10 at a time, depending on configuration).
  3. When an ProcessImage activity completes, it sends an event back to the orchestrator’s history.
  4. The orchestrator, upon seeing the completion event, schedules the next ProcessImage activity.
  5. This continues until all ProcessImage activities are scheduled and completed.
  6. Finally, the orchestrator schedules the NotifyCompletion activity.

The key here is the "orchestration history." Durable Functions stores every event—function start, activity scheduled, activity completed, timer fired—in a backing store (usually Azure Storage). When an orchestrator function is woken up, it replays its execution from the beginning, but only executes code that isn’t an activity call. When it encounters an activity call that hasn’t completed yet, it yields execution and waits for the result, effectively pausing itself without holding up a server thread. This "replay" mechanism is what makes it appear stateful and fault-tolerant.

You control the flow through the orchestrator code. For instance, you can implement complex logic like:

  • Human interaction: Using context.WaitForExternalEventAsync to pause an orchestrator until a human approves something.
  • Timers: Using context.CreateTimer to schedule actions for a future time.
  • Fan-out/Fan-in: As shown in the image processing example, you can start many activities concurrently and then aggregate their results.
  • Error handling: Using try-catch blocks around CallActivityAsync to handle failures gracefully, perhaps retrying an activity or taking an alternative path.

The "state" isn’t stored in memory; it’s materialized from the event history in the storage account. This is why you can have orchestrators running for days or weeks. The orchestrator function itself is a stateless Azure Function that’s executed on demand by the Durable Functions extension. The extension manages the orchestration’s state by reading and writing to the history table.

A common point of confusion is how to handle configuration for the number of concurrent activities. While the orchestrator code above shows a simple foreach loop, in practice, you’d want to limit concurrency to avoid overwhelming downstream services or your own function app’s scaling limits. You can achieve this by using Task.WhenAll on batches of tasks. For example, to process 10 images concurrently:

[FunctionName("ImageProcessing_Orchestrator")]
public static async Task RunOrchestrator(
    [OrchestrationTrigger] IDurableOrchestrationContext context)
{
    var imageFileNames = context.GetInput<string[]>();
    var tasks = new List<Task>();
    int concurrencyLimit = 10; // Process 10 at a time

    for (int i = 0; i < imageFileNames.Length; i += concurrencyLimit)
    {
        var batch = imageFileNames.Skip(i).Take(concurrencyLimit).ToList();
        var batchTasks = new List<Task>();
        foreach (var fileName in batch)
        {
            batchTasks.Add(context.CallActivityAsync("ProcessImage", fileName));
        }
        tasks.Add(Task.WhenAll(batchTasks)); // Wait for the current batch to finish
    }

    await Task.WhenAll(tasks); // Wait for all batches to finish
    await context.CallActivityAsync("NotifyCompletion", null);
}

This pattern ensures you’re not trying to spin up thousands of activities simultaneously, which could lead to throttling or resource exhaustion. The Durable Functions extension will still manage the individual activity executions and their history.

Understanding the replay mechanism is crucial. If your orchestrator code has side effects outside of CallActivityAsync, CallActivityWithRetryAsync, WaitForExternalEventAsync, or CreateTimer, those side effects will occur on every replay, potentially leading to unexpected behavior or duplicate operations. Always ensure orchestrator logic is purely deterministic and focuses on orchestrating activities.

The next step in mastering Durable Functions is exploring error handling patterns like retries and managing long-running operations with timeouts.

Want structured learning?

Take the full Azure-functions course →