Blocking Activities & Triggers
This guide explains how to implement custom blocking activities and triggers in Elsa Workflows v3. Blocking activities use bookmarks to pause workflow execution and wait for external events, while triggers start or resume workflows in response to external stimuli.
Table of Contents
Overview
Elsa Workflows supports two primary patterns for coordinating with external systems:
Blocking Activities (Bookmarks): Activities that pause workflow execution and create a bookmark that can be resumed later by an external event
Trigger Activities: Activities that start or resume workflows when specific events occur
Both patterns use Elsa's bookmark system under the hood. The key difference is in their usage:
Blocking activities are placed inline in a workflow and pause execution at that point
Triggers are typically placed at the start of a workflow and wait for specific events to start or resume execution
Bookmarks and Resume Flows
What is a Bookmark?
A bookmark is Elsa's mechanism for pausing a workflow and storing its state until an external event occurs. When a workflow creates a bookmark:
The workflow execution pauses at the current activity
A bookmark record is persisted to the database
The workflow instance enters a suspended state
External code can resume the workflow by providing the bookmark information
Bookmark Lifecycle
┌─────────────────┐
│ Activity │
│ Executes │
└────────┬────────┘
│
▼
┌─────────────────┐
│ CreateBookmark │ ← Bookmark created with unique hash
└────────┬────────┘
│
▼
┌─────────────────┐
│ Workflow │
│ Suspended │ ← Workflow state persisted
└────────┬────────┘
│
│ External event triggers resume
▼
┌─────────────────┐
│ ResumeAsync │ ← Bookmark matched and consumed
└────────┬────────┘
│
▼
┌─────────────────┐
│ Workflow │
│ Continues │ ← Execution resumes from bookmark
└─────────────────┘Bookmark Correlation
Bookmarks use a hash-based correlation mechanism to match external events to the correct workflow instance. When creating a bookmark, you provide:
Bookmark Name: A logical identifier (e.g., "WaitForApproval")
Payload: Optional data used to calculate the bookmark hash
Correlation ID: Optional workflow-level correlation for multi-instance scenarios
The bookmark hash is calculated from these values and is used to locate the correct bookmark when resuming.
When to Use Blocking Activities
Use blocking activities when your workflow needs to:
Wait for human interaction: Approvals, form submissions, manual reviews
Coordinate with external systems: Wait for callbacks, webhooks, or async operations
Implement timeouts: Combine with timers to handle time-sensitive operations
Handle long-running operations: Operations that may take hours, days, or weeks
Common Use Cases
Human approvals
Expense approval, document review
WaitForApproval activity
External callbacks
Payment gateway, third-party API
Webhook receiver
Scheduled operations
Wait until specific date/time
Timer + bookmark
Fan-in scenarios
Wait for multiple signals
Trigger with aggregation
Creating a Blocking Activity
Let's create a complete example of a blocking activity that waits for an approval decision.
Step 1: Define the Activity
using Elsa.Extensions;
using Elsa.Workflows;
using Elsa.Workflows.Attributes;
using Elsa.Workflows.Models;
namespace CustomActivities;
/// <summary>
/// A blocking activity that waits for an approval decision from an external system.
/// Creates a bookmark and provides a resume URL that can be used to approve or reject.
/// </summary>
[Activity("Custom", "Blocking", "Waits for an approval decision")]
public class WaitForApprovalActivity : Activity
{
/// <summary>
/// Input: The approval request message or context
/// </summary>
public Input<string> ApprovalMessage { get; set; } = default!;
/// <summary>
/// Output: The URL that can be used to resume this workflow with an approval decision
/// </summary>
public Output<string?> ResumeUrl { get; set; } = default!;
protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
{
// Get the approval message
var message = context.Get(ApprovalMessage);
// Create bookmark arguments with a unique payload
var bookmarkArgs = new CreateBookmarkArgs
{
// Bookmark name - used for logical grouping
BookmarkName = "WaitForApproval",
// Payload - used to calculate the bookmark hash for correlation
// Include any data needed to uniquely identify this specific approval
Payload = new Dictionary<string, object>
{
["ApprovalMessage"] = message ?? string.Empty,
["ActivityInstanceId"] = context.ActivityExecutionContext.Id
},
// Callback invoked when the bookmark is resumed
Callback = OnResumeAsync,
// Auto-burn: bookmark is consumed after one use (true by default)
AutoBurn = true
};
// Create the bookmark
var bookmark = context.CreateBookmark(bookmarkArgs);
// Try to generate a resume URL using the HTTP module's helper
// This requires Elsa.Http to be installed and configured
string? resumeUrl = null;
try
{
// GenerateBookmarkTriggerUrl is an extension method from Elsa.Http
// It creates a tokenized URL that can be used to resume this bookmark
resumeUrl = context.GenerateBookmarkTriggerUrl(bookmark.Id);
}
catch (Exception ex)
{
// If HTTP module is not available, log a warning
// In production, you might use a custom URL generation strategy
context.AddExecutionLogEntry(
"Warning",
$"Could not generate resume URL: {ex.Message}. HTTP module may not be configured.");
}
// Set the resume URL as output so it can be used by subsequent activities
context.Set(ResumeUrl, resumeUrl);
// Add execution log for debugging
context.AddExecutionLogEntry(
"Info",
$"Waiting for approval. Message: {message}. Resume URL: {resumeUrl ?? "N/A"}");
}
/// <summary>
/// Callback invoked when the bookmark is resumed
/// </summary>
private async ValueTask OnResumeAsync(ActivityExecutionContext context)
{
// Get the input provided when resuming
var input = context.WorkflowInput;
// Extract the decision from input
var decision = input.TryGetValue("Decision", out var decisionValue)
? decisionValue?.ToString()
: null;
// Complete the activity with an outcome based on the decision
var outcome = decision?.ToLowerInvariant() switch
{
"approved" => "Approved",
"rejected" => "Rejected",
_ => "Done"
};
await context.CompleteActivityWithOutcomesAsync(outcome);
}
}See the complete implementation in WaitForApprovalActivity.cs.
Step 2: Register the Activity
In your Program.cs or startup configuration:
using Elsa.Extensions;
var builder = WebApplication.CreateBuilder(args);
// Add Elsa services
builder.Services.AddElsa(elsa =>
{
// Register your custom activity
elsa.AddActivity<WaitForApprovalActivity>();
// Other configuration...
});Key Concepts
CreateBookmark vs CreateBookmarkArgs
Elsa provides multiple ways to create bookmarks:
// Method 1: Using CreateBookmarkArgs (recommended for complex scenarios)
var bookmark = context.CreateBookmark(new CreateBookmarkArgs
{
BookmarkName = "MyBookmark",
Payload = new { Key = "Value" },
Callback = OnResumeAsync,
AutoBurn = true
});
// Method 2: Simple bookmark (for basic scenarios)
var bookmark = context.CreateBookmark("MyBookmark", OnResumeAsync);ActivityExecutionContext APIs
The ActivityExecutionContext provides several key methods:
CreateBookmark(CreateBookmarkArgs): Creates a bookmark with detailed configurationCreateBookmark(string, Func<ActivityExecutionContext, ValueTask>): Creates a simple bookmarkGenerateBookmarkTriggerUrl(string bookmarkId): Generates a tokenized HTTP URL for resuming (requires Elsa.Http)CompleteActivityWithOutcomesAsync(params string[]): Completes the activity with specific outcomesSet<T>(Output<T>, T): Sets an output valueGet<T>(Input<T>): Gets an input value
Resuming Workflows
There are multiple patterns for resuming workflows from external code.
Pattern 1: Resume by Bookmark Stimulus
This pattern uses a "stimulus" - a payload containing the bookmark name and correlation data. Elsa will find all matching bookmarks and resume them.
using Elsa.Workflows.Runtime;
using Elsa.Workflows.Runtime.Stimuli;
public class ApprovalController : ControllerBase
{
private readonly IWorkflowResumer _workflowResumer;
public ApprovalController(IWorkflowResumer workflowResumer)
{
_workflowResumer = workflowResumer;
}
[HttpPost("approve")]
public async Task<IActionResult> Approve([FromBody] ApprovalRequest request)
{
// Create a stimulus with the bookmark name and payload
var stimulus = new BookmarkStimulus
{
BookmarkName = "WaitForApproval",
Payload = new Dictionary<string, object>
{
["ApprovalMessage"] = request.Message,
["ActivityInstanceId"] = request.ActivityInstanceId
}
};
// Input to pass to the resumed workflow
var input = new Dictionary<string, object>
{
["Decision"] = "Approved",
["ApprovedBy"] = User.Identity?.Name ?? "System",
["ApprovedAt"] = DateTime.UtcNow
};
// Resume all workflows matching this stimulus
var results = await _workflowResumer.ResumeAsync(stimulus, input);
if (results.Count == 0)
{
return NotFound(new { Message = "No matching workflow found" });
}
return Ok(new { ResumedWorkflows = results.Count });
}
}Pattern 2: Resume by Bookmark ID
This pattern directly targets a specific bookmark using its ID. This is more precise but requires storing the bookmark ID.
[HttpPost("resume/{bookmarkId}")]
public async Task<IActionResult> ResumeByBookmarkId(
string bookmarkId,
[FromBody] Dictionary<string, object> input)
{
// Resume a specific bookmark by its ID
var result = await _workflowResumer.ResumeAsync(bookmarkId, input);
if (result == null)
{
return NotFound(new { Message = "Bookmark not found or already consumed" });
}
return Ok(new { WorkflowInstanceId = result.WorkflowInstanceId });
}Pattern 3: Resume via HTTP Trigger URL
When using GenerateBookmarkTriggerUrl, Elsa automatically creates an HTTP endpoint that can resume the workflow:
POST /workflows/resume/{token}
Content-Type: application/json
{
"Decision": "Approved",
"ApprovedBy": "[email protected]"
}The token contains encrypted bookmark information, so you don't need to manually specify the bookmark ID or stimulus.
See complete controller examples in ApprovalController.cs.
Creating Trigger Activities
Triggers are special activities that can start or resume workflows based on external events. They inherit from the Trigger base class and implement payload generation.
Example: SignalFanIn Trigger
This example shows a trigger that waits for multiple signals before continuing:
using Elsa.Workflows;
using Elsa.Workflows.Attributes;
using Elsa.Workflows.Models;
namespace CustomActivities;
/// <summary>
/// A trigger that waits for multiple signals with the same aggregation key.
/// Useful for fan-in scenarios where multiple parallel operations must complete.
/// </summary>
[Activity("Custom", "Triggers", "Waits for multiple signals to arrive")]
public class SignalFanInTrigger : Trigger
{
/// <summary>
/// The name of the signal to wait for
/// </summary>
public Input<string> SignalName { get; set; } = default!;
/// <summary>
/// The aggregation key used to group signals together
/// </summary>
public Input<string> AggregationKey { get; set; } = default!;
/// <summary>
/// The number of signals required before continuing
/// </summary>
public Input<int> RequiredCount { get; set; } = new(2);
/// <summary>
/// GetTriggerPayloads is called by Elsa to index this trigger.
/// Return all possible payload combinations that should activate this trigger.
/// </summary>
protected override IEnumerable<object> GetTriggerPayloads(TriggerIndexingContext context)
{
// Get the configured values
var signalName = context.Get(SignalName);
var aggregationKey = context.Get(AggregationKey);
// Return a payload that will be used to match incoming signals
yield return new SignalPayload
{
SignalName = signalName ?? string.Empty,
AggregationKey = aggregationKey ?? string.Empty
};
}
protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
{
// When the trigger executes, create a bookmark to wait for signals
var signalName = context.Get(SignalName);
var aggregationKey = context.Get(AggregationKey);
var requiredCount = context.Get(RequiredCount);
// Store received signals in workflow state
var receivedSignals = context.GetVariable<List<SignalData>>("ReceivedSignals")
?? new List<SignalData>();
// Check if we've received enough signals
if (receivedSignals.Count >= requiredCount)
{
// All signals received, complete the activity
await context.CompleteActivityAsync();
}
else
{
// Create a bookmark to wait for more signals
var bookmark = context.CreateBookmark(new CreateBookmarkArgs
{
BookmarkName = "SignalFanIn",
Payload = new SignalPayload
{
SignalName = signalName ?? string.Empty,
AggregationKey = aggregationKey ?? string.Empty
},
Callback = OnSignalReceivedAsync
});
context.AddExecutionLogEntry(
"Info",
$"Waiting for signals. Received: {receivedSignals.Count}/{requiredCount}");
}
}
private async ValueTask OnSignalReceivedAsync(ActivityExecutionContext context)
{
// Get the signal data from input
var signalData = context.WorkflowInput.TryGetValue("SignalData", out var data)
? data as SignalData
: null;
if (signalData != null)
{
// Add to received signals
var receivedSignals = context.GetVariable<List<SignalData>>("ReceivedSignals")
?? new List<SignalData>();
receivedSignals.Add(signalData);
context.SetVariable("ReceivedSignals", receivedSignals);
// Check if we have enough signals now
var requiredCount = context.Get(RequiredCount);
if (receivedSignals.Count >= requiredCount)
{
await context.CompleteActivityAsync();
}
else
{
// Re-create the bookmark for the next signal
await ExecuteAsync(context);
}
}
}
}
/// <summary>
/// Payload structure for signal triggers
/// </summary>
public record SignalPayload
{
public string SignalName { get; init; } = string.Empty;
public string AggregationKey { get; init; } = string.Empty;
}
/// <summary>
/// Data structure for received signals
/// </summary>
public record SignalData
{
public string SignalName { get; init; } = string.Empty;
public string Source { get; init; } = string.Empty;
public DateTime ReceivedAt { get; init; } = DateTime.UtcNow;
public Dictionary<string, object> Data { get; init; } = new();
}See the complete implementation in SignalFanInTrigger.cs.
Trigger Indexing
Elsa uses trigger indexing to efficiently match incoming events to workflows. When a trigger activity is registered:
GetTriggerPayloadsis called during indexingThe returned payloads are hashed and stored in the trigger index
When an event occurs, Elsa computes a hash and looks up matching workflows
Matching workflows are started or resumed
This allows Elsa to quickly find relevant workflows without scanning all workflow definitions.
Best Practices
1. Correlation and Idempotency
Always design your bookmarks and triggers with correlation in mind:
// ✅ GOOD: Include unique correlation data
var payload = new Dictionary<string, object>
{
["OrderId"] = orderId,
["CustomerId"] = customerId,
["RequestTimestamp"] = DateTime.UtcNow.Ticks
};
// ❌ BAD: Generic bookmarks without correlation
var payload = new Dictionary<string, object>
{
["Type"] = "Approval"
};Idempotency: Ensure that resuming the same bookmark multiple times with the same input doesn't cause issues. Use the AutoBurn = true setting to consume bookmarks after one use.
2. Timeouts and Fallback Paths
Always provide timeout handling for blocking activities:
// In your workflow:
var waitForApproval = new WaitForApprovalActivity { ApprovalMessage = new("Please approve") };
var timer = new Delay { Duration = TimeSpan.FromDays(7) };
// Race between approval and timeout
var fork = new Fork { JoinMode = ForkJoinMode.WaitAny };
fork.Branches = new[] { waitForApproval, timer };3. Distributed Locking
Elsa's WorkflowResumer automatically handles distributed locking when resuming workflows. This ensures that:
Multiple resume requests for the same bookmark don't cause race conditions
Workflows execute safely in clustered/multi-instance deployments
Bookmark consumption is atomic
You don't need to implement your own locking logic - Elsa handles this internally using IDistributedLockProvider.
4. Scheduled Bookmarks and Timers
For time-based operations, use Elsa's built-in timer activities or scheduled bookmarks:
// Schedule a bookmark to execute at a specific time
var scheduledBookmark = context.CreateBookmark(new CreateBookmarkArgs
{
BookmarkName = "ScheduledTask",
Callback = OnScheduledTimeAsync,
ScheduledAt = DateTime.UtcNow.AddHours(24)
});The DefaultBookmarkScheduler handles scheduled bookmarks using background jobs.
Timezone Considerations:
Store times in UTC to avoid timezone issues
Use
DateTime.UtcNowinstead ofDateTime.NowWhen displaying times to users, convert to their local timezone
Single-Instance vs Clustered:
In single-instance deployments, the scheduler runs in the same process
In clustered deployments, use a distributed scheduler (e.g., Quartz.NET with shared storage)
Ensure only one instance processes each scheduled bookmark
5. Bookmark Retention and Cleanup
Configure bookmark retention policies to prevent database growth:
// In Program.cs
builder.Services.AddElsa(elsa =>
{
elsa.UseWorkflowManagement(management =>
{
management.SetRetentionPolicy(policy =>
{
policy.RetainCompletedWorkflows(TimeSpan.FromDays(30));
policy.RetainFailedWorkflows(TimeSpan.FromDays(90));
// Bookmarks are cleaned up with their workflows
});
});
});6. Error Handling and Fault Tolerance
Handle failures gracefully in your resume callbacks:
private async ValueTask OnResumeAsync(ActivityExecutionContext context)
{
try
{
// Process resume logic
var input = context.WorkflowInput;
// ... validation and processing ...
await context.CompleteActivityAsync();
}
catch (Exception ex)
{
// Log the error
context.AddExecutionLogEntry("Error", $"Resume failed: {ex.Message}");
// Optionally fault the workflow or retry
await context.ScheduleFaultActivityAsync(ex);
}
}7. Testing Blocking Activities
Test your blocking activities thoroughly:
// Example test structure (requires Elsa.Testing)
public class WaitForApprovalTests
{
[Fact]
public async Task WaitForApproval_ShouldCreateBookmark()
{
// Arrange
var workflow = new WorkflowBuilder()
.WithActivity<WaitForApprovalActivity>()
.Build();
// Act
var result = await RunWorkflowAsync(workflow);
// Assert
Assert.Equal(WorkflowStatus.Suspended, result.Status);
Assert.Single(result.Bookmarks);
}
[Fact]
public async Task WaitForApproval_ShouldResumeWithApproval()
{
// Arrange
var workflow = new WorkflowBuilder()
.WithActivity<WaitForApprovalActivity>()
.Build();
var runResult = await RunWorkflowAsync(workflow);
var bookmarkId = runResult.Bookmarks.First().Id;
// Act
var resumeResult = await ResumeWorkflowAsync(
bookmarkId,
new { Decision = "Approved" });
// Assert
Assert.Equal(WorkflowStatus.Completed, resumeResult.Status);
}
}Troubleshooting
Common Issues and Solutions
1. Bookmark Not Found When Resuming
Symptom: ResumeAsync returns no results or null.
Possible Causes:
Bookmark payload hash doesn't match
Bookmark already consumed (AutoBurn = true)
Workflow instance deleted or expired
Solutions:
Verify the payload data matches exactly what was used during bookmark creation
Check the
AutoBurnsetting - set tofalseif the bookmark should be reusableEnsure the workflow instance still exists in the database
Use bookmark ID-based resume for exact matching
2. GenerateBookmarkTriggerUrl Throws Exception
Symptom: Exception when calling GenerateBookmarkTriggerUrl.
Possible Causes:
Elsa.Http module not installed or configured
Base URL not configured
Solutions:
// Install Elsa.Http package
// In Program.cs:
builder.Services.AddElsa(elsa =>
{
elsa.UseHttp(http =>
{
http.ConfigureHttpOptions(options =>
{
options.BaseUrl = new Uri("https://your-server.com");
});
});
});
// Or handle the exception gracefully:
try
{
var url = context.GenerateBookmarkTriggerUrl(bookmark.Id);
}
catch (Exception)
{
// Fallback: use custom URL generation or store bookmark ID
}3. Workflow Not Resuming in Clustered Deployment
Symptom: Workflows don't resume in multi-instance deployments.
Possible Causes:
Distributed locking not configured
Database not shared between instances
Trigger indexing not synchronized
Solutions:
// Configure distributed locking
builder.Services.AddElsa(elsa =>
{
elsa.UseDistributedLocking(locking =>
{
// Use Redis or other distributed lock provider
locking.UseRedis("your-redis-connection-string");
});
});
// Ensure all instances use the same database
builder.Services.AddElsa(elsa =>
{
elsa.UseWorkflowManagement(management =>
{
management.UseEntityFrameworkCore(ef =>
{
ef.UseSqlServer("shared-connection-string");
});
});
});4. Trigger Not Starting Workflow
Symptom: Trigger activity registered but workflow doesn't start.
Possible Causes:
Trigger not properly indexed
Payload hash mismatch
Workflow not published
Solutions:
Ensure
GetTriggerPayloadsreturns consistent payload structuresVerify the workflow is published (not just saved as draft)
Check trigger indexing logs for errors
Rebuild the trigger index if necessary
5. Memory Leaks with Long-Running Workflows
Symptom: Memory usage grows over time with many suspended workflows.
Solutions:
Configure retention policies to clean up old workflows
Use external storage for large workflow data
Implement bookmark expiration logic:
var bookmark = context.CreateBookmark(new CreateBookmarkArgs
{
BookmarkName = "MyBookmark",
Callback = OnResumeAsync,
// Bookmark expires after 7 days
ScheduledAt = DateTime.UtcNow.AddDays(7),
AutoBurn = true
});Debugging Checklist
When troubleshooting blocking activities and triggers:
Diagnostic Queries
Useful SQL queries for troubleshooting (adjust table names for your database):
-- Find all bookmarks for a workflow instance
SELECT * FROM Bookmarks
WHERE WorkflowInstanceId = 'your-instance-id';
-- Find suspended workflows
SELECT * FROM WorkflowInstances
WHERE Status = 'Suspended';
-- Find triggers for a workflow definition
SELECT * FROM Triggers
WHERE WorkflowDefinitionId = 'your-definition-id';
-- Find bookmarks by name
SELECT * FROM Bookmarks
WHERE Name = 'WaitForApproval';Additional Resources
Example Files
WaitForApprovalActivity.cs - Complete blocking activity implementation
ApprovalController.cs - Controller showing resume patterns
SignalFanInTrigger.cs - Trigger example with fan-in pattern
workflow-wait-for-approval.json - Sample workflow JSON
This guide covers the core concepts and patterns for implementing blocking activities and triggers in Elsa v3. For more advanced scenarios, consult the Elsa source code at github.com/elsa-workflows/elsa-core.
Last updated