ASP.NET Core 6+ 中最简单返回 SSE 流的方式是手动写入 HttpResponse.Body 并设置响应头:StatusCode=200、ContentType="text/event-stream"、Cache-Control="no-cache"、Connection="keep-alive",每次写入 data: 消息后调用 FlushAsync,且所有 await 必须传入 HttpContext.RequestAborted 以防止资源泄漏。

ASP.NET Core 6+ 中用 IActionResult 返回 SSE 流最简单
不需要引入第三方库,.NET 6 起内置了对 Server-Sent Events 的基础支持。核心是返回一个持续写入的 FileStreamResult 或更推荐的 StreamingFileResult 变体——但实际最稳妥的是直接用 HttpResponse 写入原始流,并手动设置响应头。
关键点:必须禁用响应缓冲、设置正确的 MIME 类型和缓存策略,否则浏览器收不到实时事件。
Response.StatusCode = 200Response.ContentType = "text/event-stream"Response.Headers.Add("Cache-Control", "no-cache")Response.Headers.Add("Connection", "keep-alive")- 调用
Response.Body.FlushAsync()每次写完一行后(尤其在开发环境 IIS Express 下容易卡住)
用 HttpResponse 手动写入 SSE 格式数据
SSE 协议本身极轻量:每条消息由若干字段行(data:、id:、event:、retry:)组成,空行分隔。浏览器只认 data: 开头的行,且会自动拼接多行 data: 成一个完整字符串。
示例片段(在 Controller Action 中):
Response.StatusCode = 200;
Response.ContentType = "text/event-stream";
Response.Headers.Add("Cache-Control", "no-cache");
Response.Headers.Add("Connection", "keep-alive");
var writer = new StreamWriter(Response.Body, Encoding.UTF8) { AutoFlush = true };
while (!HttpContext.RequestAborted.IsCancellationRequested)
{
await writer.WriteLineAsync($"data: {{\"time\":\"{DateTime.Now:O}\"}}");
await writer.WriteLineAsync("");
await Task.Delay(1000, HttpContext.RequestAborted);
}
注意:AutoFlush = true 很重要;若不用 StreamWriter,直接用 Response.Body.WriteAsync,记得每次写完调 FlushAsync。
避免 JsonSerializer 或 System.Text.Json 自动换行导致格式错误
如果用 JsonSerializer.Serialize 输出对象再写入流,它默认不换行,但你仍需手动加 data: 前缀和末尾空行。更麻烦的是,若 JSON 含换行符(如字符串里有 \n),SSE 会把它当成消息分隔,导致解析失败。
- 不要直接
WriteAsync(JsonSerializer.Serialize(obj)) - 应先序列化为单行 JSON:
JsonSerializerOptions options = new() { WriteIndented = false }; - 然后拼接:
await writer.WriteLineAsync($"data: {json}"); - 严格确保每条消息以
data:开始、以空行结束
客户端断连时如何安全清理后台任务
ASP.NET Core 不会自动取消已启动的异步循环。若用户关闭页面或网络中断,HttpContext.RequestAborted 是唯一可靠信号,但必须在所有 await 点都传入它。
常见陷阱:
- 忘记在
Task.Delay(1000)里传HttpContext.RequestAborted→ 任务继续跑,资源泄漏 - 用
while (true)但没检查IsCancellationRequested→ 无法退出 - 在循环中启动新
Task.Run且未绑定CancellationToken→ 彻底失控
真正可靠的模式是:整个循环逻辑在一个 async 方法里,每个 await 都带 token,外层用 try/finally 或 using 清理资源(比如取消 Timer、释放数据库连接等)。
真实项目里,SSE 很少裸写循环;多数会结合 IAsyncEnumerable + ChannelReader 或 IObservable 做解耦,但底层响应流的写法和头设置逻辑完全一致。最容易被忽略的,其实是 FlushAsync 的调用时机和 RequestAborted 的全程穿透 —— 这两点一漏,就变成“看似能发,实则收不到”或者“服务端悄悄堆积数百个僵尸任务”。










