Skip to main content

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-stream Content-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:

  1. Long-lived connections: Maintains connections for minutes to hours
  2. Stateful: Server manages per-client connections
  3. 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

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

  1. Application Gateway → Backend settings
  2. Set Cookie-based affinity to "Enabled"
  3. 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 Affinity
  • affinity-mode: "persistent": Cookie persistence
  • session-cookie-name: Specify cookie name
  • session-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:

  1. sessionAffinity.type: Cookie: Enable Cookie-based Session Affinity
  2. cookie.name: Specify session cookie name
  3. cookie.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:

FeatureNGINX IngressGateway API (ALB)Gateway API (NGINX)
Session AffinityAnnotationHTTPRoute SpecAnnotation
Standardization×
Cookie ControlDetailedBasicDetailed
Timeout SettingsAnnotationLimitedAnnotation

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

  1. Impact During Scale-Out

    • Existing connections don't migrate when new instances are added
    • Connections may concentrate on specific servers
  2. Impact During Server Failure

    • All connections are dropped when server goes down
    • Reconnections are routed to different servers
  3. 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:

  1. Pass token via query parameter

    const eventSource = new EventSource(`/api/events/stream?token=${token}`);
  2. 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

References