From SSE to Streamable HTTP: Protocol Evolution for Real-Time Web Applications

· Michael Chen · 10 min read

Server-Sent Events arrived in 2006 as the browser-native answer to Comet long-polling. A single HTTP connection, a text/event-stream Content-Type, and a dead-simple framing format gave frontend developers a unidirectional push channel without the complexity of WebSocket handshakes. For a decade, SSE was good enough. Then the Model Context Protocol needed a transport that could survive CDN edge servers, horizontal scaling, and zero-downtime deployments. The result is Streamable HTTP, a protocol pattern that keeps SSE's simplicity for server push while restoring the stateless request-response semantics that HTTP infrastructure was built for.

This article examines why SSE creates operational problems at scale, how Streamable HTTP solves them, and what infrastructure engineers need to understand about deploying real-time protocols behind modern CDN architectures.

How Server-Sent Events Work at the Protocol Level

SSE operates over a standard HTTP connection. The client sends a GET request with Accept: text/event-stream. The server responds with Content-Type: text/event-stream and holds the connection open, sending events as newline-delimited text frames. Each event consists of optional id:, event:, and data: fields, terminated by a double newline.

GET /events HTTP/1.1
Host: api.example.com
Accept: text/event-stream
Cache-Control: no-cache

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

id: 1
event: update
data: {"temperature": 22.5, "unit": "celsius"}

id: 2
event: update
data: {"temperature": 22.8, "unit": "celsius"}

The browser's EventSource API handles reconnection automatically. When the connection drops, the browser sends a new request with a Last-Event-ID header set to the last received event ID. The server can use this to replay missed events. This built-in reconnection logic made SSE attractive compared to WebSockets, which require application-level reconnection handling.

SSE Connection Lifecycle SSE Connection Lifecycle Browser CDN Edge Origin Conn Pool GET /events Pass-through Pin FD Long-lived connection (minutes to hours) event: data1 event: data2 event: data3 Exhausting... Each SSE client pins one file descriptor for the entire session
Figure 1: SSE connections pin to specific edge servers and consume file descriptors for the entire session duration, preventing load redistribution.

Why SSE Breaks CDN Infrastructure

SSE connections create four distinct problems for CDN edge infrastructure.

Performance Comparison: Edge vs Origin Processing
Metric Edge Processing Origin Processing Improvement
Latency (p50)8–15 ms120–300 ms15–20x faster
Cache Hit Ratio85–95%N/AReduces origin load
TLS Handshake1–5 ms (nearby PoP)50–150 ms10–30x faster
Bandwidth Cost$0.01–0.04/GB$0.05–0.12/GB3–5x cheaper
Availability99.99% (multi-PoP)99.9% (single DC)10x fewer outages

Connection pinning defeats load balancing

An SSE connection binds a client to a specific edge server for the duration of the stream, which can last minutes to hours. Load balancers cannot redistribute these connections when a server becomes overloaded or when new capacity comes online. During a rolling deployment, servers draining SSE connections must wait for all streams to close naturally or forcibly terminate them, causing client disconnections.

Uncacheable responses waste edge capacity

CDN caches operate on complete responses with defined boundaries. An SSE response has no Content-Length, no ETag, and no defined end. Cache engines cannot store a response that never finishes. Every SSE request passes through the edge server directly to the origin, turning the CDN from a caching layer into a pure TCP proxy. This fundamentally undermines the purpose of deploying cache infrastructure.

File descriptor exhaustion under load

Each SSE connection consumes a file descriptor and associated kernel memory on the edge server. A server with a typical ulimit -n of 65,536 open files can handle at most 65,536 concurrent SSE connections, minus file descriptors consumed by cache storage, logging, and origin connections. Under HTTP/1.1, browsers limit themselves to 6 connections per domain, meaning a single SSE stream consumes 16% of a client's connection budget to that origin.

Proxy buffering corrupts event delivery

Reverse proxies and CDN edge servers often buffer responses to optimize delivery. Response buffering accumulates data in memory and sends it in larger chunks to reduce syscall overhead. For SSE, buffering introduces latency or completely breaks event delivery. Engineers must disable buffering for SSE endpoints with headers like X-Accel-Buffering: no (nginx) or Cache-Control: no-transform, adding configuration complexity at every proxy hop between origin and client.

# nginx configuration to disable buffering for SSE
location /events {
    proxy_pass http://upstream;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 86400s;
    chunked_transfer_encoding off;
}

Streamable HTTP: The Protocol Design

Streamable HTTP, introduced as a transport option for the Model Context Protocol (MCP), replaces SSE's always-on connection with standard HTTP request-response semantics that optionally upgrade to streaming when the server needs to push multiple messages.

Request-response as the default

The client sends requests as HTTP POST with Content-Type: application/json. The server processes the request and returns a standard HTTP response. No long-lived connection, no special Content-Type, no proxy configuration changes. The response is a normal HTTP response that CDN edge servers can cache, compress, and route through standard infrastructure.

