Showing posts with label real-time. Show all posts
Showing posts with label real-time. Show all posts

Monday, July 27, 2026

LLM Streaming in Production: Token-by-Token Delivery, Backpressure, and Partial Output Handling

We launched a streaming chat interface on top of Claude. The first version worked fine in staging with five concurrent testers. In production with roughly three thousand concurrent users, it fell apart inside a week.

The failure mode was not what we expected. The LLM side was fine. The streaming protocol was fine. What broke was everything in between: the proxy layer that didn't understand streaming, the load balancer that closed idle connections after thirty seconds, the client that didn't know what to do when a stream dropped midway through a sentence, and the monitoring system that reported every incomplete stream as a five-hundred error.

This post covers what we rebuilt and why.


Why Streaming Matters (And Why It's Harder Than It Looks)

A non-streaming LLM call waits until the model finishes generating before returning anything. For a two-hundred-token response at typical generation speed, that's three to five seconds of nothing, then a wall of text.

Streaming returns tokens as they're generated. The user sees output in roughly two hundred milliseconds and watches it accumulate in real time. Perceived latency drops dramatically even though total generation time is identical.

The implementation complexity is the catch. Non-streaming is a request/response cycle. Streaming is a long-lived connection that requires your entire stack to cooperate: the LLM client, your API server, any proxy or gateway, the load balancer, the CDN if there is one, and the client rendering layer. Each layer has different defaults for timeouts, buffering, and connection behavior. Getting all of them right takes deliberate configuration.


SSE vs WebSocket: The Actual Tradeoff

Most teams reach for WebSockets for streaming LLM output. We did too, initially. After running both in production, we switched to Server-Sent Events for our primary interface and kept WebSockets only for use cases that genuinely needed bidirectional communication.

Why SSE won for us:

SSE is HTTP. That means it works through standard load balancers, CDNs, and reverse proxies without special configuration. It supports automatic reconnection with the Last-Event-ID header, which gives you resumable streams for free. Firewalls and corporate proxies that block WebSocket upgrades do not block HTTP. Browser support is universal and the API is simple.

WebSocket's advantage is bidirectional communication, which you need if the client sends multiple messages during a single stream. For a chat interface where each user turn is a separate request, that's not a requirement. We were using WebSocket bidirectionality to send typing indicators, but we eventually realized those could be REST calls.

The practical difference in implementation:

# SSE implementation with FastAPI
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import anthropic
import asyncio

app = FastAPI()
client = anthropic.AsyncAnthropic()

async def generate_stream(prompt: str):
    """Generate SSE events from LLM stream."""
    try:
        async with client.messages.stream(
            model="claude-opus-4-5",
            max_tokens=1024,
            messages=[{"role": "user", "content": prompt}]
        ) as stream:
            async for text in stream.text_stream:
                # SSE format: data: <payload>\n\n
                yield f"data: {json.dumps({'token': text})}\n\n"

            # Send done signal
            yield f"data: {json.dumps({'done': True})}\n\n"

    except anthropic.APIError as e:
        yield f"data: {json.dumps({'error': str(e)})}\n\n"

@app.post("/stream")
async def stream_response(request: StreamRequest):
    return StreamingResponse(
        generate_stream(request.prompt),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",  # Disable nginx buffering
            "Connection": "keep-alive",
        }
    )

The X-Accel-Buffering: no header is critical if you're behind nginx. Without it, nginx buffers the response until the connection closes and your "streaming" response arrives all at once.


The Backpressure Problem

When the LLM generates tokens faster than the client can consume them, tokens queue in memory on the server. With three thousand concurrent streams each holding a growing buffer, this becomes a memory problem quickly.

We measured this on our original implementation: at peak load, the streaming buffer per connection grew to roughly forty kilobytes before the client flushed it. Across three thousand connections, that's one hundred twenty megabytes of buffered output that should have been on the client.

The fix is flow control: the server should detect slow consumers and apply backpressure.

import asyncio
from asyncio import Queue

class BackpressureStream:
    def __init__(self, max_queue_size: int = 50):
        self.queue: Queue = Queue(maxsize=max_queue_size)
        self.done = False

    async def producer(self, prompt: str):
        """Feed tokens into queue from LLM."""
        try:
            async with client.messages.stream(
                model="claude-opus-4-5",
                max_tokens=1024,
                messages=[{"role": "user", "content": prompt}]
            ) as stream:
                async for text in stream.text_stream:
                    # put() blocks when queue is full → backpressure
                    await self.queue.put({"token": text})

            await self.queue.put({"done": True})
        except Exception as e:
            await self.queue.put({"error": str(e)})
        finally:
            self.done = True

    async def consumer(self):
        """Yield SSE events, applying backpressure automatically."""
        while True:
            item = await self.queue.get()
            yield f"data: {json.dumps(item)}\n\n"
            if item.get("done") or item.get("error"):
                break

@app.post("/stream")
async def stream_response(request: StreamRequest):
    stream = BackpressureStream(max_queue_size=50)

    # Start producer in background
    asyncio.create_task(stream.producer(request.prompt))

    return StreamingResponse(
        stream.consumer(),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
    )

The Queue(maxsize=50) creates the backpressure mechanism. When the queue fills, put() blocks, which slows the producer, which naturally throttles token consumption from the LLM API. The client controls pacing implicitly through how fast it reads.


Timeout Configuration Across the Stack

The second failure mode was timeouts. An LLM generating a long response takes time. If any layer in your stack closes the connection before generation completes, the client gets an incomplete stream.

Things that will kill your stream if not configured:

Load balancer idle timeout. Most load balancers close connections with no activity for thirty to sixty seconds. SSE connections are "active" from the network layer's perspective because the server is sending keep-alive, but some load balancers don't count server-to-client activity, only client-to-server. Check your specific load balancer documentation.

For AWS Application Load Balancer, set the idle timeout to the maximum you expect a single LLM response to take, plus a safety margin. We use three hundred seconds.

Nginx proxy timeout. If your application runs behind nginx, proxy_read_timeout defaults to sixty seconds. Set it to match or exceed your load balancer timeout.

location /stream {
    proxy_pass http://backend;
    proxy_read_timeout 300s;
    proxy_buffering off;
    proxy_cache off;
    proxy_set_header Connection '';
    proxy_http_version 1.1;
    chunked_transfer_encoding on;
}

LLM client timeout. The Anthropic SDK default timeout is ten minutes for streaming. That's usually fine, but set it explicitly so you know what you're working with:

client = anthropic.AsyncAnthropic(
    timeout=anthropic.Timeout(
        connect=5.0,    # Connection establishment
        read=300.0,     # Time to receive each chunk
        write=10.0,     # Time to send the request
        pool=5.0,       # Time to acquire connection from pool
    )
)

Keep-alive ping. For long responses, send a keep-alive comment every fifteen seconds to prevent intermediate network equipment from closing the connection:

async def generate_stream_with_keepalive(prompt: str):
    last_ping = asyncio.get_event_loop().time()

    async with client.messages.stream(...) as stream:
        async for text in stream.text_stream:
            current_time = asyncio.get_event_loop().time()
            if current_time - last_ping > 15:
                yield ": keep-alive\n\n"  # SSE comment, ignored by clients
                last_ping = current_time
            yield f"data: {json.dumps({'token': text})}\n\n"

Handling Partial Output

When a stream drops midway through generation, you have partial output. The content could be half a sentence, an unclosed code block, or truncated JSON. The right handling depends on what you're building.

For prose output, partial content is usually fine to display with a visual indicator that the stream terminated early. The user can see where it cut off.

For structured output (JSON, code), partial content is often unparseable. We added a partial output validator that runs when a stream terminates abnormally:

import json
from enum import Enum

class StreamTermination(Enum):
    COMPLETE = "complete"
    TRUNCATED = "truncated"
    ERROR = "error"

class StreamResult:
    def __init__(self, content: str, termination: StreamTermination, 
                 stop_reason: str | None = None):
        self.content = content
        self.termination = termination
        self.stop_reason = stop_reason
        self.is_valid_json = self._check_json()
        self.unclosed_code_blocks = self._count_unclosed_code_blocks()

    def _check_json(self) -> bool:
        try:
            json.loads(self.content)
            return True
        except (json.JSONDecodeError, ValueError):
            return False

    def _count_unclosed_code_blocks(self) -> int:
        blocks = self.content.count("```")
        return blocks % 2  # Odd count means unclosed block

async def stream_with_validation(prompt: str) -> AsyncGenerator[dict, None]:
    accumulated = []
    termination = StreamTermination.ERROR
    stop_reason = None

    try:
        async with client.messages.stream(
            model="claude-opus-4-5",
            max_tokens=1024,
            messages=[{"role": "user", "content": prompt}]
        ) as stream:
            async for text in stream.text_stream:
                accumulated.append(text)
                yield {"token": text}

            final_message = await stream.get_final_message()
            stop_reason = final_message.stop_reason
            termination = (StreamTermination.COMPLETE 
                          if stop_reason == "end_turn" 
                          else StreamTermination.TRUNCATED)

    except anthropic.APIStatusError:
        termination = StreamTermination.ERROR

    finally:
        result = StreamResult(
            content="".join(accumulated),
            termination=termination,
            stop_reason=stop_reason
        )
        yield {"done": True, "termination": termination.value, 
               "stop_reason": stop_reason,
               "has_unclosed_code_blocks": bool(result.unclosed_code_blocks)}

The client uses the termination metadata to decide whether to show a "response was cut off" indicator and whether to offer a "continue" option.


Client-Side Reconnection

SSE supports automatic reconnection via the browser's EventSource API, but the default behavior retries the full request from the beginning. For LLM streaming, you want to resume from where you left off.

This requires server-side support for resumption:

// Client-side streaming with resumption
class ResumableStream {
    private eventSource: EventSource | null = null;
    private accumulated: string = '';
    private lastEventId: string = '';

    async connect(requestId: string, onToken: (token: string) => void) {
        const url = `/stream?request_id=${requestId}&resume_from=${this.lastEventId}`;

        this.eventSource = new EventSource(url);

        this.eventSource.onmessage = (event) => {
            this.lastEventId = event.lastEventId || '';
            const data = JSON.parse(event.data);

            if (data.token) {
                this.accumulated += data.token;
                onToken(data.token);
            }

            if (data.done || data.error) {
                this.eventSource?.close();
            }
        };

        this.eventSource.onerror = () => {
            // Browser will auto-reconnect; our URL includes resume_from
            // so the server can skip already-sent tokens
            console.log('Stream disconnected, reconnecting...');
        };
    }
}

Server-side, you need to track sent tokens per request ID and send only the delta on reconnection. We use Redis for this with a short TTL:

import redis.asyncio as redis

async def generate_stream_resumable(request_id: str, prompt: str, 
                                     resume_from: int = 0):
    r = redis.Redis()
    token_count = 0

    async with client.messages.stream(
        model="claude-opus-4-5",
        max_tokens=1024,
        messages=[{"role": "user", "content": prompt}]
    ) as stream:
        async for text in stream.text_stream:
            token_count += 1

            # Cache every token with request_id prefix
            await r.rpush(f"stream:{request_id}", text)
            await r.expire(f"stream:{request_id}", 300)  # 5-minute TTL

            # Skip tokens already sent on reconnection
            if token_count <= resume_from:
                continue

            yield f"id: {token_count}\ndata: {json.dumps({'token': text})}\n\n"

