StateleSSE.AspNetCore 4.0.0-preview.1

This is a prerelease version of StateleSSE.AspNetCore.
dotnet add package StateleSSE.AspNetCore --version 4.0.0-preview.1
                    
NuGet\Install-Package StateleSSE.AspNetCore -Version 4.0.0-preview.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="StateleSSE.AspNetCore" Version="4.0.0-preview.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="StateleSSE.AspNetCore" Version="4.0.0-preview.1" />
                    
Directory.Packages.props
<PackageReference Include="StateleSSE.AspNetCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add StateleSSE.AspNetCore --version 4.0.0-preview.1
                    
#r "nuget: StateleSSE.AspNetCore, 4.0.0-preview.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package StateleSSE.AspNetCore@4.0.0-preview.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=StateleSSE.AspNetCore&version=4.0.0-preview.1&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=StateleSSE.AspNetCore&version=4.0.0-preview.1&prerelease
                    
Install as a Cake Tool

StateleSSE

Realtime SSE framework for ASP.NET Core with live queries. Pair with the statele-sse npm package for a type-safe client.

These docs are for the newest version (v4). For older version (before live queries with EF.Realtime), see branch "v3"

Dependencies

Required Notes
.NET 6.0+ Targets net6.0, net8.0, net9.0, net10.0
ASP.NET Core yes FrameworkReference — comes with the SDK
Entity Framework Core only for EfRealtime Bundled in the package but unused unless you call AddEfRealtime()
StackExchange.Redis only for Redis backplane Bundled in the package but unused unless you call AddRedisSseBackplane()

Minimal setup (in-memory backplane, no EfRealtime) requires no additional packages from the consumer — just ASP.NET Core.

Install

dotnet add package StateleSSE.AspNetCore --prerelease

Quick start

These snippets are from ExampleApp.Quickstart.

Server

// ExampleApp.Quickstart/Program.cs

using Microsoft.EntityFrameworkCore;
using StateleSSE.AspNetCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddInMemorySseBackplane();
builder.Services.AddEfRealtime();
builder.Services.AddDbContext<AppDb>((sp, opt) =>
{
    opt.UseInMemoryDatabase("quickstart");
    opt.AddEfRealtimeInterceptor(sp);
});
builder.Services.AddControllers();

var app = builder.Build();
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapControllers();
app.Run();

// ExampleApp.Quickstart/AppDb.cs

using Microsoft.EntityFrameworkCore;

public class AppDb(DbContextOptions<AppDb> options) : DbContext(options)
{
    public DbSet<Message> Messages => Set<Message>();
}

public class Message
{
    public int Id { get; set; }
    public string Content { get; set; } = "";
    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}

// ExampleApp.Quickstart/RealtimeController.cs

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using StateleSSE.AspNetCore;
using StateleSSE.AspNetCore.EfRealtime;

public class RealtimeController(ISseBackplane backplane, IRealtimeManager realtimeManager, AppDb db)
    : RealtimeControllerBase(backplane)
{
    /// <summary>
    ///Will produce the following in the browser's response tab:
    ///id: 2
    ///event: messages
    ///data: [{"id":1,"content":"hi","createdAt":"2026-02-09T10:34:37.1856196+00:00"},{"id":2,"content":"asd","createdAt":"2026-02-09T10:34:40.5670584+00:00"},{"id":3,"content":"a","createdAt":"2026-02-09T11:11:34.6666671+00:00"}]
    /// </summary>
    /// <param name="connectionId"></param>
    /// <returns></returns>
    [HttpGet("messages")]
    public async Task<RealtimeListenResponse<List<Message>>> GetMessages(string connectionId)
    {
        var group = "messages";
        await backplane.Groups.AddToGroupAsync(connectionId, group);

        realtimeManager.Subscribe<AppDb>(connectionId, group,
            criteria: changes => changes.HasChanges<Message>(),
            query: async ctx => await ctx.Messages.OrderBy(m => m.CreatedAt).ToListAsync());

        return new RealtimeListenResponse<List<Message>>(group,
            await db.Messages.OrderBy(m => m.CreatedAt).ToListAsync());
    }

    /// <summary>
    /// Since this calls .SaveChangesAsync() on dbcontext, it triggers the "Listener" to make a new query and broadcast to the group
    /// </summary>
    /// <param name="message"></param>
    [HttpPost("send")]
    public async Task Send(string message)
    {
        db.Messages.Add(new Message { Content = message });
        await db.SaveChangesAsync();
    }
}

Client



