Skip to documentation
Heimdall Docs
Heimdall Docs

SSE / Bifrost

Bifrost is Heimdall’s server-sent events layer. It lets the server push HTML updates to subscribed browser clients outside the normal request-response cycle.

If out-of-band updates are side effects inside a response, SSE is how Heimdall delivers HTML when there is no active response at all.

1. What SSE Means in Heimdall

With Bifrost, the browser subscribes to a topic and the server can later publish HTML to that topic. The client runtime receives that HTML and applies it using the configured target and swap behavior.

Text
Browser subscribes to a topic
-> server publishes HTML later
-> Heimdall applies that HTML to the DOM

2. Why Bifrost Exists

Some UI updates are not the immediate result of the current click or submit. They may come from background work, another action, another user, or some other server event. That is where SSE fits.

Text
Good fits for SSE:
- toast notifications
- status changes
- background job progress
- live dashboards
- shared activity streams
- async completion messages

3. The Basic Model

Bifrost uses topic-based subscriptions. A page or layout declares interest in a topic, and the server publishes HTML messages to that topic.

Text
Subscriber -> topic: "toasts"
Publisher  -> topic: "toasts"
Result     -> subscribed clients receive HTML updates

4. Important: Topics Are App-Defined

Topic names are chosen by the application. Heimdall does not automatically scope them per request, per user, or per page. Topics should be stable app-level identities, not transient connection identifiers like HttpContext.Connection.Id. If multiple clients subscribe to the same topic, they can all receive the same published HTML.

Text
Good topics:
"chat:{chatId}"
"user:{userId}:notifications"
"tab:{pageInstanceId}:chat"

Avoid:
HttpContext.Connection.Id
request-specific IDs
values that change on reconnect

5. Layout-Level Subscription

A common pattern is to mount the SSE subscription in the layout so every page can receive updates for a shared UI region like a toast manager.

Strongly Typed Markup

C#
return FluentHtml.Div(root => root
    .Id("toast-manager")
    .Heimdall(heimdall => heimdall
        .SseTopic("toasts")
        .SseTarget("#toast-manager")
        .SseSwap(Swap.BeforeEnd)));

That example is intentionally simple, but it is also important to understand what it means: a shared topic like "toasts" behaves like a shared channel. That is fine for demos, shared dashboards, or broadcast-style notifications, but it is not the right default for per-user UI feedback.

Rendered HTML Mental Model

HTML
<div
  id="toast-manager"
  heimdall-sse="toasts"
  heimdall-sse-target="#toast-manager"
  heimdall-sse-swap="beforeend">
</div>

6. Publishing to a Topic

On the server, publishing is straightforward: render HTML and publish it to the topic that clients are subscribed to. The default SSE event name is heimdall. Use the overload with an event name when one topic carries several message types.

Text
await bifrost.PublishAsync(
    topic: "toasts",
    content: ToastManager.Create(toast),
    ttl: TimeSpan.FromSeconds(5),
    ct: ct
);

await bifrost.PublishAsync(
    "orders",
    "order.updated",
    Html.Span("Updated"),
    TimeSpan.FromSeconds(5),
    ct
);

7. Topic, Event, Target, and Swap

The SSE mental model has four separate pieces. The topic is the subscription, authorization, and routing boundary. The event is the message type inside that stream. Target and swap decide where and how the HTML is applied.

Text
topic = subscription / auth / routing boundary
event = message type inside that stream
target = where the payload updates the DOM
swap = how the payload updates the DOM

<div
  heimdall-sse="orders"
  heimdall-sse-event="order.updated"
  heimdall-sse-target="#orders"
  heimdall-sse-swap="beforeend">
</div>

8. SSE Helper Attributes

The static and fluent Heimdall helpers can emit the SSE attributes directly. SseTopic and SseTopicAlias both choose the subscribed topic. SseEvent chooses the client-side EventSource event name handled inside that topic.

C#
// Static helpers
HeimdallHtml.SseTopic("orders")
HeimdallHtml.SseTopicAlias("orders")
HeimdallHtml.SseTarget("#orders")
HeimdallHtml.SseSwapMode(HeimdallHtml.Swap.BeforeEnd)
HeimdallHtml.SseEvent("order.updated")
HeimdallHtml.SseDisable()

// Fluent helpers
root.Heimdall()
    .SseTopic("orders")
    .SseEvent("order.updated")
    .SseTarget("#orders")
    .SseSwap(Swap.BeforeEnd);

9. Safe Defaults

For most immediate user interactions, out-of-band updates are the safer default. OOB updates are tied to the current response, so they naturally affect only the client that made the request.

Text
Prefer OOB for:
- form submissions
- button clicks
- immediate success/error feedback
- request-local side effects

Prefer SSE for:
- async or delayed updates
- background work completion
- live status streams
- intentionally shared notifications