This adds complexity. We only implemented resumption for our highest-traffic endpoint. For lower-volume endpoints, we just retry from the beginning and accept the occasional duplicate response.


Monitoring Streaming Endpoints

Standard HTTP monitoring doesn't work well for streaming. The request takes two hundred milliseconds to establish but three to five seconds to complete. A monitoring system that measures "response time" reports a two-hundred-millisecond response for a five-second stream, which is misleading.

Metrics that actually matter for streaming:

Time to first token (TTFT): How long from request to first token received. This is the perceived latency from the user's perspective. Track this as a percentile distribution.

Token generation rate: Tokens per second. Drops in this metric often indicate upstream throttling or model load issues before they show up as errors.

Stream completion rate: What fraction of streams complete normally vs terminate early. Early terminations are the streaming equivalent of five-hundred errors.

Stream duration: Total time from first to last token. Useful for capacity planning.

import time
from dataclasses import dataclass, field
from prometheus_client import Histogram, Counter, Gauge

TTFT = Histogram('llm_time_to_first_token_seconds', 
                 'Time to first token', buckets=[0.1, 0.2, 0.5, 1.0, 2.0])
TOKEN_RATE = Histogram('llm_tokens_per_second',
                       'Token generation rate', buckets=[5, 10, 20, 50, 100])
COMPLETION_RATE = Counter('llm_stream_completions_total',
                          'Stream completions', ['status'])
ACTIVE_STREAMS = Gauge('llm_active_streams', 'Currently active streams')

@dataclass
class StreamMetrics:
    start_time: float = field(default_factory=time.time)
    first_token_time: float | None = None
    token_count: int = 0

    def record_first_token(self):
        if self.first_token_time is None:
            self.first_token_time = time.time()
            TTFT.observe(self.first_token_time - self.start_time)

    def record_token(self):
        self.token_count += 1

    def finalize(self, status: str):
        duration = time.time() - self.start_time
        if duration > 0 and self.token_count > 0:
            TOKEN_RATE.observe(self.token_count / duration)
        COMPLETION_RATE.labels(status=status).inc()
        ACTIVE_STREAMS.dec()

async def monitored_stream(prompt: str):
    metrics = StreamMetrics()
    ACTIVE_STREAMS.inc()

    try:
        async with client.messages.stream(
            model="claude-opus-4-5",
            max_tokens=1024,
            messages=[{"role": "user", "content": prompt}]
        ) as stream:
            async for text in stream.text_stream:
                metrics.record_first_token()
                metrics.record_token()
                yield f"data: {json.dumps({'token': text})}\n\n"

        metrics.finalize("complete")
        yield f"data: {json.dumps({'done': True})}\n\n"

    except Exception as e:
        metrics.finalize("error")
        yield f"data: {json.dumps({'error': str(e)})}\n\n"

What We'd Do Differently

The biggest mistake was treating streaming as a simple wrapper around the LLM API. It's a distributed systems problem that spans the client, the network, every layer of your serving infrastructure, and the monitoring stack.

The changes that had the most impact, in order:

  1. Disabled nginx response buffering. Fixed most of our "delayed streaming" complaints immediately.
  2. Increased load balancer idle timeout. Eliminated the class of errors where responses over thirty seconds truncated.
  3. Added time-to-first-token as a primary metric. Made it obvious when upstream latency spiked.
  4. Implemented the backpressure queue. Dropped per-process memory usage by roughly sixty percent under load.
  5. Added client-side incomplete stream detection. Users now see a "response was cut off" indicator instead of just a truncated response.

Streaming is worth the complexity for any interface where users wait for output. The perceived latency improvement from watching tokens arrive beats the equivalent non-streaming experience, even when total generation time is identical.



Get the next one

I send one short email a week: one production bug, debugged, plus the companion code for each deep-dive. No spam, unsubscribe anytime.

👉 Subscribe (free)

Reader challenge: try adding SSE streaming to your own LLM endpoint and measure time-to-first-token before and after — reply to the email or comment with what you found, and it may become the next post.

Sources

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-07-27 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Saturday, April 18, 2026

Edge AI: Why Processing at the Source Changes Everything

Edge AI: Why Processing at the Source Changes Everything

The first edge AI model I deployed was a defect-detection CNN running on a Jetson Nano mounted above a small injection-moulding press. It inferred in 24 milliseconds. The press cycled every 1.8 seconds. On paper this was trivial. In practice, the model's accuracy dropped from 97% in lab testing to 71% in the factory on its first shift, and I spent the next week learning that everything I thought I knew about "inference latency" was the least important number in the system.

That experience is the reason this post exists. Edge AI is one of the genuinely transformative shifts in how we build intelligent systems, and it is also one of the easiest places to burn three months building something that works on your laptop and falls apart in the field. This post is the version I wish someone had handed me before that Jetson went on the production floor.

Imagine a factory robot that must decide in 5 milliseconds whether to stop a conveyor belt before a defective part causes damage. Or a self-driving car that detects a child running into the street. Or a smartwatch that recognizes an irregular heartbeat. In every one of these scenarios, there's no time to send data to a faraway server, wait for a response, and act on it. The decision has to happen right there, on the device itself.

That's edge AI — and it's quietly becoming one of the most important shifts in how we build intelligent systems.

What Is Edge AI?

Edge AI means running artificial intelligence models directly on the device where data is generated — instead of sending that data to the cloud for processing.

Think about how most AI works today. Your phone's voice assistant records audio, sends it to a server farm, the server transcribes it and runs the AI model, the response travels back to your phone, and then you hear the answer. That round-trip typically takes 300–600 milliseconds. For voice commands, that's fine. For a car detecting an obstacle, it's potentially fatal.

Edge AI flips this model. The AI model lives on the device — the camera, the sensor, the robot arm, the wearable. Data is processed locally. Decisions are made in milliseconds without any network dependency.

The "edge" refers to the network edge: the boundary between local devices and the wider internet. Edge computing (running compute at that boundary) has existed for years, but Edge AI adds intelligence to that local processing.

Why Now? What Changed?

Edge AI isn't a new idea — people have talked about running AI on devices for over a decade. What changed is that it's now actually practical.

Hardware got powerful enough. A modern smartphone has more compute than what NASA used to land the first moon missions. But more importantly, specialized AI chips have proliferated. NVIDIA's Jetson Orin series can run large neural networks on a small board that draws under 60 watts. Google's Coral USB Accelerator costs $59 and adds dedicated AI inference to any Linux device. Apple's Neural Engine in the M-series chips runs models at 38 trillion operations per second.

Models got small enough. Researchers developed techniques like quantization (shrinking model precision from 32-bit to 4-bit), pruning (removing unnecessary neurons), and distillation (training small "student" models to mimic large "teacher" models). A model that required a data center GPU in 2020 can now run on a microcontroller in 2026.

The IoT explosion created the need. There are now over 15 billion connected devices worldwide. Having all of them constantly stream data to cloud servers would cost a fortune and create massive latency. Running AI locally solves both problems.

The Three Killer Advantages of Edge AI

1. Latency: Decisions in Milliseconds, Not Seconds

Cloud AI latency has a hard floor. Even with perfect network conditions, you're looking at 50–200ms minimum for a round-trip to a data center. In practice, it's often 300–600ms or more.

Edge AI latency is measured in single-digit milliseconds — often 1–10ms. That's not just faster; it's a qualitatively different category of response.

This matters everywhere:
- Industrial automation: A defect detection system on a manufacturing line must react faster than the line moves. At 500ms cloud latency, defective parts are already 3 meters downstream before action can be taken.
- Autonomous vehicles: At 60 mph, a car travels 27 meters in one second. Edge inference at 5ms gives 5,400× more reaction time than 300ms cloud AI.
- Healthcare monitoring: A wearable ECG that detects atrial fibrillation locally can alert the wearer within seconds — not minutes after a cloud round-trip.
- AR/VR: Head-mounted displays need sub-20ms response to avoid motion sickness. Cloud AI makes this impossible.

2. Privacy: Data Never Leaves the Device

Cloud AI means sensitive data travels over networks and gets processed by third-party servers. For many applications, that's unacceptable.

Edge AI keeps data local. A facial recognition system for building access control doesn't need to send employee faces to Amazon or Microsoft. A medical imaging device doesn't need to upload patient scans to a cloud provider. A voice assistant can process "Hey [wake word]" entirely on-device, only activating a network connection when the user actually wants cloud features.

This matters especially in:
- Healthcare: Patient data regulations (HIPAA, GDPR) create strict rules about where health data can flow
- Manufacturing: Companies don't want to send proprietary production data to third-party cloud providers
- Consumer trust: Users increasingly want control over their data — edge AI makes it technically possible to guarantee it never leaves the device

3. Reliability: Works Without the Internet

Cloud AI requires cloud connectivity. Edge AI doesn't.

A smart factory can't afford production shutdowns every time the internet goes out. A drone performing an autonomous mission can't wait for Wi-Fi. An agricultural monitoring system in a remote field may have no connectivity at all.

Edge AI turns network outages from catastrophic failures into minor inconveniences. The device keeps working. Decisions keep getting made. Data can queue locally and sync when connectivity returns.

How Edge AI Actually Works

At its core, edge AI involves three steps: train the model, optimize it for the target device, then deploy and run inference on that device.

Training still happens in the cloud or on powerful servers. You train a neural network on large datasets using GPUs. This doesn't change with edge AI.

Optimization is where edge AI diverges from standard deployment. To run on constrained hardware, models go through:

  • Quantization: Converting weights from float32 (4 bytes per value) to int8 or int4 (1–0.5 bytes per value). This reduces model size by 4–8× with minimal accuracy loss.
  • Pruning: Removing neurons and connections that contribute little to output. A typical neural network has significant redundancy; pruning can reduce size by 50–90% with careful tuning.
  • Knowledge distillation: Training a small, fast model (the "student") to reproduce the outputs of a large, accurate model (the "teacher"). The student runs efficiently on edge hardware; the teacher stays in the lab.
  • Operator fusion: Combining multiple computational operations into single hardware-optimized kernels.

Deployment uses inference runtimes optimized for edge hardware. ONNX Runtime, TensorFlow Lite, and TensorRT convert optimized models into formats that run efficiently on specific chips. A model exported from PyTorch can be converted to TensorRT format and run on an NVIDIA Jetson at full hardware acceleration.

flowchart LR A[PyTorch / TF
Trained Model] --> B[Quantize
fp32 -> int8] B --> C[Prune
remove low-weight neurons] C --> D[Distill
train smaller student] D --> E{Target
hardware?} E -->|NVIDIA| F[TensorRT Engine] E -->|Google Coral| G[Edge TPU compile] E -->|Arm / Apple| H[Core ML / TFLite] E -->|Microcontroller| I[TFLite Micro
C++ bundle] F --> Z[Deploy] G --> Z H --> Z I --> Z style A fill:#1e293b,color:#f8fafc style Z fill:#16a34a,color:#fff

Here's what a minimal quantization-and-export pipeline looks like in practice. This takes a PyTorch image classifier, applies dynamic INT8 quantization, and exports it to ONNX so it can be loaded by ONNX Runtime on a Jetson, a Pi, or a laptop:

import torch
from torchvision.models import mobilenet_v3_small

# Load a pre-trained model and put it in eval mode
model = mobilenet_v3_small(weights="DEFAULT").eval()