<!DOCTYPE html>
<html>
<body>
  <div id="messages"></div>
  <input id="msg" placeholder="Message" />
  <button onclick="send()">Send</button>

  <script type="module">
    import { StateleSSEClient } from 'https://cdn.jsdelivr.net/npm/statele-sse/dist/index.js'

    const sse = new StateleSSEClient("/sse");

    sse.listen(
        async (id) => {
          const res = await fetch(`/messages?connectionId=${id}`);
          return await res.json();
        },
        (data) => render(data)
    );

    function render(messages) {
      document.getElementById("messages").innerHTML =
        messages.map(m => `<p>${m.content}</p>`).join("");
    }

    window.send = () => {
      fetch(`/send?message=${document.getElementById("msg").value}`, { method: "POST" });
      document.getElementById("msg").value = "";
    };
  </script>
</body>
</html>

EF Core Realtime

Automatic broadcasts when SaveChanges modifies data. Add a criteria (what change triggers it) and a query (what data to send).

Setup for EF Core Realtime

builder.Services.AddInMemorySseBackplane();
builder.Services.AddEfRealtime();

builder.Services.AddDbContext<MyDbContext>((sp, options) => {
    options.UseNpgsql(connectionString);
    options.AddEfRealtimeInterceptor(sp);
});

Subscribe endpoint for EF Core Realtime

public class ChatController(ISseBackplane backplane, IRealtimeManager realtime, MyDbContext ctx)
    : RealtimeControllerBase(backplane)
{
    [HttpGet(nameof(GetMessages))]
    public async Task<RealtimeListenResponse<List<Message>>> GetMessages(string connectionId, string roomId)
    {
        var group = $"room-messages:{roomId}";
        await backplane.Groups.AddToGroupAsync(connectionId, group);

        realtime.Subscribe<MyDbContext>(connectionId, group,
            criteria: changes => changes.OfType<Message>().Any(e => e.Entity.RoomId == roomId),
            query: async c => await c.Messages.Where(m => m.RoomId == roomId).ToListAsync());

        return new RealtimeListenResponse<List<Message>>(group, ctx.Messages.Where(m => m.RoomId == roomId).ToList());
    }
}

That's it. Any SaveChanges touching a Message with that roomId re-executes the query and broadcasts to all listeners.

Group Realtime

Broadcasts driven by group membership changes (joins/leaves/disconnects) instead of DB changes.

Setup

builder.Services.AddInMemorySseBackplane();
builder.Services.AddGroupRealtime();

Example subscribe endpoint for getting all members in a group in realtime

[HttpGet(nameof(GetMembers))]
public async Task<RealtimeListenResponse<IReadOnlyList<string>>> GetMembers(string connectionId, string roomId)
{
    var listenGroup = $"room-members:{roomId}";
    var roomGroup = $"room-messages:{roomId}";
    await backplane.Groups.AddToGroupAsync(connectionId, listenGroup);

    groupRealtime.Subscribe(listenGroup,
        criteria: change => change.GroupName == roomGroup,
        query: async groups => await groups.GetMembersAsync(roomGroup));

    return new RealtimeListenResponse<IReadOnlyList<string>>(listenGroup,
        await backplane.Groups.GetMembersAsync(roomGroup));
}

Client library: statele-sse

A very small TS/JS client can be downloaded from npm with:

npm i statele-sse

listen handles the full lifecycle — calls the endpoint, delivers initial data, then listens for SSE updates:

const url = '/sse'
const sse = new StateleSSEClient(url)

const unsub = sse.listen<Message[]>(
    (id) => fetch(`/GetMessages?connectionId=${id}&roomId=abc`).then(r => r.json()),
    (messages) => console.log(messages)
)

For more docs on the statele-sse-client, please see https://www.npmjs.com/package/statele-sse

Public signatures & API reference

"Criteria" for triggering a query with EF realtime:

//When using the realtimeManager:
        realtime.Subscribe<MyDbContext>(connectionId, group,
            criteria: changes => changes.OfType<Message>().Any(e => e.Entity.RoomId == roomId),
            query: async c => await c.Messages.Where(m => m.RoomId == roomId).ToListAsync()); 

//The criteria API is as following:
changes.OfType<Message>()
changes.HasChanges<Message>()
changes.HasAdded<Message>()
changes.HasModified<Message>()
changes.HasDeleted<Message>()

"Criteria" for triggering a query with Group realtime manager:

tood

Backplane API

await backplane.Clients.SendToAllAsync(data);
await backplane.Clients.SendToGroupAsync("room-1", data);
await backplane.Clients.SendToGroupsAsync(["room-1", "room-2"], data);
await backplane.Clients.SendToClientAsync(connectionId, data);
await backplane.Clients.SendToClientsAsync([id1, id2], data);

await backplane.Groups.AddToGroupAsync(connectionId, "room-1");
await backplane.Groups.RemoveFromGroupAsync(connectionId, "room-1");
var members = await backplane.Groups.GetMembersAsync("room-1");
var count = await backplane.Groups.GetMemberCountAsync("room-1");
var groups = await backplane.Groups.GetClientGroupsAsync(connectionId);

