Guide · AWS Networking · NLB + WebSocket/SSE

WebSocket and SSE Connections over NLB for MCP Servers

MCP's HTTP transport uses Server-Sent Events (SSE) — a long-lived HTTP GET where the server streams newline-delimited JSON events. This connection pattern breaks under ALB's 60-second idle timeout (if no events arrive for 60 seconds, ALB RSTs the connection), while NLB's 350-second TCP idle timeout handles quiet periods without disconnecting clients. This guide covers NLB's TCP idle timeout behavior, keep-alive configuration to prevent spurious timeouts, source IP hash sticky sessions for stateful MCP tool sessions, and monitoring active flow count to detect client disconnect storms and connection leaks. For NLB overview see AWS NLB for MCP Servers; for health checks see NLB Health Checks; for TLS setup see TLS at NLB for MCP Servers.

TL;DR

NLB's TCP idle timeout is 350 seconds for established connections (fixed, not configurable). SSE connections that go quiet for >350 seconds (no events, no keep-alives) will be RST by NLB. To prevent this: send SSE comment keep-alives every 30 seconds (: keepalive\n\n) and set Node.js socket keep-alive at the TCP level. For stateful MCP sessions (tools that require the same server task across multiple calls), enable source IP hash sticky sessions on the target group. Monitor ActiveFlowCount in CloudWatch — a sudden drop signals a disconnect storm; a monotonic rise signals a connection leak.

NLB TCP idle timeout: the 350-second rule

NLB tracks every TCP connection as a "flow" entry in its connection table. A flow entry expires after 350 seconds of inactivity — no bytes in either direction. When the entry expires, NLB sends a TCP RST to both the client and the target.

MCP SSE connections are one-directional during quiet periods: the server sends events, the client only reads them. If an MCP server has no events to send (e.g., all monitored endpoints are healthy and the client is subscribed to the live status feed), the connection is silent from the client side. The server may also be silent if it's waiting for a new probe cycle. This creates idle periods that can exceed 350 seconds and trigger NLB's timeout RST.

Load BalancerIdle timeoutConfigurableWhat happens at timeout
NLB (TCP/TLS listener) 350 seconds No — fixed at 350s TCP RST sent to both ends; TCP_ELB_Reset_Count metric increments
ALB 60 seconds (default) Yes — 1 to 4000 seconds Connection closed with HTTP 408 or TCP RST depending on state

For AliveMCP's live status feed (SSE stream that pushes events when endpoint status changes), quiet periods during "all healthy" windows can last several minutes. Setting ALB idle timeout to 400+ seconds avoids the issue but requires explicit configuration and has an upper limit. NLB's 350s is better than ALB's 60s default but still requires keep-alives for long-quiet SSE streams.

Keep-alive strategies: preventing NLB timeout on quiet SSE streams

Two independent keep-alive mechanisms work together to prevent NLB from RST-ing quiet SSE connections:

1. SSE comment keep-alives (application layer)

The SSE specification allows comment lines that the browser ignores: a line starting with : followed by any text. Sending these every 30 seconds creates traffic in the NLB flow and resets the 350-second idle timer.

// MCP SSE server: send keep-alive comments every 30 seconds
function createMcpSseHandler(req, res) {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no',   // prevents Nginx/proxy buffering of SSE chunks
  });

  // Send initial connection event
  res.write('data: {"type":"connected","serverTime":"' + new Date().toISOString() + '"}\n\n');

  // Keep-alive: comment line every 30s (well within NLB's 350s timeout)
  const keepAliveTimer = setInterval(() => {
    if (res.writableEnded) {
      clearInterval(keepAliveTimer);
      return;
    }
    res.write(': keepalive\n\n');
  }, 30_000);

  req.on('close', () => {
    clearInterval(keepAliveTimer);
  });

  // Send real MCP events when they arrive
  const unsubscribe = subscribeToProbeResults((event) => {
    if (res.writableEnded) return;
    res.write('data: ' + JSON.stringify(event) + '\n\n');
  });

  req.on('close', unsubscribe);
}

2. TCP socket keep-alive (transport layer)