Optional SSE upgrade for server push

When the server needs to send multiple messages in response to a single request (progress updates during a long-running operation, for example), the server responds with Content-Type: text/event-stream and streams events. The critical difference from pure SSE is that this streaming response is scoped to a single request-response cycle. The stream ends when the operation completes, and the connection returns to normal HTTP semantics.

Streamable HTTP vs SSE Architecture Streamable HTTP vs Traditional SSE Traditional SSE Client connects Connection pinned to single server Stream open minutes to hours uncacheable No load balancing after connect Proxy buffering must be disabled FD exhaustion under high concurrency Streamable HTTP POST request standard HTTP JSON response cacheable Optional SSE upgrade for push scoped to request Any server can handle any request Standard proxy config works Connection freed after each request completes Breaks CDN caching, load balancing, and connection management Works with existing HTTP infrastructure. Streaming only when needed. Key Difference Connection = Session Session = Header Token Session dies when connection drops Session survives connection changes Must reconnect to same server Any server with session store works
Figure 2: Streamable HTTP preserves standard HTTP semantics while providing server push when needed, avoiding SSE's infrastructure problems.

Session management via MCP-Session-Id

Streamable HTTP decouples session state from connection state. The server generates a unique session identifier during initialization and returns it in the Mcp-Session-Id response header. The client includes this identifier in all subsequent requests. Any server instance with access to the session store can handle any request, enabling standard round-robin load balancing.

# Initial request
POST /mcp HTTP/1.1
Content-Type: application/json

{"jsonrpc": "2.0", "method": "initialize", "id": 1}

# Response with session ID
HTTP/1.1 200 OK
Content-Type: application/json
Mcp-Session-Id: sess_a1b2c3d4e5f6

{"jsonrpc": "2.0", "result": {"capabilities": {}}, "id": 1}

# Subsequent request includes session header
POST /mcp HTTP/1.1
Content-Type: application/json
Mcp-Session-Id: sess_a1b2c3d4e5f6

{"jsonrpc": "2.0", "method": "tools/list", "id": 2}

This pattern is familiar to anyone who has worked with HTTP session cookies, but the explicit header avoids cookie-related complications like CSRF vulnerabilities, SameSite restrictions, and third-party cookie blocking.

CDN Architecture for Streamable HTTP

Deploying Streamable HTTP behind a CDN requires understanding which request types are cacheable and how to route streaming responses correctly.

Cache strategy by response type

Standard JSON responses from Streamable HTTP endpoints follow normal Cache-Control semantics. Read-only operations like listing available tools or reading resource metadata can carry Cache-Control: public, max-age=300 and be served from edge cache. Mutation operations and streaming responses should carry Cache-Control: no-store to bypass caching.

Request TypeResponse FormatCacheableCache-Control
Initialize sessionJSONNono-store
List tools/resourcesJSONYespublic, max-age=300
Read resourceJSONVariesETag + stale-while-revalidate
Execute tool (fast)JSONNono-store
Execute tool (slow)SSE streamNono-store, X-Accel-Buffering: no
Session terminationHTTP DELETENono-store

Edge routing configuration

CDN edge functions can inspect the Accept header and Mcp-Session-Id to make routing decisions. Requests without a session ID (initialization) route to origin servers with available session capacity. Requests with an existing session ID route to any origin server that shares the session store, typically backed by Redis or a similar in-memory data store.

# Edge function pseudocode for Streamable HTTP routing
def handle_request(request):
    session_id = request.headers.get("Mcp-Session-Id")

    if not session_id:
        # New session - route to least-loaded origin
        origin = select_origin(strategy="least-connections")
        return proxy_to(origin, request)

    # Existing session - route to any origin with session store access
    origin = select_origin(strategy="round-robin")
    response = proxy_to(origin, request)

    # If response is SSE, disable buffering
    if response.headers.get("Content-Type") == "text/event-stream":
        response.headers["X-Accel-Buffering"] = "no"

    return response

Security Advantages of Stateless Transport

Streamable HTTP provides security improvements over SSE that matter for production deployments behind CDN security infrastructure.

Request-level authentication

With SSE, authentication happens once at connection establishment. If a token expires mid-stream, the server has no mechanism to re-authenticate without dropping the connection. Streamable HTTP authenticates every request independently. Expired tokens result in a standard 401 response, and the client obtains a fresh token before retrying. This aligns with zero-trust security principles where every request is verified.

Reduced attack surface for connection hijacking

Long-lived SSE connections increase the window for connection hijacking attacks, man-in-the-middle interception, and session fixation. Streamable HTTP's short-lived connections reduce this exposure. Each request can carry fresh credentials, and security headers are evaluated per-request rather than once per session.

Standard WAF compatibility

Web Application Firewalls are designed to inspect complete HTTP request-response pairs. SSE streams bypass WAF inspection after the initial handshake because the WAF cannot meaningfully analyze an unbounded response stream. Streamable HTTP's request-response pattern allows WAF rules to inspect every interaction, including payload size limits, rate limiting, and content inspection.