# Apply dynamic INT8 quantization to all Linear and Conv2d layers
quantized = torch.quantization.quantize_dynamic(
    model,
    {torch.nn.Linear, torch.nn.Conv2d},
    dtype=torch.qint8,
)

# Dummy input for tracing — match the shape the device will send
example = torch.randn(1, 3, 224, 224)

# Export to ONNX for cross-runtime deployment
torch.onnx.export(
    quantized,
    example,
    "mobilenet_v3_edge.onnx",
    input_names=["image"],
    output_names=["logits"],
    dynamic_axes={"image": {0: "batch"}, "logits": {0: "batch"}},
    opset_version=17,
)

print("Model size:", round(torch.save(quantized, "tmp.pt") or 0, 2))

On a MobileNetV3-Small, that pipeline typically cuts the model from ~10 MB fp32 to ~2.5 MB int8 with a single-digit percentage drop in top-1 accuracy on ImageNet. On a Coral USB Accelerator, the same model runs at sub-10 ms per inference.

TinyML: AI on Microcontrollers

The extreme end of edge AI is TinyML — running machine learning models on microcontrollers with kilobytes of RAM and no operating system.

An Arduino or STM32 microcontroller with 256KB RAM can run a keyword detection model that wakes up when it hears a specific word. The same class of hardware can detect anomalies in vibration patterns (predictive maintenance), recognize gestures from accelerometer data, or classify simple images with ultra-low-power cameras.

TensorFlow Lite for Microcontrollers and Edge Impulse are the main frameworks. They target boards that run on milliwatts — a coin cell battery for months.

This enables AI in places that were previously unthinkable: disposable sensors, implantables, environmental monitors deployed at massive scale.

The Three-Tier Architecture

Real-world edge AI deployments typically use a three-tier architecture:

Tier 1 — Endpoints (Microcontrollers, Sensors): The smallest, cheapest, lowest-power devices. Run simple models for keyword detection, anomaly detection, gesture recognition. RAM measured in KB. Think TinyML on Arduino-class hardware.

Tier 2 — Edge Nodes (Smart Cameras, Gateways, Jetson-class boards): More capable devices that aggregate data from multiple endpoints and run more complex models. Object detection, speech recognition, video analytics. These are the workhorses of industrial and commercial edge AI.

Tier 3 — Edge Servers (On-premise servers, 5G MEC nodes): Full servers deployed near the point of use — in a factory, a hospital, a retail store — rather than in a distant cloud datacenter. Run the same models as cloud AI but with dramatically lower latency.

Data flows up this hierarchy, with each tier handling what it can locally and forwarding the rest upward.

flowchart TB subgraph T1["Tier 1 — Endpoints"] A1[Microcontroller
Arduino / STM32
KB of RAM] A2[Sensor Node
Coin-cell powered] end subgraph T2["Tier 2 — Edge Nodes"] B1[Smart Camera
Jetson Orin Nano] B2[Industrial Gateway
Raspberry Pi + Coral] end subgraph T3["Tier 3 — Edge Servers"] C1[On-prem Server
GPU-backed] C2[5G MEC Node] end A1 -->|events only| B1 A2 -->|summaries| B2 B1 -->|aggregated data| C1 B2 -->|aggregated data| C2 C1 -->|rare sync| Cloud[Cloud
Training, retraining,
long-term storage] C2 -->|rare sync| Cloud style T1 fill:#0f172a,stroke:#fb923c,color:#f8fafc style T2 fill:#0f172a,stroke:#60a5fa,color:#f8fafc style T3 fill:#0f172a,stroke:#34d399,color:#f8fafc style Cloud fill:#1e293b,color:#f8fafc

Real-World Applications Right Now

Edge AI isn't theoretical. It's already operating at scale:

Smart Manufacturing: Vision systems on assembly lines detect defects in real time. Predictive maintenance sensors on motors detect bearing wear before failure. Quality control AI on packaging lines ensures 100% inspection at production speed.

Retail: Smart shelves use computer vision to detect out-of-stock items. Checkout-free stores (Amazon Go style) track customer selections using on-device AI across dozens of cameras.

Healthcare: Continuous glucose monitors use edge AI to predict hypoglycemic events. Wearable ECGs detect arrhythmias. Hospital cameras monitor patient falls without sending footage to external servers.

Agriculture: Autonomous tractors navigate fields using on-board computer vision. Drone-based crop monitoring processes imagery in flight. Irrigation controllers analyze soil sensor data locally.

Consumer Devices: Your phone's camera uses neural networks running entirely on-device for portrait mode, night mode, and real-time video stabilization. Your earbuds do noise cancellation with custom AI chips. Your smartwatch detects sleep stages.

The Challenges Worth Knowing

Edge AI isn't all upside. The constraints are real:

Limited compute: Edge devices have significantly less processing power than cloud servers. Complex models must be aggressively compressed, which can hurt accuracy.

Memory constraints: Even "capable" edge devices like the Jetson Orin have 16–64GB RAM. That sounds like a lot until you're running multiple models simultaneously for a multi-camera system.

Update complexity: Updating models on thousands of deployed edge devices is operationally harder than updating a cloud service. Over-the-air update mechanisms must be robust.

Heterogeneous hardware: Edge hardware is fragmented — NVIDIA GPUs, Google TPUs, Arm Cortex chips, Apple Neural Engine. Each has different optimization requirements. A model optimized for one may perform poorly on another.

Development complexity: Edge AI development requires more hardware-level knowledge than cloud AI. You're dealing with device drivers, inference runtime configuration, and power budgets — not just Python and a GPU.

Getting Started: What You Need to Know

If you want to explore edge AI, here's the practical entry point:

flowchart TD Start["I want to build
an edge AI project"] --> Q1{What's the task?} Q1 -->|Keyword / sound / gesture| TinyML[Arduino Nano 33 BLE Sense
+ TFLite Micro
or Edge Impulse] Q1 -->|Computer vision| Q2{How much
compute budget?} Q1 -->|Large-model inference
LLM-ish| Server[On-prem edge server
+ GPU / NPU] Q2 -->|$100 or less| Pi[Raspberry Pi 5
+ Coral USB Accelerator] Q2 -->|$250-500| Jetson[NVIDIA Jetson Orin Nano] Q2 -->|$500+| JetsonPro[Jetson Orin NX
or AGX] TinyML --> Ship[Prototype
in a weekend] Pi --> Ship2[Prototype
in a weekend] Jetson --> Ship3[CV production
workloads] JetsonPro --> Ship4[Multi-camera
real-time] Server --> Ship5[Edge LLM /
complex models] style Start fill:#1e293b,stroke:#fb923c,color:#f8fafc style Ship fill:#16a34a,color:#fff style Ship2 fill:#16a34a,color:#fff style Ship3 fill:#16a34a,color:#fff style Ship4 fill:#16a34a,color:#fff style Ship5 fill:#16a34a,color:#fff

  1. Pick a target hardware platform: Raspberry Pi 5 with a Coral USB Accelerator is a great beginner setup. NVIDIA Jetson Orin Nano ($249) is excellent for computer vision. Arduino Nano 33 BLE Sense is the TinyML starting point.

  2. Choose a framework: TensorFlow Lite and its Micro variant for broad hardware support. ONNX Runtime for cross-framework flexibility. PyTorch Mobile if you're already in the PyTorch ecosystem.

  3. Start with a pre-trained model: Don't train from scratch. MobileNetV3 for image classification, YOLOv8 Nano for object detection, Whisper Tiny for speech recognition. These are designed for edge deployment.

  4. Use Edge Impulse (free for individuals): It handles the full workflow from data collection through model training, optimization, and deployment to edge hardware. Best learning environment for edge AI.

  5. Deploy and measure: Profile your model's inference latency, power consumption, and accuracy on real hardware. Optimize iteratively.

The Gotchas I Wish I'd Known Before Shipping

The reason my first Jetson deployment dropped from 97% lab accuracy to 71% on the factory floor came down to four things, and I'd bet most first-time edge AI teams hit at least two of them.

Lighting was different. My training data was captured under the factory's daylight coming through skylights. Shipping meant running at night under yellowish sodium-vapour lights. The CNN had learned colour cues that no longer matched reality. The fix was collecting a second training set under the night lighting, doing domain adaptation fine-tuning, and normalizing the colour channel before inference. Lesson: always capture training data under every lighting regime the model will encounter, not just the convenient one.

Camera framerate drifted under thermal load. After 30 minutes of continuous operation, the Jetson got warm enough that the CSI camera's auto-exposure started reacting sluggishly, and frames arrived at 22 fps instead of 30 fps. Because I'd pipelined inference assuming a steady 30 fps, I was now processing stale frames. The fix was moving inference to a ring-buffer consumer thread that processes "latest frame" rather than "next frame," and adding a heatsink to the device. Lesson: thermal behaviour at hour 5 is not the same as minute 5; soak-test everything for at least 24 hours on real hardware.

The quantized model made different mistakes than the fp32 model. Post-training INT8 quantization is lossy in ways that are not uniformly distributed across the input space. My quantized model lost accuracy specifically on dark-coloured defects because the int8 dynamic range had less resolution in the low end. The fix was calibration-based quantization using a representative dataset with balanced classes, plus per-channel quantization for the convolution layers. Lesson: never benchmark your edge model using the fp32 validation set; always re-validate the quantized model end-to-end.

Deployment was harder than the training. The actual hardest part of the project wasn't building the model — it was setting up the over-the-air update mechanism, signed firmware boot, remote log collection, and rollback strategy for when something on the device broke at 3 AM. Budget 60% of your project time for MLOps and deployment engineering if you're going to production. A model that works great on a benchtop but cannot be safely updated at scale is not a product.

What's Next

Edge AI is moving fast. The next few years will bring:

  • More capable edge hardware: Next-gen NPUs (Neural Processing Units) will close the gap with data center chips further
  • Better compression techniques: LLM quantization research is already enabling GPT-class models to run on phones
  • Federated learning: Training models across thousands of edge devices without centralizing data — solving the privacy problem while improving model quality
  • AI standardization: ONNX and similar formats are converging toward true write-once-deploy-anywhere portability

The direction is clear: intelligence is moving to where the data is generated. The cloud will remain important for training and complex reasoning, but the front line of AI — the moment of action — will increasingly run at the edge.


Conclusion

Edge AI is the answer to a fundamental constraint: physics. Data takes time to travel. Networks fail. Privacy matters. And sometimes, milliseconds are the difference between a working system and a catastrophic failure.

The shift to edge processing isn't just a technical optimization — it's an architectural rethinking of where intelligence lives. As hardware gets cheaper and models get smaller, AI will proliferate into devices that were never considered "smart" before.

If you're building anything that interacts with the physical world — industrial systems, consumer devices, autonomous machines, healthcare tech — edge AI isn't optional reading. It's the foundation of where this field is going.

Ready to go deeper? Watch the companion video Edge AI: Run AI on Anything for a visual walkthrough of the hardware landscape, TinyML demos, and real deployment examples.


Part of the AmtocSoft Emerging Tech series. Follow for weekly deep dives into AI infrastructure, hardware, and developer tools.

Sources

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-04-18 · Written with AI assistance, reviewed by Toc Am.

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Friday, April 17, 2026

Edge AI: Why Processing at the Source Changes Everything

