Stop Orchestrating Workflows in the Browser
Most teams get this wrong once: they try to orchestrate long-running workflows from the UI, chaining REST calls and painting progress bars in JavaScript. It works in demos, then breaks in production. Lets take the opposite path: backend-owned orchestration with simple .NET building blocks (Channel, BackgroundService) and SignalR for live progress.
Meridian is a learning platform built on ASP.NET Core. Enrollment is a key workflow that touches multiple systems and can take minutes to complete. Its a perfect candidate for a robust orchestration pattern. Lets implement a reliable enrollment orchestrator that keeps the UI responsive and provides authoritative progress updates by designing a reliable Enrollment Orchestrator in ASP.NET Core using Channels and SignalR
Problem Statement
Enrollment is a long-running workflow with side effects, not a single request:
- Parse course source.
- Upsert learner.
- Upsert course.
- Create Jira epic.
- Persist enrollment.
- Create stories and quiz links.
Product expectation is still simple: submit once, watch progress, get a deterministic outcome. A common early approach is browser orchestration: call A, then B, then C from jQuery/Angular/React and render progress in the UI. This fails operationally:
- Tab close/refresh/network loss breaks orchestration ownership.
- Workflow correctness depends on client lifecycle.
- Retries can duplicate external side effects (Jira in this case).
- Progress UI is often estimated, not authoritative.
- Auditability and incident debugging become weak.
Principle: UI should observe workflow state, not own workflow execution.
.NET’s “Multiple Personality Disorder” (In a Good Way)
In .NET, you can solve this with several personalities:
- Basic data structures:
Queue<T>,ConcurrentQueue<T>. - Concurrency primitive:
System.Threading.Channels. - Process host:
BackgroundService. - External messaging systems: Service Bus/RabbitMQ/Kafka.
- Real-time client transport: SignalR.
The confusion is not lack of tools; it is choosing the right depth for current scale. .NET Channels (from System.Threading.Channels) are an in-memory, asynchronous producer–consumer pipeline. Think of them as:
A high-performance, async-aware queue with built-in backpressure and coordination.
They are designed for:
async/awaitworkflows- High-throughput pipelines
- Fine-grained control over concurrency
Lets compare these options and see why Channels hit the sweet spot for our enrollment orchestrator.
🔁 Channels vs basic data structures
🧵 Queue<T>
- Not thread-safe
- No async support
- No blocking/waiting built in
👉 You’d need to manually add:
- Locks
- Signals (
Monitor,Semaphore, etc.)
Verdict: Good for simple, single-thread scenarios. Not suitable for modern concurrent pipelines.
⚙️ ConcurrentQueue<T>
- Thread-safe
- Lock-free in many cases
- Still not async-aware
Problems:
- No natural way to wait for data asynchronously
-
You end up doing:
while (!queue.TryDequeue(out var item)) await Task.Delay(10);❌ inefficient polling
👉 Or you combine it with:
SemaphoreSlim- Manual signaling
Verdict:
Better than Queue<T>, but still low-level and incomplete for async workflows.
🚀 .NET Channels
- Thread-safe ✅
- Async-first (
WriteAsync,ReadAsync) ✅ - Built-in waiting (no polling) ✅
- Supports bounded capacity (backpressure) ✅
- Supports completion & cancellation ✅
Example:
var channel = Channel.CreateBounded<int>(100);
await channel.Writer.WriteAsync(42);
// someone / somewhere else
var item = await channel.Reader.ReadAsync();
No polling, no locks, no hacks.
Verdict: 👉 Best choice for in-process async pipelines
📦 Channels vs messaging systems (RabbitMQ, etc.)
Now this is a different level of abstraction entirely. RabbitMQ (and similar systems) are:
Out-of-process, distributed messaging systems
They provide:
- Persistence (messages survive crashes)
- Delivery guarantees (at-least-once, etc.)
- Cross-service communication
- Scalability across machines
- Routing, topics, fanout, etc.
| Feature | .NET Channels | Messaging Systems |
|---|---|---|
| Scope | In-process | Distributed |
| Persistence | ❌ No | ✅ Yes |
| Performance | 🚀 Extremely fast | 🐢 Slower (network, disk) |
| Complexity | Low | High |
| Reliability | Process-bound | Durable |
| Use case | Pipelines, background tasks | Microservices, integration |
- Channels = “fast pipes inside your app”
- RabbitMQ = “postal service between apps”
✅ Use Channels when:
- You’re inside a single app/process
- You need high throughput
- You want clean async producer–consumer logic
- You need backpressure
👉 Example:
- Background job processing
- Streaming data pipelines
- Worker pools
✅ Use ConcurrentQueue when:
- You need something simple and fast
- You don’t care about async waiting
- You’re okay managing coordination manually
✅ Use RabbitMQ (or similar) when:
- You have multiple services
- You need durability
- You need retry and delivery guarantees
- You need decoupling across systems
👉 Example:
- Microservices communication
- Event-driven architectures
.NET Channels and Other Stacks
🟢 Go channels
Go’s channels are the closest conceptual match to .NET Channels.
“Communicate by passing messages, not sharing memory”
| Feature | .NET Channels | Go Channels |
|---|---|---|
| Producer–consumer | ✅ | ✅ |
| Blocking/async reads | ✅ (await) |
✅ (blocking) |
| Bounded/unbounded | ✅ | ✅ |
| Backpressure | ✅ | ✅ |
| Multiple producers/consumers | ✅ | ✅ |
-
Go channels are built into the language and .NET Channels are a library. Go code feels more natural and .NET code is more explicit.
ch <- 42 x := <-chawait channel.Writer.WriteAsync(42); var x = await channel.Reader.ReadAsync();
⚠️ Key Differences
- Go channels are idiomatic concurrency but .NET Channels are more of a specialized tool for certain scenarios. You can write concurrent code in .NET without Channels
- Concurrency model is different, Go is built around goroutines + channels and Channels are the central coordination primitive, while .NET is built around Tasks + async/await and Channels are one option
- Select/multiplexing is native in Go but not in .NET (you have to simulate it with
Task.WhenAnyor custom coordination)
select {
case x := <-ch1:
case ch2 <- y:
}
☕ Java
Java doesn’t have one equivalent—it has several layers.
BlockingQueue(classic Java) fromjava.util.concurrentthat offers thread-blocking model (and its not async-aware)Reactive Streams(modern Java) fromorg.reactivestreamsthat offers push-based, asynchronous model
Reactive Streams are used by frameworks like:
- Project Reactor
- RxJava
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(100);
queue.put(42);
int x = queue.take();
Flux.just(1,2,3)
.map(x -> x * 2)
.subscribe(System.out::println);
Comparison
| Feature | .NET Channels | BlockingQueue | Reactive Streams |
|---|---|---|---|
| Pull model | ✅ pull via await |
✅ manual take() |
❌ push-only |
| Async/await | ✅ native | ❌ blocking only | ⚠️ via adapters |
| Backpressure | ✅ bounded buffer | ✅ blocks when full | ✅ built-in |
| Completion model | ✅ Complete() |
❌ none | ✅ onComplete() |
| Non-blocking async | ✅ fully async | ❌ blocking | ✅ non-blocking |
| Simplicity | ✅ simple API | ✅ trivial | ❌ steeper learning curve |
| Pipelines | ⚠️ manual plumbing | ❌ none | ✅ built-in |
| Thread-safety | ✅ | ✅ | ✅ |
| Buffering | ✅ bounded/unbounded | ✅ bounded/unbounded | ✅ bounded/unbounded |
| Error handling | ⚠️ manual | ❌ | ✅ built-in |
| Composability | ⚠️ manual | ❌ | ✅ high |
Note: Channels are “manual plumbing with full control,” Reactive Streams are “dataflow pipelines with operators.”
🌿 Spring Ecosystem
Spring usually operates at a higher abstraction level. It provides:
- Message channels
- Routers
- Transformers
- Adapters (HTTP, Kafka, etc.)
👉 Conceptually similar naming, but:
| Feature | .NET Channels | Spring Integration |
|---|---|---|
| Scope | In-process | Often distributed |
| Abstraction | Low-level | High-level |
| Control | Manual | Declarative/config-driven |
Spring WebFlux is more comparable to Reactive Streams than to Channels, as it is designed for reactive, non-blocking web applications. Its built on top of Project Reactor and provides a push-based, asynchronous programming model.
| Feature | .NET Channels | Spring (WebFlux + Integration) |
|---|---|---|
| Model | ✅ Pull via await |
❌ Push / declarative (Flux / Integration flows) |
| Async/await | ✅ native | ⚠️ reactive operators (Mono/Flux) / adapters |
| Backpressure | ✅ bounded buffer | ✅ built-in (reactive) / broker-managed |
| Completion model | ✅ Complete() |
✅ onComplete() (WebFlux) / message end events (Integration) |
| Non-blocking async | ✅ fully async | ✅ non-blocking (WebFlux) |
| Simplicity | ✅ simple API | ❌ steeper learning curve / config-heavy |
| Pipelines | ⚠️ manual plumbing | ✅ built-in (operators + integration flows) |
| Thread-safety | ✅ | ✅ |
| Buffering | ✅ bounded/unbounded | ✅ bounded/unbounded (configurable) |
| Error handling | ⚠️ manual | ✅ built-in |
| Composability | ⚠️ manual | ✅ high (operators + integration patterns) |
| Scope | In-process only | Often distributed / multi-transport capable |
| Abstraction level | Low-level | High-level / declarative |
| Ecosystem | Minimal | 🌐 Large (WebFlux, Integration, Kafka, HTTP, DB connectors) |
- .NET Channels → Low-level, fully async, in-process pipelines. Full manual control, lightweight, simple API.
- Spring (WebFlux + Integration) → High-level, declarative, reactive, or distributed pipelines. Large ecosystem, built-in error handling, composable operators, multi-transport ready.
Big picture comparison
| Ecosystem | Equivalent | Level |
|---|---|---|
| .NET | Channels | Mid-level |
| Go | Channels | Language-level |
| Java | BlockingQueue | Low-level |
| Java | Reactor/RxJava | High-level |
| Spring | Integration/WebFlux | Very high-level |
- If you like Go-style concurrency, .NET Channels feel familiar—but slightly more verbose
- If you come from Java, Channels feel like a huge upgrade over
BlockingQueue - If you use Spring reactive, Channels will feel too manual
.NET Channels are one of the few abstractions that hit a sweet spot between control and ergonomics—which is why they’re so powerful but also somewhat underused.
Chosen Architecture
A more robust approach requires decoupling the workflow from the UI: an asynchronous HTTP server to receive requests, a suitable data structure to track workflow state, and a control flow mechanism that triggers processing when new messages arrive. This processing runs in the background service, separate from the request thread, while a push/poll system communicates status updates back to the client. In the .NET ecosystem, this pattern maps naturally to Channel<T> for safe in-memory queues, ASP.NET’s BackgroundService feature for long-running processing, and SignalR for real-time client notifications.
Our implementation intentionally picks a classic middle path:
- Multiple producers: HTTP requests enqueue operation IDs.
- Single consumer: one hosted background worker processes operations in sequence.
- In-memory transport: unbounded
Channel<Guid>withSingleReader=true,SingleWriter=false. - Persistent state model:
EnrollmentOperation+EnrollmentOperationEventin DB. - UI delivery: SignalR group push with
/enroll/status/{id}polling fallback.
This keeps infrastructure light while still preserving server-side source of truth.
Architecture Diagram
[Browser]
| POST /enroll
v
[EnrollmentController] -- create operation --> [EnrollmentOperations table]
| enqueue operationId
v
[Channel<Guid>] (multi-producer, single-consumer)
|
v
[EnrollmentProcessingService]
| run workflow via IEnrollmentService
| append status/events
v
[EnrollmentOperations + Events]
|
+--> [SignalR Hub Group: operationId] --> [Live UI updates]
|
+--> [GET /enroll/status/{id}] --> [Polling fallback]
Implementation Details (Hands-On)
1) Controller schedules work, does not execute it
[HttpPost("/enroll")]
public async Task<IActionResult> Index(EnrollmentViewModel model, CancellationToken cancellationToken)
{
if (!ModelState.IsValid)
return View(model);
var source = new CourseSourceLocator(model.SourceType, model.SourceUri, model.SubPath);
var operation = await enrollmentOperationService.CreateQueuedAsync(model.LearnerEmail, source);
await enrollmentQueue.EnqueueAsync(operation.Id, cancellationToken);
return RedirectToAction(nameof(Progress), new { operationId = operation.Id });
}
Impact: request latency remains short and predictable.
2) Queue implementation is tiny and explicit
public class EnrollmentQueue : IEnrollmentQueue
{
readonly Channel<Guid> channel = Channel.CreateUnbounded<Guid>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false
});
public ValueTask EnqueueAsync(Guid operationId, CancellationToken cancellationToken = default) =>
channel.Writer.WriteAsync(operationId, cancellationToken);
public IAsyncEnumerable<Guid> DequeueAllAsync(CancellationToken cancellationToken) =>
channel.Reader.ReadAllAsync(cancellationToken);
}
3) Background worker owns orchestration lifecycle
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var operationId in queue.DequeueAllAsync(stoppingToken))
{
try
{
var operation = await getOperationSnapshotAsync(operationId);
var source = new CourseSourceLocator(
Enum.Parse<CourseSourceType>(operation.SourceType, ignoreCase: true),
operation.SourceUri,
operation.SubPath);
await updateAndPublishAsync(operationId, s =>
s.MarkInProgressAsync(operationId, new EnrollmentProgressUpdate("in_progress", "Started", 6)), stoppingToken);
var enrollment = await enrollmentService.EnrollAsync(operation.LearnerEmail, source, async progress =>
{
await updateAndPublishAsync(operationId, s =>
s.AppendProgressAsync(operationId, progress), stoppingToken);
});
await updateAndPublishAsync(operationId, s =>
s.MarkCompletedAsync(operationId, enrollment.Id, enrollment.JiraEpicKey), stoppingToken);
}
catch (Exception ex)
{
await updateAndPublishAsync(operationId, s =>
s.MarkFailedAsync(operationId, ex.Message), stoppingToken);
}
}
}
4) SignalR push plus polling fallback on the client
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/enrollment")
.withAutomaticReconnect()
.build();
connection.on("EnrollmentProgress", (snapshot) => updateProgress(snapshot));
connection.onreconnecting(() => startPolling());
connection.onreconnected(() => {
connection.invoke("Subscribe", operationId);
stopPolling();
});
The key detail is fallback: if realtime fails, status polling keeps the UX correct.
- GitHub Issue #29 Asynchronous Enrollment Progress UX with SignalR
- Commit reference:
ec03af1cb84f0fba6537035f96fabaa95a695b8b
Engineering Decisions: Strengths and Tradeoffs
Strengths
- Clear separation of concerns: HTTP intake vs workflow execution vs UI notification.
- Better reliability semantics than client-driven orchestration.
- Strong observability path via persisted state/events.
- Incremental architecture: good fit for current scale without premature broker complexity.
- Easier incident handling: operation IDs provide a stable trace handle.
Tradeoffs
- In-memory channel is not durable through process crash/restart.
- Recovery policy for stale queued/in-progress operations is not fully implemented yet.
- Single consumer limits throughput by design.
- SignalR group subscription model should be hardened with stronger auth checks for multi-tenant scenarios.
These are acceptable if team explicitly acknowledges current reliability envelope.
When Not to Use This Pattern
Do not use this exact setup if:
- You need durable queue semantics across process/node failure today.
- You need high parallel throughput across many workers now.
- You need cross-service fan-out with independent consumers.
- You require strict delivery guarantees beyond what in-memory channels provide.
In those cases, keep the orchestration model but replace in-memory transport with a proper broker.
Bottom Line
For Issue #29 we prioritized pragmatic engineering:
- Backend owns orchestration.
- UI consumes authoritative progress.
- Modern .NET primitives provide clean implementation with low operational drag.
- Architecture leaves room to evolve without rewriting the workflow model.
If this workflow becomes business-critical, we will need to prioritize:
- Startup recovery job to re-enqueue stale operations.
- Idempotency strategy for Jira side effects.
- Bounded channel + admission/backpressure policy.
- Metrics and SLOs (queue depth, processing latency, failure rates).
- Migration seam to external broker when durability/scale requires it.