Implementing Streamable HTTP in Production

A production Streamable HTTP deployment requires session state management, graceful degradation, and monitoring that accounts for the hybrid nature of the protocol.

Session store architecture

The session store must be accessible from all origin servers. Redis with replication provides sub-millisecond reads and automatic failover. Session data typically includes the protocol state machine position, registered capabilities, and any accumulated context. Sessions should carry a TTL (typically 30 minutes of inactivity) and the server should reject requests with expired or unknown session IDs with HTTP 404.

# Session store implementation
class SessionStore:
    def __init__(self, redis_url):
        self.redis = Redis.from_url(redis_url)
        self.ttl = 1800  # 30 minutes

    def create(self, capabilities):
        session_id = f"sess_{secrets.token_hex(12)}"
        self.redis.setex(
            f"mcp:session:{session_id}",
            self.ttl,
            json.dumps({"capabilities": capabilities, "created": time.time()})
        )
        return session_id

    def get(self, session_id):
        data = self.redis.get(f"mcp:session:{session_id}")
        if data is None:
            raise SessionNotFound(session_id)
        self.redis.expire(f"mcp:session:{session_id}", self.ttl)
        return json.loads(data)

    def delete(self, session_id):
        self.redis.delete(f"mcp:session:{session_id}")

Graceful degradation for legacy clients

Servers should support both SSE-only and Streamable HTTP clients during migration. Content negotiation via the Accept header determines which transport to use. Clients sending Accept: text/event-stream without an Mcp-Session-Id receive legacy SSE behavior. Clients sending Accept: application/json, text/event-stream receive Streamable HTTP responses.

Performance Comparison: SSE vs Streamable HTTP

Real-world performance benchmarks reveal significant differences in resource consumption and scalability between the two approaches.

MetricSSEStreamable HTTPImprovement
Max concurrent clients per edge server (64K FD limit)~60,000500,000+8x
Memory per client (idle)~50 KB (connection buffers)~0 KB (no connection)Stateless
Load balancer redistributionImpossible mid-sessionPer-requestFull flexibility
Cache hit ratio (mixed workload)0% (all pass-through)40–70% (read operations cached)Significant
Rolling deployment drain timeMinutes (wait for streams to close)Seconds (in-flight requests only)Orders of magnitude
WAF inspection coverageInitial handshake onlyEvery requestFull coverage

The memory savings alone justify the migration for high-concurrency deployments. An edge server handling 50,000 concurrent SSE clients allocates approximately 2.5 GB of memory just for connection buffers. Under Streamable HTTP, the same server handles those clients with near-zero idle memory, freeing resources for cache storage and request processing.

Migration Strategy from SSE to Streamable HTTP

Migrating from SSE to Streamable HTTP follows a phased approach that maintains backward compatibility throughout the transition.

Phase 1 deploys the Streamable HTTP endpoint alongside the existing SSE endpoint. Both share the same session store and business logic. New clients connect via Streamable HTTP while existing clients continue using SSE.

Phase 2 updates CDN routing rules to identify Streamable HTTP traffic and apply appropriate caching policies. Edge functions inspect the Accept header and route accordingly.

Phase 3 monitors connection metrics to verify that SSE client counts are declining. Once SSE connections drop below a threshold (typically 5% of total traffic), configure the SSE endpoint to return HTTP 301 redirects to the Streamable HTTP endpoint.

Phase 4 decommissions the SSE endpoint entirely. Remove proxy buffering overrides, SSE-specific TCP tuning, and connection timeout configurations that were only needed for long-lived streams.

Protocol Evolution and Future Directions

Streamable HTTP represents a broader trend in protocol design: leveraging the ubiquity of HTTP semantics while adding streaming capabilities only where necessary. HTTP/3 and QUIC further improve this pattern by eliminating head-of-line blocking and reducing connection setup latency. A Streamable HTTP request over QUIC achieves zero-round-trip connection establishment (0-RTT) combined with multiplexed streams, giving clients the responsiveness of persistent connections without the infrastructure burden.

The lesson from SSE's limitations is that protocols designed for direct client-server communication often fail when deployed behind multi-layer infrastructure. CDN edge servers, reverse proxies, load balancers, and WAFs all expect HTTP's stateless request-response pattern. Protocols that violate this expectation require special handling at every layer, creating operational complexity that scales with infrastructure depth. Streamable HTTP succeeds because it works with HTTP infrastructure rather than against it.

For infrastructure engineers evaluating real-time transport options, the decision framework is straightforward. Use WebSockets for bidirectional, high-frequency communication (gaming, collaborative editing). Use Streamable HTTP for request-response patterns that occasionally need server push (AI model interactions, tool execution, workflow orchestration). Reserve pure SSE for simple notification streams where infrastructure complexity is not a concern, such as development environments or single-server deployments.

External References

References & Standards: OWASP CDN Security · HTTP/2 specification