The first edge AI model I deployed was a defect-detection CNN running on a Jetson Nano mounted above a small injection-moulding press. It inferred in 24 milliseconds. The press cycled every 1.8 seconds. On paper this was trivial. In practice, the model's accuracy dropped from 97% in lab testing to 71% in the factory on its first shift, and I spent the next week learning that everything I thought I knew about "inference latency" was the least important number in the system.

That experience is the reason this post exists. Edge AI is one of the genuinely transformative shifts in how we build intelligent systems, and it is also one of the easiest places to burn three months building something that works on your laptop and falls apart in the field. This post is the version I wish someone had handed me before that Jetson went on the production floor.

Imagine a factory robot that must decide in 5 milliseconds whether to stop a conveyor belt before a defective part causes damage. Or a self-driving car that detects a child running into the street. Or a smartwatch that recognizes an irregular heartbeat. In every one of these scenarios, there's no time to send data to a faraway server, wait for a response, and act on it. The decision has to happen right there, on the device itself.

That's edge AI — and it's quietly becoming one of the most important shifts in how we build intelligent systems.

What Is Edge AI?

Edge AI means running artificial intelligence models directly on the device where data is generated — instead of sending that data to the cloud for processing.

Think about how most AI works today. Your phone's voice assistant records audio, sends it to a server farm, the server transcribes it and runs the AI model, the response travels back to your phone, and then you hear the answer. That round-trip typically takes 300–600 milliseconds. For voice commands, that's fine. For a car detecting an obstacle, it's potentially fatal.

Edge AI flips this model. The AI model lives on the device — the camera, the sensor, the robot arm, the wearable. Data is processed locally. Decisions are made in milliseconds without any network dependency.

The "edge" refers to the network edge: the boundary between local devices and the wider internet. Edge computing (running compute at that boundary) has existed for years, but Edge AI adds intelligence to that local processing.

Why Now? What Changed?

Edge AI isn't a new idea — people have talked about running AI on devices for over a decade. What changed is that it's now actually practical.

Hardware got powerful enough. A modern smartphone has more compute than what NASA used to land the first moon missions. But more importantly, specialized AI chips have proliferated. NVIDIA's Jetson Orin series can run large neural networks on a small board that draws under 60 watts. Google's Coral USB Accelerator costs $59 and adds dedicated AI inference to any Linux device. Apple's Neural Engine in the M-series chips runs models at 38 trillion operations per second.

Models got small enough. Researchers developed techniques like quantization (shrinking model precision from 32-bit to 4-bit), pruning (removing unnecessary neurons), and distillation (training small "student" models to mimic large "teacher" models). A model that required a data center GPU in 2020 can now run on a microcontroller in 2026.

The IoT explosion created the need. There are now over 15 billion connected devices worldwide. Having all of them constantly stream data to cloud servers would cost a fortune and create massive latency. Running AI locally solves both problems.

The Three Killer Advantages of Edge AI

1. Latency: Decisions in Milliseconds, Not Seconds

Cloud AI latency has a hard floor. Even with perfect network conditions, you're looking at 50–200ms minimum for a round-trip to a data center. In practice, it's often 300–600ms or more.

Edge AI latency is measured in single-digit milliseconds — often 1–10ms. That's not just faster; it's a qualitatively different category of response.

This matters everywhere:
- Industrial automation: A defect detection system on a manufacturing line must react faster than the line moves. At 500ms cloud latency, defective parts are already 3 meters downstream before action can be taken.
- Autonomous vehicles: At 60 mph, a car travels 27 meters in one second. Edge inference at 5ms gives 5,400× more reaction time than 300ms cloud AI.
- Healthcare monitoring: A wearable ECG that detects atrial fibrillation locally can alert the wearer within seconds — not minutes after a cloud round-trip.
- AR/VR: Head-mounted displays need sub-20ms response to avoid motion sickness. Cloud AI makes this impossible.

2. Privacy: Data Never Leaves the Device

Cloud AI means sensitive data travels over networks and gets processed by third-party servers. For many applications, that's unacceptable.

Edge AI keeps data local. A facial recognition system for building access control doesn't need to send employee faces to Amazon or Microsoft. A medical imaging device doesn't need to upload patient scans to a cloud provider. A voice assistant can process "Hey [wake word]" entirely on-device, only activating a network connection when the user actually wants cloud features.

This matters especially in:
- Healthcare: Patient data regulations (HIPAA, GDPR) create strict rules about where health data can flow
- Manufacturing: Companies don't want to send proprietary production data to third-party cloud providers
- Consumer trust: Users increasingly want control over their data — edge AI makes it technically possible to guarantee it never leaves the device

3. Reliability: Works Without the Internet

Cloud AI requires cloud connectivity. Edge AI doesn't.

A smart factory can't afford production shutdowns every time the internet goes out. A drone performing an autonomous mission can't wait for Wi-Fi. An agricultural monitoring system in a remote field may have no connectivity at all.

Edge AI turns network outages from catastrophic failures into minor inconveniences. The device keeps working. Decisions keep getting made. Data can queue locally and sync when connectivity returns.

How Edge AI Actually Works

At its core, edge AI involves three steps: train the model, optimize it for the target device, then deploy and run inference on that device.

Training still happens in the cloud or on powerful servers. You train a neural network on large datasets using GPUs. This doesn't change with edge AI.

Optimization is where edge AI diverges from standard deployment. To run on constrained hardware, models go through:

  • Quantization: Converting weights from float32 (4 bytes per value) to int8 or int4 (1–0.5 bytes per value). This reduces model size by 4–8× with minimal accuracy loss.
  • Pruning: Removing neurons and connections that contribute little to output. A typical neural network has significant redundancy; pruning can reduce size by 50–90% with careful tuning.
  • Knowledge distillation: Training a small, fast model (the "student") to reproduce the outputs of a large, accurate model (the "teacher"). The student runs efficiently on edge hardware; the teacher stays in the lab.
  • Operator fusion: Combining multiple computational operations into single hardware-optimized kernels.

Deployment uses inference runtimes optimized for edge hardware. ONNX Runtime, TensorFlow Lite, and TensorRT convert optimized models into formats that run efficiently on specific chips. A model exported from PyTorch can be converted to TensorRT format and run on an NVIDIA Jetson at full hardware acceleration.

flowchart LR A[PyTorch / TF
Trained Model] --> B[Quantize
fp32 -> int8] B --> C[Prune
remove low-weight neurons] C --> D[Distill
train smaller student] D --> E{Target
hardware?} E -->|NVIDIA| F[TensorRT Engine] E -->|Google Coral| G[Edge TPU compile] E -->|Arm / Apple| H[Core ML / TFLite] E -->|Microcontroller| I[TFLite Micro
C++ bundle] F --> Z[Deploy] G --> Z H --> Z I --> Z style A fill:#1e293b,color:#f8fafc style Z fill:#16a34a,color:#fff

Here's what a minimal quantization-and-export pipeline looks like in practice. This takes a PyTorch image classifier, applies dynamic INT8 quantization, and exports it to ONNX so it can be loaded by ONNX Runtime on a Jetson, a Pi, or a laptop:

import torch
from torchvision.models import mobilenet_v3_small

# Load a pre-trained model and put it in eval mode
model = mobilenet_v3_small(weights="DEFAULT").eval()

# Apply dynamic INT8 quantization to all Linear and Conv2d layers
quantized = torch.quantization.quantize_dynamic(
    model,
    {torch.nn.Linear, torch.nn.Conv2d},
    dtype=torch.qint8,
)

# Dummy input for tracing — match the shape the device will send
example = torch.randn(1, 3, 224, 224)

# Export to ONNX for cross-runtime deployment
torch.onnx.export(
    quantized,
    example,
    "mobilenet_v3_edge.onnx",
    input_names=["image"],
    output_names=["logits"],
    dynamic_axes={"image": {0: "batch"}, "logits": {0: "batch"}},
    opset_version=17,
)

print("Model size:", round(torch.save(quantized, "tmp.pt") or 0, 2))

On a MobileNetV3-Small, that pipeline typically cuts the model from ~10 MB fp32 to ~2.5 MB int8 with a single-digit percentage drop in top-1 accuracy on ImageNet. On a Coral USB Accelerator, the same model runs at sub-10 ms per inference.

TinyML: AI on Microcontrollers

The extreme end of edge AI is TinyML — running machine learning models on microcontrollers with kilobytes of RAM and no operating system.

An Arduino or STM32 microcontroller with 256KB RAM can run a keyword detection model that wakes up when it hears a specific word. The same class of hardware can detect anomalies in vibration patterns (predictive maintenance), recognize gestures from accelerometer data, or classify simple images with ultra-low-power cameras.

TensorFlow Lite for Microcontrollers and Edge Impulse are the main frameworks. They target boards that run on milliwatts — a coin cell battery for months.

This enables AI in places that were previously unthinkable: disposable sensors, implantables, environmental monitors deployed at massive scale.

The Three-Tier Architecture

Real-world edge AI deployments typically use a three-tier architecture:

Tier 1 — Endpoints (Microcontrollers, Sensors): The smallest, cheapest, lowest-power devices. Run simple models for keyword detection, anomaly detection, gesture recognition. RAM measured in KB. Think TinyML on Arduino-class hardware.

Tier 2 — Edge Nodes (Smart Cameras, Gateways, Jetson-class boards): More capable devices that aggregate data from multiple endpoints and run more complex models. Object detection, speech recognition, video analytics. These are the workhorses of industrial and commercial edge AI.

Tier 3 — Edge Servers (On-premise servers, 5G MEC nodes): Full servers deployed near the point of use — in a factory, a hospital, a retail store — rather than in a distant cloud datacenter. Run the same models as cloud AI but with dramatically lower latency.

Data flows up this hierarchy, with each tier handling what it can locally and forwarding the rest upward.

flowchart TB subgraph T1["Tier 1 — Endpoints"] A1[Microcontroller
Arduino / STM32
KB of RAM] A2[Sensor Node
Coin-cell powered] end subgraph T2["Tier 2 — Edge Nodes"] B1[Smart Camera
Jetson Orin Nano] B2[Industrial Gateway
Raspberry Pi + Coral] end subgraph T3["Tier 3 — Edge Servers"] C1[On-prem Server
GPU-backed] C2[5G MEC Node] end A1 -->|events only| B1 A2 -->|summaries| B2 B1 -->|aggregated data| C1 B2 -->|aggregated data| C2 C1 -->|rare sync| Cloud[Cloud
Training, retraining,
long-term storage] C2 -->|rare sync| Cloud style T1 fill:#0f172a,stroke:#fb923c,color:#f8fafc style T2 fill:#0f172a,stroke:#60a5fa,color:#f8fafc style T3 fill:#0f172a,stroke:#34d399,color:#f8fafc style Cloud fill:#1e293b,color:#f8fafc

Real-World Applications Right Now

Edge AI isn't theoretical. It's already operating at scale:

Smart Manufacturing: Vision systems on assembly lines detect defects in real time. Predictive maintenance sensors on motors detect bearing wear before failure. Quality control AI on packaging lines ensures 100% inspection at production speed.

Retail: Smart shelves use computer vision to detect out-of-stock items. Checkout-free stores (Amazon Go style) track customer selections using on-device AI across dozens of cameras.

Healthcare: Continuous glucose monitors use edge AI to predict hypoglycemic events. Wearable ECGs detect arrhythmias. Hospital cameras monitor patient falls without sending footage to external servers.

