SSE Connections and Session Affinity
Server-Sent Events (SSE) is a technology that enables one-way real-time communication from server to client. When operating SSE in a multi-server environment, understanding Session Affinity and Sticky Sessions is essential.
What is SSE (Server-Sent Events)?
SSE is a standardized technology that pushes events from the server to the client while maintaining an HTTP connection.
Features
- One-way communication: Server → Client
- HTTP/1.1 based: Leverages existing HTTP infrastructure
- Automatic reconnection: Client-side automatic reconnection
- Simple: Easier to implement than WebSocket
- Text-based: Uses
text/event-streamContent-Type
Use Cases
- Real-time notifications
- Dashboard updates
- Log streaming
- Stock and currency rate updates
- Progress display
Why Session Affinity is Needed
Problem: Long-lived Connections and Load Balancers
SSE connections have the following characteristics:
- Long-lived connections: Maintains connections for minutes to hours
- Stateful: Server manages per-client connections
- Server instance dependent: Events can only be sent from the server where the connection was established
Without Session Affinity, the following issues occur:
- Reconnections are routed to different servers
- Connection information remaining on the original server cannot be used
- Clients cannot receive events
How Session Affinity (Sticky Session) Works
Session Affinity is a technology that routes requests from the same client to the same server instance consistently.
Implementation Methods
1. Cookie-Based
The load balancer sets a cookie and uses it to select the same server for subsequent requests.
2. IP Address-Based
Hashes the client's IP address to determine the server.
Advantages:
- No cookies required
- Transparent
Disadvantages:
- Multiple clients may appear as the same IP in NAT environments
- Load distribution may be uneven
SSE Implementation in ASP.NET Core
Basic Implementation
[ApiController]
[Route("api/[controller]")]
public class EventsController : ControllerBase
{
private readonly ILogger<EventsController> _logger;
public EventsController(ILogger<EventsController> logger)
{
_logger = logger;
}
[HttpGet("stream")]
public async Task StreamEvents(CancellationToken cancellationToken)
{
// Set Content-Type for SSE
Response.Headers.Append("Content-Type", "text/event-stream");
Response.Headers.Append("Cache-Control", "no-cache");
Response.Headers.Append("Connection", "keep-alive");
// Disable response buffering
Response.Headers.Append("X-Accel-Buffering", "no");
try
{
// Event sending loop
while (!cancellationToken.IsCancellationRequested)
{
var eventData = $"data: {DateTime.UtcNow:O}\n\n";
await Response.WriteAsync(eventData, cancellationToken);
await Response.Body.FlushAsync(cancellationToken);
_logger.LogInformation("Event sent to client");
await Task.Delay(1000, cancellationToken); // Every 1 second
}
}
catch (OperationCanceledException)
{
_logger.LogInformation("Client disconnected");
}
}
}
Event IDs and Reconnection
[HttpGet("stream")]
public async Task StreamEventsWithId(
[FromHeader(Name = "Last-Event-ID")] string? lastEventId,
CancellationToken cancellationToken)
{
Response.Headers.Append("Content-Type", "text/event-stream");
Response.Headers.Append("Cache-Control", "no-cache");
Response.Headers.Append("Connection", "keep-alive");
var eventId = long.TryParse(lastEventId, out var id) ? id : 0;
try
{
while (!cancellationToken.IsCancellationRequested)
{
eventId++;
// Include event ID
var eventData = $"id: {eventId}\n" +
$"data: Event at {DateTime.UtcNow:O}\n\n";
await Response.WriteAsync(eventData, cancellationToken);
await Response.Body.FlushAsync(cancellationToken);
await Task.Delay(1000, cancellationToken);
}
}
catch (OperationCanceledException)
{
_logger.LogInformation($"Client disconnected at event ID: {eventId}");
}
}
Client-Side (JavaScript)
const eventSource = new EventSource('/api/events/stream');
eventSource.onmessage = (event) => {
console.log('Received:', event.data);
// UI update logic
};
eventSource.onerror = (error) => {
console.error('SSE Error:', error);
// Error handling
};
// Explicitly close the connection
// eventSource.close();
Session Affinity Configuration in Azure
Azure Application Gateway
Application Gateway supports Cookie-Based Affinity.
Configuration in Azure Portal
- Application Gateway → Backend settings
- Set Cookie-based affinity to "Enabled"
- Set Affinity cookie name (optional)
ARM Template
{
"type": "Microsoft.Network/applicationGateways",
"properties": {
"backendHttpSettingsCollection": [
{
"properties": {
"port": 80,
"protocol": "Http",
"cookieBasedAffinity": "Enabled",
"affinityCookieName": "ApplicationGatewayAffinity",
"requestTimeout": 300
}
}
]
}
}
Bicep
resource appGateway 'Microsoft.Network/applicationGateways@2023-05-01' = {
name: 'myAppGateway'
location: location
properties: {
backendHttpSettingsCollection: [
{
name: 'appGatewayBackendHttpSettings'
properties: {
port: 80
protocol: 'Http'
cookieBasedAffinity: 'Enabled'
affinityCookieName: 'ApplicationGatewayAffinity'
requestTimeout: 300
}
}
]
}
}
Azure Kubernetes Service (AKS)
NGINX Ingress Controller
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: sse-app-ingress
annotations:
nginx.ingress.kubernetes.io/affinity: "cookie"
nginx.ingress.kubernetes.io/affinity-mode: "persistent"
nginx.ingress.kubernetes.io/session-cookie-name: "sse-affinity"
nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
# Timeout settings for SSE
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
ingressClassName: nginx
rules:
- host: sse-app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: sse-app-service
port:
number: 80
Important annotations:
affinity: "cookie": Cookie-based Session Affinityaffinity-mode: "persistent": Cookie persistencesession-cookie-name: Specify cookie namesession-cookie-expires: Cookie expiration (seconds)proxy-read-timeout: Read timeout (critical for SSE)
Azure Load Balancer (Service)
apiVersion: v1
kind: Service
metadata:
name: sse-app-service
annotations:
service.beta.kubernetes.io/azure-load-balancer-internal: "true"
spec:
type: LoadBalancer
sessionAffinity: ClientIP
sessionAffinityConfig:
clientIP:
timeoutSeconds: 10800 # 3 hours
ports:
- port: 80
targetPort: 8080
protocol: TCP
selector:
app: sse-app
sessionAffinity options:
None: No affinity (default)ClientIP: Client IP-based
Gateway API and HTTPRoute
When using Kubernetes Gateway API, you can configure Session Affinity with HTTPRoute.
For Application Gateway for Containers (ALB):
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: sse-app-route
namespace: default
spec:
parentRefs:
- name: gateway-01
namespace: default
hostnames:
- "sse-app.example.com"
rules:
- backendRefs:
- name: sse-app-service
port: 80
sessionAffinity:
type: Cookie
cookie:
name: affinity
maxAge: 3600 # 1 hour (in seconds)
Key Session Affinity Settings in Gateway API:
sessionAffinity.type: Cookie: Enable Cookie-based Session Affinitycookie.name: Specify session cookie namecookie.maxAge: Cookie expiration time (in seconds)
For NGINX Gateway Fabric:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: sse-app-route
namespace: default
annotations:
# NGINX-specific annotations
nginx.org/session-cookie-name: "sse-affinity"
nginx.org/session-cookie-expires: "3600"
nginx.org/session-cookie-max-age: "3600"
spec:
parentRefs:
- name: nginx-gateway
namespace: nginx-gateway
hostnames:
- "sse-app.example.com"
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: sse-app-service
port: 80
Comparison: Gateway vs. IngressClass:
| Feature | NGINX Ingress | Gateway API (ALB) | Gateway API (NGINX) |
|---|---|---|---|
| Session Affinity | Annotation | HTTPRoute Spec | Annotation |
| Standardization | × | ○ | △ |
| Cookie Control | Detailed | Basic | Detailed |
| Timeout Settings | Annotation | Limited | Annotation |
Advantages of Using Gateway API:
- Standardization: Uses Kubernetes-standard Gateway API
- Role-based Routing: Easier management of multiple Gateways
- Future-proof: More extensible than Ingress
- Azure Native: Integrates with Application Gateway for Containers
Additional SSE Configuration (ALB):
For Application Gateway for Containers, you can configure timeouts using BackendTLSPolicy.
apiVersion: alb.networking.azure.io/v1
kind: BackendTLSPolicy
metadata:
name: sse-backend-policy
namespace: default
spec:
targetRef:
group: ""
kind: Service
name: sse-app-service
namespace: default
override:
timeout:
request: 3600s # Longer timeout for SSE
Azure Front Door
Front Door supports Session Affinity, but be cautious with long-lived SSE connections.
resource frontDoor 'Microsoft.Cdn/profiles@2023-05-01' = {
name: 'myFrontDoor'
location: 'Global'
sku: {
name: 'Premium_AzureFrontDoor'
}
properties: {
originResponseTimeoutSeconds: 60 // Maximum 60 seconds
}
}
Considerations:
- Front Door timeout is maximum 60 seconds
- For long-lived SSE connections, Application Gateway + AKS combination is recommended
Limitations and Alternatives to Session Affinity
Limitations
-
Impact During Scale-Out
- Existing connections don't migrate when new instances are added
- Connections may concentrate on specific servers
-
Impact During Server Failure
- All connections are dropped when server goes down
- Reconnections are routed to different servers
-
Horizontal Scaling Constraints
- Load distribution may become uneven
- Idle connections occupy resources
Alternative: SignalR
For multi-server environments, consider using Azure SignalR Service.
SignalR Advantages:
- Scale-out support: Azure SignalR Service manages connections
- Automatic failover: Maintains connections during server failures
- Bi-directional communication: Client ⇔ Server
- Multiple protocols: Automatically selects WebSocket, SSE, or Long Polling
SignalR Implementation Example
// Startup.cs / Program.cs
builder.Services.AddSignalR()
.AddAzureSignalR(options =>
{
options.ConnectionString = builder.Configuration["Azure:SignalR:ConnectionString"];
});
app.MapHub<EventHub>("/eventhub");
// EventHub.cs
public class EventHub : Hub
{
public async Task SendEvent(string message)
{
await Clients.All.SendAsync("ReceiveEvent", message);
}
}
// Client-side
const connection = new signalR.HubConnectionBuilder()
.withUrl("/eventhub")
.withAutomaticReconnect()
.build();
connection.on("ReceiveEvent", (message) => {
console.log("Received:", message);
});
await connection.start();
Alternative: Redis Backplane
You can use Redis to share events across multiple servers.
// Program.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration["Redis:ConnectionString"];
});
// Event publishing
public class EventPublisher
{
private readonly IConnectionMultiplexer _redis;
public EventPublisher(IConnectionMultiplexer redis)
{
_redis = redis;
}
public async Task PublishEventAsync(string eventData)
{
var subscriber = _redis.GetSubscriber();
await subscriber.PublishAsync("sse-events", eventData);
}
}
// Event subscription
public class EventSubscriber : BackgroundService
{
private readonly IConnectionMultiplexer _redis;
private readonly ILogger<EventSubscriber> _logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var subscriber = _redis.GetSubscriber();
await subscriber.SubscribeAsync("sse-events", (channel, message) =>
{
_logger.LogInformation($"Received: {message}");
// Broadcast to connected clients
});
}
}
Best Practices
1. Configure Timeouts Appropriately
Since SSE connections are maintained for long periods, adjust timeouts at each layer.
// ASP.NET Core
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(30);
options.Limits.RequestHeadersTimeout = TimeSpan.FromMinutes(30);
});
2. Implement Heartbeats
Send data periodically to check connection liveness.
while (!cancellationToken.IsCancellationRequested)
{
// Send comment (heartbeat) every 30 seconds
await Response.WriteAsync(": heartbeat\n\n", cancellationToken);
await Response.Body.FlushAsync(cancellationToken);
await Task.Delay(30000, cancellationToken);
}
3. Implement Reconnection Logic
Implement automatic reconnection on the client side.
let retryCount = 0;
const maxRetries = 5;
function connectSSE() {
const eventSource = new EventSource('/api/events/stream');
eventSource.onopen = () => {
console.log('SSE Connected');
retryCount = 0;
};
eventSource.onerror = (error) => {
console.error('SSE Error:', error);
eventSource.close();
if (retryCount < maxRetries) {
retryCount++;
const delay = Math.min(1000 * Math.pow(2, retryCount), 30000);
console.log(`Reconnecting in ${delay}ms...`);
setTimeout(connectSSE, delay);
}
};
return eventSource;
}
const eventSource = connectSSE();
4. Resource Management
Monitor connection counts to prevent resource leaks.
public class SseConnectionManager
{
private readonly ConcurrentDictionary<string, HttpResponse> _connections = new();
private readonly ILogger<SseConnectionManager> _logger;
public void AddConnection(string connectionId, HttpResponse response)
{
_connections.TryAdd(connectionId, response);
_logger.LogInformation($"Active connections: {_connections.Count}");
}
public void RemoveConnection(string connectionId)
{
_connections.TryRemove(connectionId, out _);
_logger.LogInformation($"Active connections: {_connections.Count}");
}
public async Task BroadcastAsync(string message, CancellationToken cancellationToken)
{
var tasks = _connections.Values
.Select(async response =>
{
try
{
await response.WriteAsync($"data: {message}\n\n", cancellationToken);
await response.Body.FlushAsync(cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send message to client");
}
});
await Task.WhenAll(tasks);
}
}
5. Authentication and Authorization
Implement proper authentication for SSE connections.
[Authorize]
[HttpGet("stream")]
public async Task StreamEvents(CancellationToken cancellationToken)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
_logger.LogInformation($"SSE connection from user: {userId}");
// Send user-specific events only
// ...
}
// Bearer Token authentication example
const token = localStorage.getItem('access_token');
const eventSource = new EventSource('/api/events/stream', {
headers: {
'Authorization': `Bearer ${token}`
}
});
Note: The standard EventSource API doesn't support custom headers. Use one of the following methods:
-
Pass token via query parameter
const eventSource = new EventSource(`/api/events/stream?token=${token}`); -
Use EventSource Polyfill
import { EventSourcePolyfill } from 'event-source-polyfill';const eventSource = new EventSourcePolyfill('/api/events/stream', {headers: {'Authorization': `Bearer ${token}`}});
Summary
- SSE is effective for real-time push communication from server to client
- Session Affinity is essential in multi-server environments
- In Azure, configure with Application Gateway or NGINX Ingress
- Consider Azure SignalR Service for more advanced scenarios
- Implementation of timeouts, heartbeats, and reconnection logic is critical