Skip to content

Broadcast channels

Broadcast channels, provided by the Microsoft.Orleans.BroadcastChannel package, provide nonpersistent, implicit fan-out to grains. They don’t use a queue, retain history, retry from a log, or maintain explicit subscriptions. Use them for best-effort notifications where loss is acceptable; use Orleans streams when events or subscriptions need durability or replay.

A broadcast writer is selected by a configured provider name and a ChannelId. Create creates an identifier whose namespace and key have two distinct routing roles:

  • The namespace is matched against ImplicitChannelSubscriptionAttribute declarations to select subscriber grain types.
  • The key maps to the primary key of one subscriber grain identity for each matching grain type.

Publishing doesn’t enumerate all current activations. It addresses the matching virtual grain identities, activating them when necessary. DefaultChannelIdMapper interprets the channel key for each matching grain type: it uses raw text for a string-keyed grain, parses GUID text for a GUID-keyed grain, and parses decimal text for an integer-keyed grain. The Create overload that accepts a Guid formats a GUID key; use a decimal string for an integer key. Custom IChannelIdMapper implementations can change that mapping.

The provider name and channel namespace are independent. They can use the same string by convention, but provider registration doesn’t make that string the channel namespace.

Register a named broadcast provider on every silo with AddBroadcastChannel:

using System.Text.Json;
using BroadcastChannel.GrainInterfaces;
using BroadcastChannel.Silo.Options;
using BroadcastChannel.Silo.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Orleans.Serialization;
await Host.CreateDefaultBuilder(args)
.UseOrleans((context, silo) =>
{
silo.Services.AddOptions<AlphaVantageOptions>()
.Bind(context.Configuration.GetSection(nameof(AlphaVantageOptions)));
silo.Services.AddTransient<StockClient>();
silo.Services.AddHttpClient<StockClient>(client =>
{
client.BaseAddress = new("https://www.alphavantage.co/");
});
silo.Services.AddSerializer(
serializer => serializer.AddJsonSerializer(
isSupported: type => type?.Namespace?.StartsWith(
nameof(BroadcastChannel)) ?? false,
jsonSerializerOptions: new(JsonSerializerDefaults.Web)));
silo.Services.AddHostedService<StockWorker>();
silo.UseLocalhostClustering();
silo.AddBroadcastChannel(
ChannelNames.LiveStockTicker,
builder => builder.Configure(
options => options.FireAndForgetDelivery = false));
})
.RunConsoleAsync();

An Orleans client that publishes must register the same provider name and compatible options:

using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
using BroadcastChannel.GrainInterfaces;
using IHost host = Host.CreateDefaultBuilder(args)
.UseOrleansClient(client =>
{
client.UseLocalhostClustering();
client.AddBroadcastChannel(
ChannelNames.LiveStockTicker,
builder => builder.Configure(
options => options.FireAndForgetDelivery = false));
})
.UseConsoleLifetime()
.Build();
await host.StartAsync();
await Task.Delay(3_000);
IGrainFactory factory = host.Services.GetRequiredService<IGrainFactory>();
ILiveStockGrain liveStocks = factory.GetGrain<ILiveStockGrain>(primaryKey: Guid.Empty);
foreach (StockSymbol symbol in Enum.GetValues<StockSymbol>())
{
Stock stock = await liveStocks.GetStock(symbol);
Console.WriteLine($"{symbol}: {stock.GlobalQuote.Price:C}");
}
await host.WaitForShutdownAsync();

FireAndForgetDelivery defaults to true. In that mode, Publish starts subscriber calls and returns without awaiting them; subscriber exceptions are logged and aren’t returned to the publisher. Setting it to false awaits all subscriber callbacks and propagates failures as an aggregate exception. Neither mode adds persistence, retry, replay, or exactly-once processing.

Mark the grain class with an implicit channel subscription and implement IOnBroadcastChannelSubscribed. Attach a callback when Orleans supplies the channel subscription:

using System.Collections.Concurrent;
using BroadcastChannel.GrainInterfaces;
using Orleans.BroadcastChannel;
namespace BroadcastChannel.Silo;
[ImplicitChannelSubscription]
public sealed class LiveStockGrain :
Grain,
ILiveStockGrain,
IOnBroadcastChannelSubscribed
{
private readonly IDictionary<StockSymbol, Stock> _stockCache =
new ConcurrentDictionary<StockSymbol, Stock>();
public ValueTask<Stock> GetStock(StockSymbol symbol) =>
_stockCache.TryGetValue(symbol, out Stock? stock) is false
? new ValueTask<Stock>(Task.FromException<Stock>(new KeyNotFoundException()))
: new ValueTask<Stock>(stock);
public Task OnSubscribed(IBroadcastChannelSubscription subscription) =>
subscription.Attach<Stock>(OnStockUpdated, OnError);
private Task OnStockUpdated(Stock stock)
{
if (stock is { GlobalQuote: { } })
{
_stockCache[stock.GlobalQuote.Symbol] = stock;
}
return Task.CompletedTask;
}
private static Task OnError(Exception ex)
{
Console.Error.WriteLine($"An error occurred: {ex}");
return Task.CompletedTask;
}
}

The parameterless attribute matches all nonempty channel namespaces. Pass a namespace to match exactly, use RegexImplicitChannelSubscriptionAttribute for a pattern, or provide a custom namespace predicate.

Attach selects the payload type and supplies item and error callbacks. Channel subscriptions are implicit metadata bindings, so there is no explicit subscribe or unsubscribe operation.

Resolve IBroadcastChannelProvider by provider name, construct a channel ID, get a typed writer, and publish:

using System.Diagnostics;
using BroadcastChannel.GrainInterfaces;
using Microsoft.Extensions.Hosting;
using Orleans.BroadcastChannel;
namespace BroadcastChannel.Silo.Services;
internal sealed class StockWorker : BackgroundService
{
private readonly StockClient _stockClient;
private readonly IBroadcastChannelProvider _provider;
private readonly List<StockSymbol> _symbols = Enum.GetValues<StockSymbol>().ToList();
public StockWorker(
StockClient stockClient, IClusterClient clusterClient) =>
(_stockClient, _provider) =
(stockClient, clusterClient.GetBroadcastChannelProvider(ChannelNames.LiveStockTicker));
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
// Capture the starting timestamp.
long startingTimestamp = Stopwatch.GetTimestamp();
// Get all updated stock values.
Stock[] stocks = await Task.WhenAll(
tasks: _symbols.Select(selector: _stockClient.GetStockAsync));
// Get the live stock ticker broadcast channel.
ChannelId channelId = ChannelId.Create(ChannelNames.LiveStockTicker, Guid.Empty);
IBroadcastChannelWriter<Stock> channelWriter = _provider.GetChannelWriter<Stock>(channelId);
// Broadcast all stock updates on this channel.
await Task.WhenAll(
stocks.Where(s => s is not null).Select(channelWriter.Publish));
// Use the elapsed time to calculate a 15 second delay.
int elapsed = Stopwatch.GetElapsedTime(startingTimestamp).Milliseconds;
int remaining = Math.Max(0, 15_000 - elapsed);
await Task.Delay(remaining, stoppingToken);
}
}
}

In this sample, the channel namespace is live-stock-ticker and the key is Empty, so each matching GUID-keyed subscriber grain type receives the message at that grain identity. Use a customer, tenant, device, or other domain key to target the corresponding identity instead.

CapabilityBroadcast channelOrleans stream
Fan-outOne grain identity per matching subscriber grain typeEvery explicit and implicit subscription
Subscription modelImplicit grain metadata onlyExplicit and implicit
Event persistenceNoneProvider-dependent
Subscription persistenceNone requiredExplicit records depend on PubSubStore
ReplayNoProvider-dependent
Publisher completionFire-and-forget by default; optionally awaits callbacksProvider acceptance, not general consumer completion
External broker integrationNoAvailable through stream providers

See Choose an Orleans messaging abstraction for selection guidance.