Agriculture: Autonomous tractors navigate fields using on-board computer vision. Drone-based crop monitoring processes imagery in flight. Irrigation controllers analyze soil sensor data locally.

Consumer Devices: Your phone's camera uses neural networks running entirely on-device for portrait mode, night mode, and real-time video stabilization. Your earbuds do noise cancellation with custom AI chips. Your smartwatch detects sleep stages.

The Challenges Worth Knowing

Edge AI isn't all upside. The constraints are real:

Limited compute: Edge devices have significantly less processing power than cloud servers. Complex models must be aggressively compressed, which can hurt accuracy.

Memory constraints: Even "capable" edge devices like the Jetson Orin have 16–64GB RAM. That sounds like a lot until you're running multiple models simultaneously for a multi-camera system.

Update complexity: Updating models on thousands of deployed edge devices is operationally harder than updating a cloud service. Over-the-air update mechanisms must be robust.

Heterogeneous hardware: Edge hardware is fragmented — NVIDIA GPUs, Google TPUs, Arm Cortex chips, Apple Neural Engine. Each has different optimization requirements. A model optimized for one may perform poorly on another.

Development complexity: Edge AI development requires more hardware-level knowledge than cloud AI. You're dealing with device drivers, inference runtime configuration, and power budgets — not just Python and a GPU.

Getting Started: What You Need to Know

If you want to explore edge AI, here's the practical entry point:

flowchart TD Start["I want to build
an edge AI project"] --> Q1{What's the task?} Q1 -->|Keyword / sound / gesture| TinyML[Arduino Nano 33 BLE Sense
+ TFLite Micro
or Edge Impulse] Q1 -->|Computer vision| Q2{How much
compute budget?} Q1 -->|Large-model inference
LLM-ish| Server[On-prem edge server
+ GPU / NPU] Q2 -->|$100 or less| Pi[Raspberry Pi 5
+ Coral USB Accelerator] Q2 -->|$250-500| Jetson[NVIDIA Jetson Orin Nano] Q2 -->|$500+| JetsonPro[Jetson Orin NX
or AGX] TinyML --> Ship[Prototype
in a weekend] Pi --> Ship2[Prototype
in a weekend] Jetson --> Ship3[CV production
workloads] JetsonPro --> Ship4[Multi-camera
real-time] Server --> Ship5[Edge LLM /
complex models] style Start fill:#1e293b,stroke:#fb923c,color:#f8fafc style Ship fill:#16a34a,color:#fff style Ship2 fill:#16a34a,color:#fff style Ship3 fill:#16a34a,color:#fff style Ship4 fill:#16a34a,color:#fff style Ship5 fill:#16a34a,color:#fff
  1. Pick a target hardware platform: Raspberry Pi 5 with a Coral USB Accelerator is a great beginner setup. NVIDIA Jetson Orin Nano ($249) is excellent for computer vision. Arduino Nano 33 BLE Sense is the TinyML starting point.

  2. Choose a framework: TensorFlow Lite and its Micro variant for broad hardware support. ONNX Runtime for cross-framework flexibility. PyTorch Mobile if you're already in the PyTorch ecosystem.

  3. Start with a pre-trained model: Don't train from scratch. MobileNetV3 for image classification, YOLOv8 Nano for object detection, Whisper Tiny for speech recognition. These are designed for edge deployment.

  4. Use Edge Impulse (free for individuals): It handles the full workflow from data collection through model training, optimization, and deployment to edge hardware. Best learning environment for edge AI.

  5. Deploy and measure: Profile your model's inference latency, power consumption, and accuracy on real hardware. Optimize iteratively.

The Gotchas I Wish I'd Known Before Shipping

The reason my first Jetson deployment dropped from 97% lab accuracy to 71% on the factory floor came down to four things, and I'd bet most first-time edge AI teams hit at least two of them.

Lighting was different. My training data was captured under the factory's daylight coming through skylights. Shipping meant running at night under yellowish sodium-vapour lights. The CNN had learned colour cues that no longer matched reality. The fix was collecting a second training set under the night lighting, doing domain adaptation fine-tuning, and normalizing the colour channel before inference. Lesson: always capture training data under every lighting regime the model will encounter, not just the convenient one.

Camera framerate drifted under thermal load. After 30 minutes of continuous operation, the Jetson got warm enough that the CSI camera's auto-exposure started reacting sluggishly, and frames arrived at 22 fps instead of 30 fps. Because I'd pipelined inference assuming a steady 30 fps, I was now processing stale frames. The fix was moving inference to a ring-buffer consumer thread that processes "latest frame" rather than "next frame," and adding a heatsink to the device. Lesson: thermal behaviour at hour 5 is not the same as minute 5; soak-test everything for at least 24 hours on real hardware.

The quantized model made different mistakes than the fp32 model. Post-training INT8 quantization is lossy in ways that are not uniformly distributed across the input space. My quantized model lost accuracy specifically on dark-coloured defects because the int8 dynamic range had less resolution in the low end. The fix was calibration-based quantization using a representative dataset with balanced classes, plus per-channel quantization for the convolution layers. Lesson: never benchmark your edge model using the fp32 validation set; always re-validate the quantized model end-to-end.

Deployment was harder than the training. The actual hardest part of the project wasn't building the model — it was setting up the over-the-air update mechanism, signed firmware boot, remote log collection, and rollback strategy for when something on the device broke at 3 AM. Budget 60% of your project time for MLOps and deployment engineering if you're going to production. A model that works great on a benchtop but cannot be safely updated at scale is not a product.

What's Next

Edge AI is moving fast. The next few years will bring:

  • More capable edge hardware: Next-gen NPUs (Neural Processing Units) will close the gap with data center chips further
  • Better compression techniques: LLM quantization research is already enabling GPT-class models to run on phones
  • Federated learning: Training models across thousands of edge devices without centralizing data — solving the privacy problem while improving model quality
  • AI standardization: ONNX and similar formats are converging toward true write-once-deploy-anywhere portability

The direction is clear: intelligence is moving to where the data is generated. The cloud will remain important for training and complex reasoning, but the front line of AI — the moment of action — will increasingly run at the edge.


Conclusion

Edge AI is the answer to a fundamental constraint: physics. Data takes time to travel. Networks fail. Privacy matters. And sometimes, milliseconds are the difference between a working system and a catastrophic failure.

The shift to edge processing isn't just a technical optimization — it's an architectural rethinking of where intelligence lives. As hardware gets cheaper and models get smaller, AI will proliferate into devices that were never considered "smart" before.

If you're building anything that interacts with the physical world — industrial systems, consumer devices, autonomous machines, healthcare tech — edge AI isn't optional reading. It's the foundation of where this field is going.

Ready to go deeper? Watch the companion video Edge AI: Run AI on Anything for a visual walkthrough of the hardware landscape, TinyML demos, and real deployment examples.


Part of the AmtocSoft Emerging Tech series. Follow for weekly deep dives into AI infrastructure, hardware, and developer tools.

Sources

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-04-18 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

WebSockets and Real-Time Architecture in 2026: SSE, WebRTC, and Scaling Stateful Connections

Hero image

Introduction

The web was designed for request-response. A client asks, a server answers, the connection closes. That model works for loading pages, submitting forms, and fetching data on demand. It falls apart the moment you need the server to push something to the client without being asked — a new chat message arriving, a collaborator's cursor moving, a stock price updating, a live game state changing.

In 2026, real-time is no longer a niche feature. Chat is table stakes. Collaborative editing is expected — Google Docs set that bar a decade ago and every modern SaaS has internalized it. Live dashboards are standard in observability tools, trading platforms, and operational software. Multiplayer experiences, from document editors to CAD tools to coding environments, have moved from differentiators to requirements. Presence indicators — knowing who else is in the document, who is typing, who is online — are woven into every serious collaborative product.

The technical challenge is that none of this fits HTTP's request-response model natively. Three transport protocols have emerged to solve it, each with different tradeoffs: WebSockets, Server-Sent Events (SSE), and WebRTC. Choosing the wrong one creates architectural debt that is painful to unwind. Using WebSockets everywhere is as much a mistake as never using them.

WebSockets give you a full-duplex persistent connection — both sides can send at any time. SSE gives you a one-way stream from server to client over plain HTTP, with built-in reconnection and event replay. WebRTC gives you peer-to-peer connections for media and data, bypassing your servers entirely for the data path. Each occupies a different position in the design space.

The practical decision comes down to directionality, frequency, latency requirements, and infrastructure complexity. A live notification feed doesn't need WebSockets — SSE is simpler, more reliable, and scales better. A video call doesn't belong on WebSockets — WebRTC is the right tool. A multiplayer game or collaborative editor genuinely needs WebSockets or a higher-level abstraction like CRDTs on top of them.

This post covers each transport in depth, with complete working code, and then addresses the hardest production problem: scaling stateful connections horizontally across multiple server instances.


1. WebSockets: Full-Duplex Persistent Connections

WebSockets are the most versatile of the three transport options, and consequently the most overused. Understanding the protocol mechanics first makes it easier to know when to reach for it and when to leave it on the shelf.

The Handshake: HTTP Upgrade

A WebSocket connection starts as a plain HTTP request. The client sends an Upgrade header signaling that it wants to switch protocols:

GET /chat HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

If the server agrees, it responds with 101 Switching Protocols. From that point on, the TCP connection is handed over to the WebSocket protocol — both sides can send frames independently at any time. There is no polling, no long-hanging request, no re-establishing a connection for each message.

The Sec-WebSocket-Key mechanism is a base64-encoded 16-byte nonce. The server concatenates it with a fixed GUID, SHA-1 hashes the result, and base64-encodes it back. This prevents a misconfigured HTTP cache from treating WebSocket frames as HTTP responses. It is not security — it is a protocol handshake validity check.

Frame Types

The WebSocket frame format is lean. Each frame has a 2-byte minimum header containing:
- FIN bit: whether this is the final frame in a message (messages can be fragmented)
- Opcode: what kind of frame this is
- Masking bit: client→server frames must be masked (server→client must not be)
- Payload length

The opcodes you care about in practice:
- 0x1 — text frame (UTF-8 payload)
- 0x2 — binary frame (arbitrary bytes)
- 0x8 — close frame (with optional status code and reason)
- 0x9 — ping frame (keepalive, expects pong)
- 0xA — pong frame (response to ping)

For most application-level messaging you'll use text frames with JSON payloads. For high-throughput binary protocols — game state sync, sensor streams, audio chunks — binary frames with MessagePack or Protocol Buffers reduce payload size substantially compared to JSON.

Node.js WebSocket Server: Rooms and Broadcast

The ws library is the standard low-level WebSocket implementation for Node.js. Here is a complete server with room-based broadcasting — the pattern you need for chat, presence, and any multi-tenant real-time feature:

import { WebSocketServer, WebSocket } from "ws";
import { createServer } from "http";
import { parse } from "url";

interface Client {
  ws: WebSocket;
  userId: string;
  room: string;
}

// Map from roomId → Set of connected clients in that room
const rooms = new Map<string, Set<Client>>();

const server = createServer();
const wss = new WebSocketServer({ server });