TCP keep-alive probes run at the OS/transport layer independently of application data. When enabled, the OS sends TCP ACK probes after a period of inactivity. If the other end responds, the connection is considered alive and the NLB flow entry is reset.

// Node.js: enable TCP keep-alive on accepted sockets
const server = require('node:http').createServer(app);
server.on('connection', (socket) => {
  // Enable TCP keep-alive after 60 seconds of inactivity
  socket.setKeepAlive(true, 60_000);
  // socket.keepAlive = true at the OS level; probes sent every ~75s by default on Linux
});

// Or globally via server options (Node.js v22+):
const server = require('node:http').createServer({
  keepAliveTimeout: 120_000,   // server-side HTTP keep-alive timeout (matches ALB/NLB)
  headersTimeout: 60_000,
}, app);

With both mechanisms active, the effective keep-alive interval is 30 seconds (SSE comment) + TCP keep-alive at 60 seconds as backup. The NLB flow entry is refreshed every 30 seconds; the 350-second timeout is never reached under normal operation. Only a client that closes its browser tab without sending TCP FIN (e.g., machine suspended mid-session) would create a truly silent connection, which TCP keep-alive will detect and clean up.

Sticky sessions for stateful MCP tool sessions

Most MCP tools are stateless: each tool call is independent. Some tools maintain session state — a "browser automation" MCP might open a browser session on one container and expect subsequent calls to reach the same container. NLB supports sticky sessions via source IP hash: all connections from the same client IP are routed to the same target.

// CDK: enable source IP hash sticky sessions on target group
const cfnTg = targetGroup.node.defaultChild as elbv2.CfnTargetGroup;
cfnTg.addPropertyOverride('TargetGroupAttributes', [
  { Key: 'stickiness.enabled', Value: 'true' },
  {
    // source_ip: hash based on client IP and port (default)
    // source_ip_dest_ip: hash includes destination IP — for multi-NLB setups
    Key: 'stickiness.type',
    Value: 'source_ip',
  },
]);

Caveats for MCP with source IP sticky sessions:

WebSocket over NLB for MCP bidirectional communication

The MCP specification's SSE transport is unidirectional: the server pushes to the client. For MCP patterns that require bidirectional streaming (tool calls that produce intermediate results while accepting streaming input), WebSocket transport is emerging. NLB handles WebSocket at the TCP level with no special configuration.

// MCP WebSocket server with NLB compatibility
import { WebSocketServer } from 'ws';
import * as http from 'node:http';

const httpServer = http.createServer((req, res) => {
  if (req.url === '/health') { res.writeHead(200); res.end('OK'); return; }
  res.writeHead(404); res.end();
});

const wss = new WebSocketServer({ server: httpServer });

wss.on('connection', (ws, req) => {
  const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;

  // Send WebSocket ping every 30s to keep NLB flow alive
  const pingInterval = setInterval(() => {
    if (ws.readyState === ws.OPEN) ws.ping();
  }, 30_000);

  ws.on('pong', () => {
    // NLB flow refreshed; client connection confirmed alive
  });

  ws.on('message', (data) => {
    // Handle MCP tool call JSON messages
    const call = JSON.parse(data.toString());
    handleMcpCall(call).then(result => {
      ws.send(JSON.stringify(result));
    });
  });

  ws.on('close', () => clearInterval(pingInterval));
  ws.on('error', () => clearInterval(pingInterval));
});

WebSocket ping/pong frames carry bytes through the NLB flow, preventing the 350-second idle timeout. Unlike SSE (HTTP GET), WebSocket upgrades from HTTP to a binary protocol — the NLB sees this as a TCP connection and doesn't interfere with the upgrade handshake. NLB supports WebSocket over TCP and TLS listeners without any special target group configuration.

Monitoring active flow count as an MCP health signal

NLB's ActiveFlowCount metric counts open TCP connections at any instant. For an MCP server, this is a proxy for active subscriber count (SSE connections) plus in-flight tool calls (WebSocket connections). Monitoring this metric reveals two failure modes:

PatternWhat it meansAlert condition
Sudden large drop in ActiveFlowCount Mass client disconnect — MCP server restarted (all SSE streams dropped), network partition, NLB RST storm Drop >50% within 1 minute
Monotonic rise in ActiveFlowCount over hours Connection leak — MCP server not closing completed SSE streams; req.on('close') handler missing; graceful shutdown not implemented Count > expected_concurrent × 2 for 30 minutes
Spike in NewFlowCount Client reconnection storm — all clients reconnecting simultaneously after a restart; can briefly overwhelm MCP server connection handling NewFlowCount > 1000/s for 2 minutes
ActiveFlowCount = 0 during business hours No clients connected — possible DNS failure, cert error preventing TLS handshake, or NLB health check failing (no healthy targets) ActiveFlowCount = 0 for 5 minutes when traffic is expected
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';

// Alert on connection leak: steady growth in active flows
const connectionLeakAlarm = new cloudwatch.Alarm(scope, 'ConnectionLeak', {
  alarmName: 'mcp-nlb-connection-leak',
  metric: new cloudwatch.Metric({
    namespace: 'AWS/NetworkELB',
    metricName: 'ActiveFlowCount',
    dimensionsMap: { LoadBalancer: nlb.loadBalancerFullName },
    period: Duration.minutes(5),
    statistic: 'Average',
  }),
  threshold: 5000,   // tune to 2× your expected max concurrent connections
  comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
  evaluationPeriods: 6,   // sustained for 30 minutes before alarming
  treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
});

// Alert on TCP reset storm (NLB closing idle connections faster than expected)
const rstStormAlarm = new cloudwatch.Alarm(scope, 'TcpRstStorm', {
  alarmName: 'mcp-nlb-tcp-reset-storm',
  metric: new cloudwatch.Metric({
    namespace: 'AWS/NetworkELB',
    metricName: 'TCP_ELB_Reset_Count',
    dimensionsMap: { LoadBalancer: nlb.loadBalancerFullName },
    period: Duration.minutes(1),
    statistic: 'Sum',
  }),
  threshold: 100,   // more than 100 RSTs/minute suggests idle timeout fires
  comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
  evaluationPeriods: 3,
  treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
});

A TCP_ELB_Reset_Count spike correlates with missing keep-alives: NLB kills idle SSE connections, clients see connection closed errors, and they reconnect — generating a NewFlowCount spike immediately after. The pattern (TCP RST spike → NewFlowCount spike with 5-second lag) is the fingerprint of "SSE keep-alive not implemented." AliveMCP's external probe catches this as intermittent disconnects on the monitored endpoint's uptime chart: brief gaps in the 60-second probe cadence where the probe had to reconnect before verifying the server was alive.

Graceful shutdown: draining SSE connections on ECS task stop

When ECS stops a Fargate task (during deployment or Spot interruption), it sends SIGTERM to the container. The MCP server should drain in-flight SSE streams gracefully rather than letting NLB RST them during deregistration:

// MCP SSE server graceful shutdown on SIGTERM
const activeSseClients = new Set();

process.on('SIGTERM', async () => {
  console.log('SIGTERM received — beginning graceful shutdown');

  // Stop NLB health check first: return 503 so NLB stops sending new connections
  isHealthy = false;

  // Send close event to all active SSE clients
  for (const res of activeSseClients) {
    if (!res.writableEnded) {
      // Final SSE event: client should reconnect to a healthy server
      res.write('event: server_shutdown\ndata: {"reconnect":true,"retryAfterMs":5000}\n\n');
      res.end();
    }
  }
  activeSseClients.clear();

  // Close the HTTP server — no new connections accepted
  httpServer.close(() => {
    console.log('HTTP server closed — exiting');
    process.exit(0);
  });

  // Force exit if drain takes too long (ECS will SIGKILL at stopTimeout anyway)
  setTimeout(() => process.exit(0), 100_000);  // 100s < 115s ECS stopTimeout
});

The server_shutdown SSE event is an application-level signal — MCP clients that handle it can display a "reconnecting" state rather than showing an error. Clients that don't handle it will see a connection close and retry automatically. The 5000ms retryAfterMs hint suggests clients wait 5 seconds before reconnecting — enough time for the new ECS task to become healthy and begin accepting connections through NLB.