10. Topic Scoping Strategies

When using SSE, choose a topic strategy that matches the behavior you want. The best choice depends on whether the update should be visible to one page, one browser session, one user, or many clients.

Text
Common strategies:

Broadcast:
"toasts"

Per-user:
"toasts:user:{userId}"

Per-session:
"toasts:session:{sessionId}"

Per-page:
"toasts:page:{pageId}"

11. Full Example

This is the classic Heimdall SSE example: a button triggers an action, the action publishes toast HTML to the toasts topic, and the layout-level toast manager receives it.

That makes the mechanics easy to understand, but remember that a shared topic like toasts should be treated as a shared channel. In a real app, immediate button-click toasts are usually better handled with OOB, or the SSE topic should be scoped intentionally.

Publisher Action

JSON
[ContentInvocation]
public static async Task<IHtmlContent> ToastViaSse(
    Bifrost bifrost,
    CancellationToken ct)
{
    var toast = new ToastItem
    {
        Header = "Hello from SSE!",
        Content = "This toast was published to subscribed clients.",
        Type = ToastType.Success,
        DurationMs = 1800
    };

    await bifrost.PublishAsync(
        topic: "toasts",
        content: ToastManager.Create(toast),
        ttl: TimeSpan.FromSeconds(5),
        ct: ct
    );

    return HtmlString.Empty;
}

Trigger

Text
button.Heimdall()
    .Click("OobPage.ToastViaSse")
    .SwapNone();

What Happens

1. The page or layout subscribes to the topic.
2. The action publishes HTML to that topic.
3. The Heimdall runtime receives the SSE message.
4. The payload is applied to the configured target and swap mode.

12. SSE Uses HTML Too

Bifrost does not switch Heimdall into a JSON event protocol. The payload is still HTML, which means the same rendering model works for pages, actions, OOB, and streaming.

Text
await bifrost.PublishAsync(
    topic: "orders",
    content: RenderOrderRow(order),
    ttl: TimeSpan.FromSeconds(5),
    ct: ct
);

13. SSE Can Also Carry OOB Instructions

Because the payload is HTML, SSE messages can also include invocation elements. This means a streamed message can update its main target and also perform out-of-band work.

C#
await bifrost.PublishAsync(
    topic: "toasts",
    content: HeimdallHtml.Invocation(
        targetSelector: "#toast-manager",
        swap: HeimdallHtml.Swap.AfterBegin,
        payload: ToastManager.Create(toast)
    ),
    ttl: TimeSpan.FromSeconds(5),
    ct: ct
);

14. Topic Security

Bifrost subscriptions are not anonymous free-for-alls. The client obtains a short-lived, topic-bound subscribe token before connecting. By default, the subscribe-token endpoint validates antiforgery and can run topic authorization before EventSource opens. A global EnableAntiforgery = false skips only the CSRF check; signed subscribe tokens, authentication, and topic authorization remain in force.

Text
Client flow:
1. fetch CSRF token when antiforgery is enabled
2. request subscribe token for the topic
3. validate antiforgery when enabled
4. authorize topic
5. connect to Bifrost with topic + token
6. server validates the token
7. subscription begins

15. Why This Matters

This keeps topic subscription explicit and server-controlled. The browser does not just open an unrestricted EventSource to arbitrary topics.

Text
Bifrost subscribe tokens are:
- short-lived
- topic-bound
- obtained through Heimdall’s server flow

With antiforgery enabled:
- invalid antiforgery returns 400
- the runtime retries once with a fresh CSRF token

With global antiforgery disabled:
- no CSRF token is requested or validated
- signed subscribe-token validation still applies

16. Authorizing Topics

Bifrost can authorize topic subscriptions before a subscribe token is minted. Use a policy when the decision belongs in ASP.NET Core authorization, use AuthorizeBifrostTopic when the topic string itself needs app-specific checks, or use both when both gates should pass. Authorization policy handlers receive a BifrostTopicResource containing the topic and HttpContext. If cookie authentication challenges the token request, the runtime navigates to sign-in and rewrites the return URL back to the current page.

C#
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("BifrostTopic", policy =>
        policy.RequireAuthenticatedUser());
});

public sealed class TopicRequirement
    : IAuthorizationRequirement
{
}