wss.on("connection", (ws: WebSocket, req) => {
  const { query } = parse(req.url ?? "", true);
  const userId = String(query.userId ?? "anonymous");
  const room = String(query.room ?? "default");

  // Validate JWT or session token here before proceeding
  // If auth fails: ws.close(4001, "Unauthorized"); return;

  const client: Client = { ws, userId, room };

  // Add client to room
  if (!rooms.has(room)) rooms.set(room, new Set());
  rooms.get(room)!.add(client);

  console.log(`[${room}] ${userId} connected. Room size: ${rooms.get(room)!.size}`);

  // Notify others in the room of the new presence
  broadcastToRoom(room, { type: "presence", userId, event: "joined" }, client);

  // Heartbeat: detect dead connections that didn't send a close frame
  // (common with mobile networks, NAT timeouts, browser tab crashes)
  let isAlive = true;
  ws.on("pong", () => { isAlive = true; });

  const heartbeatInterval = setInterval(() => {
    if (!isAlive) {
      // No pong received — connection is dead, terminate it
      console.warn(`[${room}] ${userId} heartbeat timeout, terminating`);
      ws.terminate();
      return;
    }
    isAlive = false;
    ws.ping(); // Send ping, expect pong back within next interval
  }, 30_000); // 30-second heartbeat interval

  ws.on("message", (data: Buffer) => {
    let message: Record<string, unknown>;
    try {
      message = JSON.parse(data.toString());
    } catch {
      ws.send(JSON.stringify({ error: "invalid JSON" }));
      return;
    }

    // Route by message type
    switch (message.type) {
      case "chat":
        broadcastToRoom(room, {
          type: "chat",
          userId,
          text: message.text,
          ts: Date.now(),
        });
        break;

      case "ping":
        // Application-level ping (distinct from WebSocket protocol ping)
        ws.send(JSON.stringify({ type: "pong", ts: Date.now() }));
        break;

      default:
        ws.send(JSON.stringify({ error: "unknown message type" }));
    }
  });

  ws.on("close", () => {
    clearInterval(heartbeatInterval);
    rooms.get(room)?.delete(client);
    if (rooms.get(room)?.size === 0) rooms.delete(room);
    broadcastToRoom(room, { type: "presence", userId, event: "left" });
    console.log(`[${room}] ${userId} disconnected`);
  });

  ws.on("error", (err) => {
    console.error(`[${room}] ${userId} error:`, err.message);
    clearInterval(heartbeatInterval);
    rooms.get(room)?.delete(client);
  });

  // Send initial room state to the newly connected client
  ws.send(JSON.stringify({
    type: "init",
    room,
    members: [...(rooms.get(room) ?? [])].map(c => c.userId),
  }));
});

function broadcastToRoom(
  room: string,
  message: Record<string, unknown>,
  exclude?: Client
): void {
  const clients = rooms.get(room);
  if (!clients) return;
  const payload = JSON.stringify(message);
  for (const client of clients) {
    // Skip the sender if excluded, and skip any connection not in OPEN state
    if (client === exclude) continue;
    if (client.ws.readyState === WebSocket.OPEN) {
      client.ws.send(payload);
    }
  }
}

server.listen(8080, () => console.log("WebSocket server on :8080"));

Client Reconnection with Exponential Backoff

Connections drop. Mobile networks switch, laptops sleep, browsers navigate. A production client must reconnect automatically:

class ReconnectingWebSocket {
  private ws: WebSocket | null = null;
  private attempt = 0;
  private readonly maxDelay = 30_000; // cap at 30 seconds
  private readonly baseDelay = 500;   // start at 500ms

  constructor(
    private readonly url: string,
    private readonly onMessage: (data: unknown) => void
  ) {
    this.connect();
  }

  private connect(): void {
    this.ws = new WebSocket(this.url);

    this.ws.onopen = () => {
      console.log("Connected");
      this.attempt = 0; // reset backoff on successful connect
    };

    this.ws.onmessage = (event) => {
      try {
        this.onMessage(JSON.parse(event.data));
      } catch {
        console.warn("Non-JSON message received:", event.data);
      }
    };

    this.ws.onclose = () => {
      const delay = Math.min(
        this.baseDelay * Math.pow(2, this.attempt) + Math.random() * 500,
        this.maxDelay
      );
      this.attempt++;
      console.log(`Reconnecting in ${Math.round(delay)}ms (attempt ${this.attempt})`);
      setTimeout(() => this.connect(), delay);
    };

    this.ws.onerror = () => {
      // onclose fires after onerror — let it handle reconnection
      this.ws?.close();
    };
  }

  send(data: unknown): void {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(data));
    }
  }
}

The jitter (Math.random() * 500) is critical when you have many clients reconnecting simultaneously after a server restart — without it, the thundering herd hits your server in synchronized waves.

When WebSockets Are Overkill

WebSockets maintain a persistent TCP connection for their entire lifetime. Each connection consumes a file descriptor on the server. With the default ulimit -n on Linux (1024), an untuned server runs out of file descriptors at 1024 simultaneous connections — a completely avoidable problem, but one that illustrates the statefulness cost.

Do not use WebSockets for:
- Low-frequency updates — polling every 30 seconds (stock closing prices, batch job status) costs less than a persistent connection
- One-way data flow — if the client never sends messages back, SSE is simpler and more reliable
- HTTP/2 push scenarios — SSE over HTTP/2 multiplexes multiple streams over one connection at no extra cost

Architecture diagram
sequenceDiagram participant C as Client participant S as WebSocket Server participant R as Room Registry C->>S: GET /chat?room=general&userId=alice (HTTP Upgrade) S->>C: 101 Switching Protocols Note over C,S: TCP connection now WebSocket C->>S: {type: "join", room: "general"} S->>R: Register alice in room "general" R-->>S: Room members: [alice, bob, carol] S->>C: {type: "init", members: ["bob","carol"]} S-->>S: Broadcast {type:"presence", user:"alice", event:"joined"} to bob, carol C->>S: {type: "chat", text: "hello"} S->>R: Lookup "general" members R-->>S: [alice, bob, carol] S->>C: {type: "chat", userId:"alice", text:"hello"} S-->>S: Forward to bob and carol loop Every 30s S->>C: PING (protocol frame) C->>S: PONG (protocol frame) end

2. Server-Sent Events: One-Way Streams

Server-Sent Events are the underused tool in most engineers' real-time toolkit. They solve a specific problem extremely well: the server needs to push a stream of events to the client, but the client does not need to send data back over the same connection.

How SSE Works

SSE uses plain HTTP. The client makes an ordinary GET request, and the server responds with Content-Type: text/event-stream and keeps the connection open, writing newline-delimited events as they occur. There is no new protocol, no handshake, no custom framing — it runs over HTTP/1.1 or HTTP/2 without modification.

The event format is simple text:

id: 42
event: price-update
data: {"symbol":"AAPL","price":213.40,"change":+1.2}

id: 43
event: price-update
data: {"symbol":"GOOG","price":177.85,"change":-0.8}

Each event ends with a blank line. The id field is what makes SSE powerful: when the connection drops and the EventSource reconnects, it sends the Last-Event-ID header with the last event ID it received. Your server can use this to replay missed events from a queue or database. Zero message loss with zero application code — the protocol handles it.

Named events (event: price-update) let a single stream carry multiple event types. The client subscribes selectively:

const source = new EventSource("/api/stream/market");

// Listen to specific named events
source.addEventListener("price-update", (event) => {
  const data = JSON.parse(event.data);
  updatePriceDisplay(data.symbol, data.price);
});

source.addEventListener("trade-executed", (event) => {
  const trade = JSON.parse(event.data);
  appendTradeToLog(trade);
});

// Generic message handler for unnamed events
source.onmessage = (event) => {
  console.log("Generic event:", event.data);
};

source.onerror = (err) => {
  // EventSource reconnects automatically — this fires on each retry
  // source.readyState === EventSource.CONNECTING means it's retrying
  console.warn("SSE error, reconnecting...", source.readyState);
};

No library required. EventSource is built into every browser and has been since 2012.

Python FastAPI SSE Endpoint

from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import asyncio
import json
import time
from typing import AsyncGenerator

app = FastAPI()

# In production, replace with Redis pub/sub or a real event queue
async def market_event_generator(
    request: Request,
    last_event_id: str | None
) -> AsyncGenerator[str, None]:
    """
    Generate SSE-formatted events for market data stream.
    last_event_id allows replay from a specific point.
    """
    event_id = int(last_event_id) + 1 if last_event_id else 1

    # If the client reconnected mid-stream, replay missed events here
    # e.g., fetch events with id > last_event_id from your event store

    while True:
        # Check if the client has disconnected
        if await request.is_disconnected():
            print(f"Client disconnected at event {event_id}")
            break

        # Fetch the next event from your data source
        # Here: simulated market tick
        event_data = {
            "symbol": "AAPL",
            "price": 213.40 + (event_id % 5) * 0.1,
            "ts": time.time(),
        }

        # SSE format: each field on its own line, blank line terminates event
        yield f"id: {event_id}\n"
        yield f"event: price-update\n"
        yield f"data: {json.dumps(event_data)}\n"
        yield "\n"  # blank line = end of event

        event_id += 1
        await asyncio.sleep(1)  # 1-second tick interval


@app.get("/api/stream/market")
async def market_stream(request: Request):
    last_event_id = request.headers.get("Last-Event-ID")

    return StreamingResponse(
        market_event_generator(request, last_event_id),
        media_type="text/event-stream",
        headers={
            # Prevent buffering — critical for SSE to work through proxies
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",  # Disable nginx buffering
            "Connection": "keep-alive",
        },
    )


@app.get("/api/stream/notifications/{user_id}")
async def notification_stream(user_id: str, request: Request):
    """
    Per-user notification stream. In production, subscribe to a Redis
    pub/sub channel keyed by user_id here.
    """
    async def event_generator() -> AsyncGenerator[str, None]:
        # Send a heartbeat comment every 20 seconds to prevent proxy timeout
        # SSE comments start with ':'  — browsers ignore them
        heartbeat_id = 0
        while True:
            if await request.is_disconnected():
                break
            # Heartbeat keeps the connection alive through aggressive proxies
            yield f": heartbeat {heartbeat_id}\n\n"
            heartbeat_id += 1
            await asyncio.sleep(20)

    return StreamingResponse(
        event_generator(),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )

SSE over HTTP/2

Under HTTP/1.1, browsers limit connections per host to six. An SSE connection consumes one of those six slots, which can starve other requests on the same domain. Under HTTP/2, all requests share a single multiplexed connection — SSE becomes just another stream on that connection. If your server supports HTTP/2 (nginx with http2 directive, Caddy by default, Cloudflare always), SSE scales substantially better. You can open dozens of SSE streams per tab without connection pressure.

When SSE Beats WebSocket

Use SSE when:
- The client only consumes data (dashboards, notification feeds, live logs, activity streams)
- You want built-in reconnection and event replay without writing reconnect logic
- You are streaming LLM token output to a browser — every major AI product in 2026 uses SSE for this
- You want to run behind a standard HTTP reverse proxy without WebSocket upgrade configuration
- You need to fan out server events to many read-only consumers

sequenceDiagram participant C as Client (EventSource) participant P as Proxy / CDN participant S as FastAPI Server C->>P: GET /api/stream/market (Accept: text/event-stream) P->>S: Forward request S->>P: 200 OK, Content-Type: text/event-stream P->>C: 200 OK (connection held open) loop Every 1s S->>P: id:1\nevent:price-update\ndata:{...}\n\n P->>C: Forward event chunk C->>C: Fire "price-update" event listener end Note over P,C: Network drop / proxy timeout C->>C: EventSource auto-reconnects after 3s C->>P: GET /api/stream/market\nLast-Event-ID: 47 P->>S: Forward with Last-Event-ID: 47 S->>S: Replay events 48+ from queue S->>P: Resume stream from id:48 P->>C: id:48\nevent:price-update\ndata:{...}\n\n