The RealtimeListenResponse<T>

Since the initial HTTP response of a realtime query should return the "group" / event to listen for, the RealtimeListenResponse is used a long with optional initial data:

public record RealtimeListenResponse([Required][NotNull]string Group = null!);
public record RealtimeListenResponse<T>(
    [Required][NotNull] string Group = null!,
    T? Data = default
) : RealtimeListenResponse(Group);

The client library automatically is compliant with this "wrapper" object

sse.listen<Room[]>(
    async (id) => await chatClient.getRooms(id), //this method technically returns RealtimeListenerResponse<Room[]>
    data => setRooms(data)  //here it is unwrapped when the state it used - this line will fire every time the "rooms" state is changed
);

Live query system architecture visualization

 Browser                          ASP.NET Core Server
 ──────                          ──────────────────────────────────────────────────

  EventSource ───GET /sse──────► RealtimeControllerBase
       │                              │
       │◄── event: connected ─────────┘  (connectionId)
       │
       │                          Subscribe endpoint
       ├────GET /messages ──────► ┌──────────────────────────────────────────────┐
       │    ?connectionId=xxx     │ backplane.Groups.AddToGroup(connId, group)   │
       │                          │ realtimeManager.Subscribe(connId, group,     │
       │                          │     criteria: changes => ...,               │
       │                          │     query: async ctx => ...)                │
       │◄── { group, data } ──── │ return RealtimeListenResponse(group, data)   │
       │    (initial state)       └──────────────────────────────────────────────┘
       │
       │    sse.listen(onData)
       │    renders initial data
       │
       ·                          ┌──────────────────────────────────────────────┐
       ·                          │ Any mutation endpoint                        │
       ·    POST /send ─────────► │   db.Messages.Add(...)                      │
       ·                          │   db.SaveChangesAsync() ──┐                 │
       ·                          └───────────────────────────┼─────────────────┘
       ·                                                      ▼
       ·                          ┌──────────────────────────────────────────────┐
       ·                          │ SaveChangesInterceptor (automatic)           │
       ·                          │                                              │
       ·                          │  Before save:                                │
       ·                          │    snapshot = ChangeTracker entries           │
       ·                          │    matched = subscriptions.Where(criteria)   │
       ·                          │                                              │
       ·                          │  After save:                                 │
       ·                          │    for each matched subscription:            │
       ·                          │      result = await query(dbContext)          │
       ·                          │      backplane.SendToGroup(group, result) ───┼──┐
       ·                          └──────────────────────────────────────────────┘  │
       │                                                                            │
       │◄── event: messages ────────────── SSE push ◄──────────────────────────────┘
       │    data: [updated query result]
       │
       │    onData(newMessages)
       ▼    renders updated data

Using without Entity Framework (simple backplane for basic event driven design & no live queries)

You can use the framework without Entity Framework at all. Simply remove the EF-related DI stuff:

builder.Services.AddInMemorySseBackplane();
//builder.Services.AddEfRealtime(); //not required

/*builder.Services.AddDbContext<MyDbContext>((sp, options) => {
    options.UseNpgsql(connectionString);
    options.AddEfRealtimeInterceptor(sp);
}); not required either */

And then rely on the backplane for doing the client management / broadcasting:

public class ChatController(ISseBackplane backplane) : RealtimeControllerBase(backplane)
{
    public async Task AddToGroup(string connectionId)
    {
        await backplane.Groups.AddToGroupAsync(connectionId, "room1");
    }

    public async Task SendToGrpoup(string message)
    {
        await backplane.Clients.SendToGroupAsync("room1", message);
    }
}

Scaling with Redis

Swap AddInMemorySseBackplane() for Redis to scale across multiple server instances:

builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
    ConnectionMultiplexer.Connect("localhost:6379"));
builder.Services.AddRedisSseBackplane();

All backplane operations (send, groups, membership) work transparently across instances. The EF.Realtime + Group Changes (like waiting for Entity Framework DbContext.SaveChanges()) does not currently support horizontal scaling.

JsonSerializer settings

To change broadcast serialization behavior, simply change the DI for controller's native serializer:

//example with arbitrary JSON serializer settings
builder.Services.AddControllers()
    .AddJsonOptions(options =>
    {
        options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
        options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
        options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
    });

Examples

License

MIT

Product Compatible and additional computed target framework versions.
.NET net6.0 is compatible.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
4.0.0-preview.1 479 2/10/2026
3.1.0 304 1/28/2026
3.0.0 125 1/19/2026
2.3.1 122 1/16/2026
2.3.0 121 1/13/2026
2.2.0 126 1/12/2026
2.0.0 150 1/9/2026
1.0.0 130 1/9/2026