public sealed class TopicHandler
    : AuthorizationHandler<TopicRequirement, BifrostTopicResource>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        TopicRequirement requirement,
        BifrostTopicResource resource)
    {
        if (resource.Topic.StartsWith("user:"))
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

builder.Services.AddHeimdall(options =>
{
    options.BifrostTopicPolicy = "BifrostTopic";
    options.AuthorizeBifrostTopic = (ctx, topic) =>
        ValueTask.FromResult(
            topic.StartsWith($"user:{ctx.User.Identity?.Name}:"));
});

Redirect handling:
GET /__heimdall/v1/bifrost/token?topic=secure
-> 401 / 403 with Location, or followed sign-in redirect
-> browser navigates to sign-in
-> ReturnUrl / returnUrl points to the current page

17. SSE vs Out-of-Band

These patterns are related, but they solve different timing problems.

Out-of-Band

  • Comes back inside the current action response
  • Immediate result of a user interaction
  • Great for local side effects
  • No open stream required
  • Natural default for request-local toasts

SSE / Bifrost

  • Arrives outside the current response
  • Can be triggered by later server events
  • Great for live and async updates
  • Requires an active subscription
  • Topic scope should be chosen intentionally

18. Runtime Behavior

The client runtime manages EventSource connection sharing, reconnect behavior, topic token retrieval, named events, attribute changes, and DOM application of streamed HTML. The server also emits idle heartbeat comments by default every 15 seconds so quiet streams are less likely to be closed by intermediaries.

Text
Heimdall runtime handles:
- subscribe token retrieval
- shared EventSource connection per topic
- named event dispatch
- reconnect with backoff
- fresh token on reconnect
- target + swap application
- invocation processing inside SSE payloads
- heimdall-sse-* attribute mutations
- cleanup when subscription elements disappear
- BifrostHeartbeatInterval server setting

19. Shared Connections and Recovery

Multiple subscribers on the same topic share one underlying EventSource. The topic chooses the stream; the event name chooses which subscribers handle each message. Transient token or connect failures retry with backoff, while permanent token responses (400, 401, 403, and 404) close the binding. A 401 is treated as an authentication termination and does not trigger another token request. EventSource errors close the old stream and reconnect with a fresh token, and reconnects pause while the browser is offline.

Text
one stream:
topic "orders"

multiple handlers:
event "order.updated" -> #orders
event "order.deleted" -> #orders
event "toast"         -> #toast-manager

recovery behavior:
transient token/connect failure -> backoff retry
permanent token response    -> close binding
token 401                   -> heimdall:unauthorized
token 401                   -> no auth retry
EventSource error     -> clear token + reconnect
server disconnect      -> close without reconnect
offline               -> pause reconnect
online                -> resume reconnect
ssePauseWhenHidden    -> optional hidden-tab pause

20. When to Reach for SSE

Use Bifrost when the update should arrive later, asynchronously, or for more than one subscribed client.

Text
Reach for SSE when:
- a background job completes later
- progress or status should stream
- multiple clients should see the same event
- the update should not wait for another click

21. Common Pattern: Toasts

Toasts are one of the clearest examples because they can be delivered either by OOB or by SSE. OOB is great for immediate interaction side effects. SSE is great for async or intentionally shared notifications.

Text
OOB toast:
- user clicks save
- response includes invocation for #toast-manager

SSE toast:
- server publishes to topic "toasts"
- all subscribers to that topic receive it

22. Local Subscriber Hint

HasSubscribers reports whether this Bifrost instance has an active subscriber for a topic at that instant. SubscribedTopics returns a read-only snapshot of topics with active local subscribers. Use both only for diagnostics or to skip optional expensive rendering or lookup work before publishing.

Text
if (bifrost.HasSubscribers("orders"))
{
    var html = await RenderExpensiveOrderUpdate(ct);
    await bifrost.PublishAsync(
        "orders",
        html,
        TimeSpan.FromSeconds(5),
        ct);
}

var activeTopics = bifrost.SubscribedTopics;

23. What HasSubscribers Does Not Promise

The result is local, instantaneous, and advisory. A subscriber may disconnect immediately after true; another server instance may have subscribers when this one returns false. It does not identify users, enumerate topics, coordinate a backplane, or guarantee delivery.

Text
Good use:
- avoid optional expensive render when nobody local is listening

Do not use for:
- authorization or business correctness
- distributed presence
- deciding whether durable work must happen
- delivery guarantees

24. Many Topic Families: DI Authorization Handlers

When an application has many topic families with different rules, register one scoped authorization handler per family instead of growing one large callback. Each topic must match exactly one registered handler; unknown or ambiguous topics are denied. A policy and the legacy AuthorizeBifrostTopic callback remain additional gates when configured.

C#
builder.Services
    .AddHeimdall()
    .AddBifrostTopicAuthHandler<UserNotificationsTopicAuthHandler>()
    .AddBifrostTopicAuthHandler<TenantOrdersTopicAuthHandler>();

public sealed class TenantOrdersTopicAuthHandler(
    ITenantAccessService tenantAccess)
    : IBifrostTopicAuthHandler
{
    public bool CanHandle(string topic)
        => topic.StartsWith("tenant:", StringComparison.Ordinal) &&
           topic.EndsWith(":orders", StringComparison.Ordinal);

    public ValueTask<bool> AuthorizeAsync(
        BifrostTopicAuthorizationContext context)
    {
        var tenantId = context.Topic.Split(':')[1];
        return tenantAccess.CanReadOrdersAsync(
            tenantId,
            context.User);
    }
}

25. Inspecting Active Connections

The server keeps a thread-safe, read-only snapshot of active Bifrost connections in the singleton IBifrostConnectionStore. The same store is available from dependency injection or through Bifrost.Connections. Each accepted SSE stream has a server-generated ConnectionId, one topic, an authenticated subject from the NameIdentifier or sub claim when available, ConnectedAt, and application-owned metadata. The registry is local to the current process, so it is useful for presence, diagnostics, and operational control—not distributed presence or authorization.

Text
// Inject IBifrostConnectionStore, or use bifrost.Connections.
var active = connections.Connections;

var aliceConnections = connections.GetConnections(
    BifrostConnectionSelector.ForSubject("alice"));

var tenantOrders = connections.GetConnections(
    new BifrostConnectionSelector
    {
        Topic = "orders",
        Metadata = new Dictionary<string, string>
        {
            ["tenant"] = "acme"
        }
    });

foreach (var connection in active)
{
    logger.LogInformation(
        "Bifrost {ConnectionId} is on {Topic}",
        connection.ConnectionId,
        connection.Topic);
}

26. Connection Lifecycle Handlers

Use AddBifrostConnectionHandler<THandler>() for reusable lifecycle behavior that needs dependency injection. Handlers run in short-lived callback scopes, so they can use a scoped database context or presence service without keeping it alive for the duration of the SSE stream. The authenticated callback runs after token validation and registration but before heimdall:connected; the disconnected callback runs after removal and receives the final metadata snapshot.

C#
builder.Services
    .AddHeimdall()
    .AddBifrostConnectionHandler<BifrostPresenceHandler>();

public sealed class BifrostPresenceHandler(
    ITenantResolver tenantResolver,
    IConnectionPresenceStore presence)
    : IBifrostConnectionHandler
{
    public async ValueTask OnBifrostAuthenticatedAsync(
        BifrostAuthenticatedContext context)
    {
        var tenantId = await tenantResolver.ResolveAsync(
            context.User);
        context.TrySetMetadata("tenant", tenantId);
        await presence.ConnectedAsync(
            context.Connection.ConnectionId,
            tenantId,
            context.Connection.Topic);
    }

    public ValueTask OnBifrostDisconnectedAsync(
        BifrostDisconnectedContext context)
    {
        presence.Disconnected(
            context.Connection.ConnectionId,
            context.Reason);
        return ValueTask.CompletedTask;
    }
}

27. Inline Lifecycle Escape Hatch

For a one-off integration, configure the lifecycle callbacks directly on HeimdallServiceSettings. The context.Services property exposes the same short-lived callback scope, so inline code can resolve application services without requiring a handler class. Lifecycle hooks observe and enrich connections; topic authorization still belongs in policy, AuthorizeBifrostTopic, or IBifrostTopicAuthHandler.

C#
builder.Services.AddHeimdall(options =>
{
    options.OnBifrostAuthenticated = context =>
    {
        context.TrySetMetadata("source", "dashboard");
        return context.Services
            .GetRequiredService<IAuditWriter>()
            .ConnectionOpenedAsync(context.Connection);
    };

    options.OnBifrostDisconnected = context =>
        context.Services
            .GetRequiredService<IAuditWriter>()
            .ConnectionClosedAsync(
                context.Connection,
                context.Reason);
});

28. Revoking a Connection

When authorization is revoked, select the affected connections from the local registry and request a terminal disconnect. Selectors are conjunctive: every populated framework field and every metadata entry must match. An empty selector can inspect all connections but is rejected for disconnect operations.

Text
var selector = new BifrostConnectionSelector
{
    Topic = "orders",
    Subject = userId,
    Metadata = new Dictionary<string, string>
    {
        ["tenant"] = tenantId
    }
};

var storeCount = connections.Disconnect(
    selector,
    "authorization-revoked");

// Equivalent convenience API:
var bifrostCount = bifrost.DisconnectSubscribers(
    selector,
    "authorization-revoked");

29. Controlled Disconnects Do Not Reconnect

A store or Bifrost selector disconnect sends the reserved heimdall:disconnect event and closes the browser connection intentionally. The Heimdall runtime does not request another token or reconnect that subscription. A later DOM or programmatic subscription can create a new connection. Because the registry and disconnect operation are local to one server process, multi-instance applications should coordinate revocation across their instances or use a shared control channel.

Text
connections.Disconnect(
    BifrostConnectionSelector.ForSubject(userId),
    "authorization-revoked");

browser receives:
event: heimdall:disconnect
data: authorization-revoked
-> connection closes
-> no automatic token/auth retry