3. WebRTC: Peer-to-Peer Media

WebRTC is a different category entirely. It is not a transport you use for application data under normal circumstances. It exists for one primary reason: moving audio, video, and arbitrary data between browsers with the lowest possible latency, without routing that data through your servers.

The Use Case

When you make a video call on Google Meet, Zoom, or Discord, the video frames are not going from your browser to a server and back to the other person. They travel directly between the two browsers — or through a media relay if direct connection isn't possible. That direct path eliminates a server hop, cuts latency roughly in half, and means your servers don't pay for the bandwidth of transmitting video frames. For a platform like Discord handling 8M+ concurrent voice connections, the bandwidth savings are enormous.

The Signaling Dance

WebRTC connections require a signaling channel to negotiate the connection. The signaling mechanism is intentionally not specified by the WebRTC standard — you can use WebSockets, SSE, HTTP long-polling, or carrier pigeon. In practice, everyone uses WebSockets.

The negotiation has two parts:

Session Description Protocol (SDP) offer/answer: Peer A creates an offer describing its media capabilities (codecs it supports, bandwidth parameters, data channel intent). It sends this to Peer B via the signaling channel. Peer B responds with an answer. Both sides now know what the connection will carry.

ICE candidates: WebRTC uses the Interactive Connectivity Establishment framework to find the best network path between peers. Each browser generates a list of candidate addresses — local IP, reflexive IP from a STUN server, relayed IP from a TURN server — and exchanges them via the signaling channel. The ICE agent tries each pair to find the one with the lowest latency.

// Simplified WebRTC connection setup (both sides follow this pattern)
const pc = new RTCPeerConnection({
  iceServers: [
    { urls: "stun:stun.l.google.com:19302" }, // Free STUN server for NAT traversal
    {
      // TURN relay — required when STUN fails (symmetric NAT, firewalls)
      // You must run your own or use a paid service (Twilio, Cloudflare Calls)
      urls: "turn:turn.example.com:3478",
      username: "user",
      credential: "pass",
    },
  ],
});

// For data channels (arbitrary P2P data, no media required)
const dataChannel = pc.createDataChannel("game-state", {
  ordered: false,    // UDP-like: drop stale packets rather than wait for retransmit
  maxRetransmits: 0, // For game state: latest frame wins, don't retransmit old ones
});

dataChannel.onmessage = (event) => {
  const state = JSON.parse(event.data);
  applyGameState(state);
};

// Trickle ICE: send candidates as they're discovered, don't wait for all of them
pc.onicecandidate = (event) => {
  if (event.candidate) {
    signalingChannel.send({ type: "ice-candidate", candidate: event.candidate });
  }
};

// Caller side: create and send offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signalingChannel.send({ type: "offer", sdp: offer.sdp });

// Callee side: receive offer, create and send answer
signalingChannel.onmessage = async (msg) => {
  if (msg.type === "offer") {
    await pc.setRemoteDescription(new RTCSessionDescription(msg));
    const answer = await pc.createAnswer();
    await pc.setLocalDescription(answer);
    signalingChannel.send({ type: "answer", sdp: answer.sdp });
  }
  if (msg.type === "ice-candidate") {
    await pc.addIceCandidate(new RTCIceCandidate(msg.candidate));
  }
};

STUN vs TURN

STUN (Session Traversal Utilities for NAT) is a lightweight server that tells a browser its own public IP address. It's how the browser discovers its reflexive candidate. STUN servers are cheap to run and have free public instances. About 80% of WebRTC connections succeed with STUN alone.

TURN (Traversal Using Relays around NAT) is a relay server. For the other 20% — symmetric NATs, enterprise firewalls — direct P2P is impossible. TURN relays all media between the peers through the server. This is bandwidth-intensive: you pay for every byte of video. Cloudflare Calls and Twilio provide TURN as a service. If you run your own, budget for the bandwidth.

Latency Comparison

Transport Typical Latency Notes
WebRTC data channel 30–80 ms P2P, no server hop in media path
WebSocket 80–200 ms Server round-trip included
SSE 100–300 ms One-way, server push
HTTP polling (1s) 0–1000 ms Depends entirely on poll interval

WebRTC's latency advantage only matters when it matters a lot: video calls, real-time gaming, collaborative cursors. For chat, notifications, and dashboards, WebSocket or SSE latency is imperceptible to humans.

Comparison visual
flowchart TD Start([What do you need?]) --> Q1{Does the client
send data back
to the server?} Q1 -->|No| SSE[Use SSE
Simpler, HTTP-native,
auto-reconnect, event replay] Q1 -->|Yes| Q2{Is media involved
video/audio/low-latency
P2P data?} Q2 -->|Yes| WebRTC[Use WebRTC
P2P, lowest latency,
handles NAT traversal] Q2 -->|No| Q3{Update frequency?} Q3 -->|Low
less than 1/min| Poll[HTTP Polling
Simplest, low overhead] Q3 -->|Medium–High
seconds to ms| Q4{Bidirectional
client and server
both initiate?} Q4 -->|Yes| WS[Use WebSocket
Full-duplex, persistent,
rooms + broadcast] Q4 -->|No — server pushes| SSE2[Use SSE
Unidirectional is enough] style SSE fill:#22c55e,color:#fff style SSE2 fill:#22c55e,color:#fff style WebRTC fill:#3b82f6,color:#fff style WS fill:#f59e0b,color:#fff style Poll fill:#94a3b8,color:#fff

4. Scaling Stateful Connections

A single Node.js process can handle approximately 10,000–20,000 concurrent WebSocket connections, depending on message throughput and per-connection memory usage. At that ceiling — or before it for reliability — you need multiple server instances. This is where real-time architecture gets hard.

The Stickiness Problem

HTTP is stateless. A load balancer can route any request to any backend instance because there is no per-instance state that makes one instance the "right" one for a given client. WebSocket connections are the opposite: once connected, a client is bound to a specific server instance for the duration of that connection. Room membership, subscription lists, and in-flight message buffers all live in that instance's memory.

If a client connects to Instance A, joins room "project-42", and a message arrives for "project-42", it must be delivered through Instance A. Instance B and Instance C don't know the client exists.

Sticky Sessions: Works Until It Doesn't

The simplest approach is IP-hash or cookie-based session affinity at the load balancer. nginx:

upstream websocket_backend {
    ip_hash;  # Route the same client IP to the same upstream
    server ws1.internal:8080;
    server ws2.internal:8080;
    server ws3.internal:8080;
}

This works for small deployments. It breaks down when:
- A server instance restarts — all its connections drop and clients reconnect, potentially to different instances
- IPv6 or CGNAT means many users share one IP (corporate networks)
- You need zero-downtime deploys — draining one instance means redistributing thousands of connections

Redis Pub/Sub: The Correct Solution

The production pattern is to move room state out of process memory and into Redis. Every server instance subscribes to channels in Redis. When a message needs to reach all members of room "project-42", it's published to a Redis channel. Every instance picks it up and delivers it to any local clients subscribed to that room.

Socket.io, the higher-level WebSocket abstraction library, has a first-class Redis adapter for exactly this:

import { createServer } from "http";
import { Server } from "socket.io";
import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis";

const httpServer = createServer();
const io = new Server(httpServer, {
  cors: { origin: "https://app.example.com" },
  transports: ["websocket", "polling"], // Polling fallback for restrictive networks
});

// Two Redis clients: one for publishing, one for subscribing
// Redis requires separate clients because SUBSCRIBE puts a connection
// into subscriber mode and it cannot be used for other commands
const pubClient = createClient({ url: "redis://redis.internal:6379" });
const subClient = pubClient.duplicate();

await Promise.all([pubClient.connect(), subClient.connect()]);

// Wire Socket.io to Redis — now all emit/broadcast calls fan out across instances
io.adapter(createAdapter(pubClient, subClient));

io.on("connection", (socket) => {
  const userId = socket.handshake.auth.userId;
  const room = socket.handshake.query.room as string;

  if (!userId || !room) {
    socket.disconnect(true);
    return;
  }

  // Socket.io rooms are virtual groups. With the Redis adapter,
  // join/leave/emit are automatically synchronized across all instances.
  socket.join(room);
  console.log(`${userId} joined room ${room} on instance ${process.pid}`);

  // This emit reaches ALL clients in the room on ALL server instances
  io.to(room).emit("presence", { userId, event: "joined" });

  socket.on("chat", (text: string) => {
    // Validate and sanitize before broadcasting
    if (typeof text !== "string" || text.length > 1000) return;

    io.to(room).emit("chat", {
      userId,
      text: text.trim(),
      ts: Date.now(),
    });
  });

  socket.on("disconnecting", () => {
    io.to(room).emit("presence", { userId, event: "left" });
  });
});

httpServer.listen(8080, () => {
  console.log(`Instance ${process.pid} listening on :8080`);
});

With this setup, a client connected to Instance A can send a message that is delivered to a client on Instance C — via Redis pub/sub in approximately one additional millisecond of latency.

Kubernetes Considerations

In Kubernetes, WebSocket-backed deployments require deliberate configuration:

Ingress sticky sessions: The nginx ingress controller supports cookie-based affinity:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    nginx.ingress.kubernetes.io/affinity: "cookie"
    nginx.ingress.kubernetes.io/session-cookie-name: "ws-route"
    nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"  # Keep WS alive
    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /ws
            pathType: Prefix
            backend:
              service:
                name: websocket-service
                port:
                  number: 8080

Graceful shutdown: When Kubernetes sends SIGTERM to a pod, you have terminationGracePeriodSeconds to drain connections. Signal clients to reconnect before killing the process:

process.on("SIGTERM", async () => {
  console.log("SIGTERM received, draining connections...");

  // Tell all connected clients to reconnect (they'll go to a healthy instance)
  io.emit("server-restart", { reconnectIn: 3000 });

  // Stop accepting new connections
  httpServer.close();

  // Wait for clients to reconnect elsewhere, then exit
  setTimeout(() => {
    console.log("Graceful shutdown complete");
    process.exit(0);
  }, 5000);
});

Connection Limits and OS Tuning

Each WebSocket connection uses one file descriptor. Linux defaults are restrictive:

# Check current limits
ulimit -n       # Soft limit (often 1024 or 4096)
cat /proc/sys/fs/file-max  # System-wide maximum

# Raise for production WebSocket servers
# In /etc/security/limits.conf:
* soft nofile 65535
* hard nofile 65535

# Or per-process in systemd unit:
[Service]
LimitNOFILE=65535

At 65,535 file descriptors per process and one connection per file descriptor, a single process handles ~60,000 concurrent connections (leaving headroom for OS handles). For higher concurrency, use multiple processes via Node.js cluster module — each process gets its own file descriptor table. With the Redis adapter, all cluster workers share room state transparently.

Managed Services: When to Outsource

Running your own WebSocket infrastructure at scale is meaningful engineering work. Three managed options are worth knowing:

Service Pricing Right For
Ably $29/mo base, ~$3/mo per 1M messages Apps needing reliable delivery, presence, history
Pusher $49/mo, 500 concurrent connections Smaller apps, rapid prototyping
AWS API Gateway WebSocket $1/million messages + $0.25/million connection-minutes AWS-native apps, serverless

The break-even point where self-hosting beats Ably on cost is roughly 50 million messages/month — well beyond most startups. Until then, the engineering time saved is worth more than the subscription cost.


5. Collaborative Editing Patterns

Collaborative editing is the hardest real-time problem in common web development. When two users edit the same document simultaneously, you need a conflict resolution strategy that makes the result feel seamless — no overwriting, no lost changes.

Operational Transformation: The Historical Approach

Google Docs uses Operational Transformation (OT). The core idea: every operation (insert, delete) is represented as a data structure. When two operations conflict, a transform function adjusts them so they can be applied in either order and produce the same result.

OT works but it is algorithmically complex. Getting the transformation functions right for rich text is notoriously difficult, and the server must serialize all operations through a central authority to assign ordering. It doesn't work well offline.

CRDTs: The Modern Approach

Conflict-Free Replicated Data Types (CRDTs) take a different approach: design the data structure so that concurrent operations can always be merged without conflicts, regardless of order or network partitions. No server coordination required for merging. Works offline. Converges deterministically when peers sync.

Yjs is the dominant CRDT library in 2026. It implements a high-performance CRDT for text, arrays, and maps, and has providers for every transport layer.

Yjs with y-websocket: Full Implementation

// Server: y-websocket provider
// Install: npm install y-websocket yjs ws
import { WebSocketServer } from "ws";
import { setupWSConnection } from "y-websocket/bin/utils.js";
import * as Y from "yjs";
import { LeveldbPersistence } from "y-leveldb";

// Persist document state to disk so edits survive server restarts
const persistence = new LeveldbPersistence("./doc-storage");

const wss = new WebSocketServer({ port: 1234 });

wss.on("connection", (ws, req) => {
  // Extract document name from URL path, e.g. /doc/my-project-readme
  const docName = req.url?.slice(1) ?? "default";

  // setupWSConnection handles Yjs awareness and document sync protocol
  setupWSConnection(ws, req, {
    docName,
    gc: true, // Garbage collect deleted content to prevent unbounded growth
  });
});

// Restore persisted documents on startup
wss.on("listening", async () => {
  console.log("y-websocket server running on :1234");
});
// Client: collaborative text editor with Yjs + y-websocket
// Install: npm install yjs y-websocket y-codemirror.next @codemirror/view @codemirror/state
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import { yCollab } from "y-codemirror.next";
import { EditorView, basicSetup } from "codemirror";
import { EditorState } from "@codemirror/state";

// Each document has a Y.Doc — the CRDT root
const ydoc = new Y.Doc();

// The shared text type — changes here sync to all connected peers
const ytext = ydoc.getText("content");

// Connect to the y-websocket server
const provider = new WebsocketProvider(
  "ws://localhost:1234",  // y-websocket server URL
  "my-document",          // Document name (room)
  ydoc,
  {
    connect: true,
    params: { auth: getAuthToken() }, // Pass auth token in query params
  }
);

// Awareness: broadcast cursor position and user identity to all peers
provider.awareness.setLocalStateField("user", {
  name: currentUser.name,
  color: currentUser.color, // e.g. "#f97316"
  colorLight: currentUser.colorLight,
});

provider.awareness.on("change", () => {
  // Render remote cursors / presence indicators
  const states = provider.awareness.getStates();
  renderPresenceIndicators([...states.entries()]);
});

// Mount the editor — yCollab extension connects CodeMirror to Yjs
const state = EditorState.create({
  doc: ytext.toString(),
  extensions: [
    basicSetup,
    yCollab(ytext, provider.awareness), // Handles sync + cursor decorations
  ],
});

const view = new EditorView({
  state,
  parent: document.getElementById("editor")!,
});

provider.on("status", ({ status }: { status: string }) => {
  // "connected" | "disconnected"
  document.getElementById("sync-status")!.textContent = status;
});

Offline Support and Persistence

Yjs supports offline editing natively. If a user edits a document while offline, the changes are buffered in the Y.Doc. When the provider reconnects, it performs a sync operation — exchanging state vectors with the server to determine what each side is missing. All offline changes are merged without conflicts.

For server-side persistence beyond LevelDB, y-mongodb stores document state in MongoDB. The document state is stored as a binary update log, not the full text — Yjs encodes incremental updates efficiently, and the storage cost is proportional to the number of operations, not the document size.


6. Production Considerations

Getting WebSocket architecture working in development is the easy part. Making it reliable in production requires attention to several operational concerns that don't surface until traffic scales or infrastructure changes.

Monitoring

The metrics that matter for real-time infrastructure:

  • Connection count over time — steady growth is healthy; sudden drops indicate a server crash or network partition
  • Message throughput — messages/second per instance; this tells you when you're approaching per-process limits
  • Connection duration distribution — median, p95, p99; long-tail connections indicate clients that haven't received a close frame
  • Reconnection rate — high reconnection rate indicates connection instability, flaky network paths, or aggressive proxy timeouts
  • Redis pub/sub latency — the p99 of the time between publishing a message to Redis and delivering it to a connected client; should be under 10ms in a well-configured setup

With Prometheus and Grafana, instrument your WebSocket server:

import { Counter, Gauge, Histogram, register } from "prom-client";

const wsConnections = new Gauge({
  name: "ws_connections_active",
  help: "Number of active WebSocket connections",
  labelNames: ["room"],
});

const wsMessages = new Counter({
  name: "ws_messages_total",
  help: "Total WebSocket messages processed",
  labelNames: ["type", "direction"],
});

const wsMessageDuration = new Histogram({
  name: "ws_message_processing_seconds",
  help: "WebSocket message processing latency",
  buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5],
});

// Call wsConnections.inc({ room }) on connect, wsConnections.dec({ room }) on close
// Call wsMessages.inc({ type: "chat", direction: "inbound" }) on message receipt

Rate Limiting WebSocket Messages

Without rate limiting, a single misbehaving client can flood your server with messages. Implement a per-connection token bucket:

class TokenBucket {
  private tokens: number;
  private lastRefill: number;

  constructor(
    private readonly capacity: number,   // Max burst size
    private readonly refillRate: number  // Tokens added per second
  ) {
    this.tokens = capacity;
    this.lastRefill = Date.now();
  }

  consume(count = 1): boolean {
    const now = Date.now();
    const elapsed = (now - this.lastRefill) / 1000;
    this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate);
    this.lastRefill = now;

    if (this.tokens >= count) {
      this.tokens -= count;
      return true; // Allow
    }
    return false; // Reject
  }
}

// In connection handler:
const bucket = new TokenBucket(20, 5); // 20 burst, 5 messages/sec sustained

ws.on("message", (data) => {
  if (!bucket.consume()) {
    ws.send(JSON.stringify({ error: "rate_limited", retryAfter: 1 }));
    return; // Drop the message, do not process
  }
  // ... process message
});

Authentication

Never pass authentication credentials in WebSocket message payloads — by the time you parse the first message, the connection is already established. Validate before the upgrade completes.

The correct pattern is to pass a short-lived JWT as a query parameter in the WebSocket URL:

wss://api.example.com/ws?token=eyJhbGciOiJIUzI1NiJ9...

In the server's upgrade event handler (before the WebSocket connection is established):

server.on("upgrade", (req, socket, head) => {
  const { query } = parse(req.url ?? "", true);
  const token = String(query.token ?? "");

  verifyJWT(token)
    .then((payload) => {
      // Attach user info to request for use in connection handler
      (req as any).user = payload;
      wss.handleUpgrade(req, socket, head, (ws) => {
        wss.emit("connection", ws, req);
      });
    })
    .catch(() => {
      // Reject before WebSocket connection is established
      socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
      socket.destroy();
    });
});

Query parameters are logged by proxies and visible in browser history. Use short-lived tokens (60-second TTL) generated specifically for this connection. Never reuse long-lived API keys in WebSocket URLs.

Binary Protocols

For high-throughput message streams — sensor data, game state, financial ticks — JSON is wasteful. A JSON-encoded object {"type":"tick","symbol":"AAPL","price":213.40} is ~45 bytes. The MessagePack equivalent is ~22 bytes. At 100,000 messages/second across 10,000 connections, that difference is 230GB/day in saved bandwidth.

import { encode, decode } from "@msgpack/msgpack";

// Sender
ws.send(encode({ type: "tick", symbol: "AAPL", price: 213.40 }));

// Receiver
ws.on("message", (data: Buffer) => {
  const message = decode(data) as Record<string, unknown>;
  // ... process message
});

Graceful Shutdown

When deploying a new version, your load balancer will route new connections to the updated instances. Existing connections on old instances need to be drained gracefully — not killed abruptly, which would cause a poor user experience and a sudden reconnect spike:

let isShuttingDown = false;

process.on("SIGTERM", () => {
  isShuttingDown = true;
  console.log("Shutting down — draining connections");

  // Stop accepting new connections
  wss.close();

  // Notify all connected clients to reconnect elsewhere
  wss.clients.forEach((client) => {
    if (client.readyState === WebSocket.OPEN) {
      // Send application-level signal, then close cleanly
      client.send(JSON.stringify({ type: "server-restart", reconnectIn: 2000 }));
      setTimeout(() => client.close(1001, "Server going away"), 2000);
    }
  });

  // Force exit after 30 seconds (safety net)
  setTimeout(() => process.exit(0), 30_000);
});

// Reject new connections during shutdown
wss.on("connection", (ws) => {
  if (isShuttingDown) {
    ws.close(1013, "Server shutting down, please reconnect");
    return;
  }
  // ... normal connection handling
});

Conclusion

Three transports, three different design points:

WebSockets when you need full-duplex communication — both the client and server initiate messages independently. Chat, multiplayer, collaborative editing, live gaming. Accept the operational complexity: stateful connections, sticky sessions or Redis pub/sub, OS-level file descriptor tuning.

SSE when the server pushes and the client listens. Notification feeds, live dashboards, LLM token streaming, activity streams. It is simpler to operate than WebSockets, runs over plain HTTP with no special load balancer configuration, and the EventSource built-in handles reconnection and event replay for you.

WebRTC when you need peer-to-peer media or require the absolute minimum latency for binary data. Video calls, screen sharing, real-time audio, P2P games. The signaling infrastructure is still your problem (typically WebSockets), but the data path bypasses your servers entirely.

For scaling, the decision is binary below 10,000 concurrent connections per instance: a single well-tuned Node.js process is sufficient. Above that threshold, Redis pub/sub with Socket.io's adapter is the standard horizontal scaling pattern. Managed services like Ably are worth their cost until you hit ~50 million messages/month.

For collaborative editing specifically, Yjs is the right library for 2026. Its CRDT approach handles offline edits, conflict resolution, and presence awareness without requiring you to implement any of that logic yourself.

The default mistake is reaching for WebSockets everywhere. Most features that feel like they need WebSockets actually only need one-way server push — and SSE gets there with less infrastructure, less client code, and better behavior on reconnect. Pick the simplest transport that satisfies your actual requirements.


Sources

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-06-12 · Updated: 2026-04-18 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Bigger Is Not the Same as Better. The Job That Moved Is the Phone, Not the Lab.

Bigger is a plan. The phone is the receipt. The brief for this cycle is a question: does bigger always mean better in AI? The 2026 answer i...