Skip to content
NexiferLabs
All services
Solutions overview
Browse the library
About Nexifer
websocketsreal-timebackendarchitectureinfrastructure

WebSockets, properly: real-time systems without connection chaos

Connection lifecycle, authentication, reconnects, presence, horizontal scaling with Redis, backpressure and failure handling - the parts demos leave out.

T

team

17 min read
A stylised illustration of a WebSocket connection, with a client and server connected by a stream of data. The client is represented as a computer, and the server is represented as a cloud. The stream of data is represented as a series of arrows, with the arrows pointing in both directions.

A WebSocket demo takes fifteen minutes. A WebSocket system takes considerably longer, because the demo skips everything that matters: what happens when the connection dies silently, how a second server instance learns about a message, who is actually online when three tabs are open, and what your process does when one client stops reading while you keep writing.

The difficulty is not the protocol. It is that you have introduced long-lived, stateful, in-memory connections into an architecture that was almost certainly designed around stateless requests. This covers the lifecycle, authentication, reconnection, presence, horizontal scaling, backpressure and failure handling - the parts that turn a demo into something you can operate.

What a WebSocket actually is

A WebSocket starts life as an HTTP request. The client sends an upgrade request, the server agrees with 101 Switching Protocols, and from that point the TCP connection carries WebSocket frames instead of HTTP messages. It is full-duplex - either side can send at any time - and it stays open until something closes it.

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

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: v1.chat
Sec-WebSocket-Protocol is a genuine subprotocol negotiation and almost nobody uses it. It is the cleanest place to put your message-format version - see the versioning note later.

Three consequences worth internalising

  • A WebSocket is one TCP connection and does not multiplex over HTTP/2. Ten thousand concurrent users means ten thousand sockets, with the file descriptors, memory and kernel buffers that implies.
  • Connection state lives in one process. The socket is a file descriptor on one machine. Any other instance of your service cannot write to it, which is the root of every scaling problem in this article.
  • The transport gives you no application semantics. No request/response correlation, no acknowledgement, no ordering guarantees beyond TCP's, no delivery guarantee across a reconnect. If you want those, you build them.

First, do you actually need one?

This section exists because the most common WebSocket mistake is using one. A persistent bidirectional connection is the right tool when the client sends frequently. When it mostly listens, you are paying for statefulness you do not use.

TransportDirectionReconnectBest for
Server-Sent EventsServer → clientBuilt in, with Last-Event-ID replayNotifications, dashboards, log tails, LLM token streams
WebSocketBidirectionalYou write itChat, multiplayer, collaborative editing, live cursors
Long pollingServer → client, effectivelyEvery cycleA fallback when a proxy strips Upgrade. Nothing else
WebTransportBidirectional, plus datagramsYou write itLatency-critical work - once Safari ships it

SSE deserves more consideration than it usually gets. It is plain HTTP, so it passes through every proxy, works with normal auth, and reconnects automatically with replay from the last event ID - for free. Over HTTP/2 it multiplexes, so the old connection-limit objection no longer applies. It is why every major LLM provider streams tokens over SSE rather than WebSocket.

One more constraint worth checking early: serverless platforms handle WebSockets poorly, because a function that lives for seconds cannot hold a connection for hours. Vercel added native WebSocket support to its functions in public beta in June 2026, and the connection is pinned to a single function instance with a default cap of five minutes. That is fine for a session-scoped stream and not a substitute for a connection layer.

The connection lifecycle

States, and the one that lies to you

ts
const ws = new WebSocket('wss://api.example.com/ws', ['v1.chat'])

ws.readyState
// 0 CONNECTING
// 1 OPEN        ← does NOT mean the peer is reachable
// 2 CLOSING
// 3 CLOSED

ws.onopen    = () => { /* handshake complete */ }
ws.onmessage = (e) => { /* a frame arrived */ }
ws.onerror   = (e) => { /* deliberately uninformative, for security */ }
ws.onclose   = (e) => { console.log(e.code, e.reason, e.wasClean) }
onerror in the browser gives you almost nothing on purpose - exposing why a connection failed would leak network information to scripts. Diagnose from the close code instead.

OPEN means the handshake completed, not that the other end is still there. A laptop closing its lid, a phone switching from Wi-Fi to cellular, or a NAT table entry expiring all produce a socket that reports OPEN on both sides while nothing can traverse it. This is the half-open connection, and it is the single most common source of "the app looks connected but nothing updates".

Heartbeats are mandatory

src/ws/heartbeat.ts
// Server: ping every client, drop anyone who missed the last pong
const HEARTBEAT_MS = 30_000

wss.on('connection', (ws) => {
  ws.isAlive = true
  ws.on('pong', () => { ws.isAlive = true })
})

const interval = setInterval(() => {
  for (const ws of wss.clients) {
    if (!ws.isAlive) {
      ws.terminate()          // not close() - the peer is gone, do not wait
      continue
    }
    ws.isAlive = false
    ws.ping()
  }
}, HEARTBEAT_MS)

wss.on('close', () => clearInterval(interval))
ping/pong are protocol-level control frames the browser answers automatically - no client code needed. terminate() destroys the socket immediately; close() waits for a handshake that will never arrive.

Set the interval below every idle timeout in the path. Load balancers, reverse proxies and cloud gateways all cut idle connections - AWS ALB defaults to sixty seconds, and many others sit in the same range. A thirty-second heartbeat keeps the connection classified as active.

Close codes carry meaning

CodeMeaningShould the client retry?
1000Normal closureNo - this was intentional
1001Going away - server shutting down, or tab closingYes, after a delay
1006Abnormal closure, no close frame receivedYes. This is what a dropped network looks like
1011Server encountered an unexpected conditionYes, with backoff
1012 / 1013Service restarting / try again laterYes, respecting any hint
4000–4999Application-defined - yours to allocateDepends on what you defined
Use the 4000 range deliberately: 4001 for an invalid token, 4002 for a token that expired, 4003 for a policy violation. A client that can distinguish "reauthenticate" from "back off" behaves far better.

Authentication

This is where the browser API actively works against you: you cannot set custom headers on a browser WebSocket handshake. No Authorization: Bearer. The constructor takes a URL and an optional subprotocol array, and that is all.

The options, in order of preference

ApproachHow it worksVerdict
Ticket / one-time tokenPOST to an authenticated HTTP endpoint, receive a short-lived single-use ticket, connect with ?ticket=…The right default. No long-lived credential in a URL
CookieThe browser sends cookies on the handshake automaticallyWorks, but CSRF applies - you must validate Origin yourself
First-message authConnect unauthenticated, send a token as the first frame, authenticate thenWorkable; requires an unauthenticated-connection state and a timeout
Token in the query stringwss://…/ws?token=eyJ…Avoid. URLs land in access logs, proxy logs and error reports
Subprotocol smugglingPass the token in Sec-WebSocket-ProtocolA hack that works. Same leakage risk, less obvious
ts
// 1. Client asks an ordinary authenticated endpoint for a ticket
const { ticket } = await fetch('/api/ws-ticket', {
  method: 'POST',
  credentials: 'include',
}).then((r) => r.json())

// 2. Connect with it. Valid for ~30 seconds, single use.
const ws = new WebSocket(`wss://api.example.com/ws?ticket=${ticket}`)
src/ws/auth.ts
// Server: issue
app.post('/api/ws-ticket', requireAuth, async (c) => {
  const ticket = crypto.randomUUID()
  await redis.set(
    `ws:ticket:${ticket}`,
    JSON.stringify({ userId: c.var.user.id }),
    'EX', 30,
  )
  return c.json({ ticket })
})

// Server: redeem, during the upgrade, before accepting the socket
server.on('upgrade', async (req, socket, head) => {
  const url = new URL(req.url!, 'http://localhost')

  // CSRF: cookies are sent automatically, so the origin must be checked
  if (!ALLOWED_ORIGINS.has(req.headers.origin ?? '')) {
    socket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
    return socket.destroy()
  }

  // GETDEL: redeem atomically so a captured ticket cannot be replayed
  const raw = await redis.getdel(`ws:ticket:${url.searchParams.get('ticket')}`)
  if (!raw) {
    socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
    return socket.destroy()
  }

  wss.handleUpgrade(req, socket, head, (ws) => {
    wss.emit('connection', ws, req, JSON.parse(raw))
  })
})
Reject during the upgrade, not after. A socket you accept and then close has already consumed a connection slot and taught an attacker that the endpoint exists.

Authorisation does not end at the handshake

Authenticating the connection tells you who the user is. It does not tell you whether they may subscribe to a particular room, or whether they still may an hour later. Every subscribe and every inbound message needs its own check against current permissions - a connection opened before a user was removed from a channel will happily keep receiving it unless you check.

ts
// Connections outlive tokens. Handle expiry explicitly.
const EXPIRY_GRACE_MS = 60_000

function scheduleTokenExpiry(ws: Socket, expiresAt: number) {
  const ms = expiresAt - Date.now() - EXPIRY_GRACE_MS
  ws.expiryTimer = setTimeout(() => {
    ws.send(JSON.stringify({ type: 'auth.refresh_required' }))
    // Give the client a window to send a fresh token before closing
    ws.graceTimer = setTimeout(
      () => ws.close(4002, 'token expired'),
      EXPIRY_GRACE_MS,
    )
  }, Math.max(0, ms))
}
A connection that outlives its token is a connection running on authorisation you can no longer justify. Close it - with a distinct code so the client knows to reauthenticate rather than back off.

Reconnection

Every connection will drop. Phones change networks, laptops sleep, proxies time out, you deploy. Reconnection is not an edge case; it is the normal operating mode, and getting it wrong produces the two failure modes that hurt most: a thundering herd, and silently missing messages.

Backoff with jitter, and why the jitter matters

src/ws/client.ts
class ResilientSocket {
  private attempt = 0
  private ws?: WebSocket
  private closedByUs = false

  connect() {
    this.ws = new WebSocket(this.url())

    this.ws.onopen = () => {
      this.attempt = 0                    // reset only on a real open
      this.resubscribe()
    }

    this.ws.onclose = (e) => {
      if (this.closedByUs || e.code === 1000) return
      if (e.code === 4001) return this.reauthenticate()
      this.scheduleReconnect()
    }
  }

  private scheduleReconnect() {
    const base = Math.min(1000 * 2 ** this.attempt, 30_000)
    // Full jitter: pick uniformly in [0, base), not base ± a bit
    const delay = Math.random() * base
    this.attempt++
    setTimeout(() => this.connect(), delay)
  }
}
Full jitter rather than a fixed backoff. When a server restarts, every client disconnects in the same second; identical backoff means they all return in the same second and knock it over again.
  • Do not reconnect on close code 1000. That was intentional - usually yours.
  • Distinguish auth failures from network failures. Retrying a rejected token forever is a loop that never resolves.
  • Stop when the tab is hidden, and reconnect on visibilitychange. A backgrounded tab reconnecting on a timer wastes battery and server slots.
  • Use navigator.onLine and the online event as a hint to retry immediately rather than waiting out the backoff.
  • Cap the attempt count and surface a real "reconnecting" state in the UI. A silent client that has given up is worse than an error message.

Resumption: the part people skip

Reconnecting restores the socket. It does not restore the messages sent while you were gone. Unless you design for it, every reconnect is a gap - and users experience gaps as the application being broken.

ts
// Client stores the last message id it processed
const ws = new WebSocket(`${base}?ticket=${ticket}&since=${lastSeenId ?? ''}`)

// Server, on connect: replay from a bounded buffer before live delivery
async function onConnect(ws: Socket, rooms: string[], since?: string) {
  if (since) {
    for (const room of rooms) {
      // A Redis stream per room gives you exactly this, with a cap
      const missed = await redis.xrange(`room:${room}`, `(${since}`, '+', 'COUNT', 500)
      for (const [id, fields] of missed) {
        ws.send(JSON.stringify({ id, ...decode(fields) }))
      }
    }
  }
  subscribeLive(ws, rooms)
}
XTRIM the stream to a bounded length. If a client has been gone longer than the buffer, tell it so explicitly and let it do a full refetch - a resync_required message is far better than a silent gap.

Presence

Presence - who is online, who is in this room, who is typing - looks trivial and is the feature most likely to be subtly wrong in production. The naive implementation is a set you add to on connect and remove from on disconnect, and it fails immediately.

FailureWhy the naive version breaks
A process crashesNo disconnect handler runs. Those users are online forever
Half-open connectionsThe socket never closes, so the user never leaves
Multiple tabsOne tab closes and the user disappears while still connected in two others
Multiple devicesPhone and laptop are two connections, one person
A network partition between nodesEach node's view of presence diverges and neither knows

Model presence as a lease, not a flag

The fix is to stop treating presence as durable state. It is a claim that expires unless renewed. Each connection registers itself with a TTL and refreshes on every heartbeat; anything that stops refreshing disappears on its own, whether it disconnected cleanly, crashed, or vanished.

src/ws/presence.ts
// One key per connection, not per user. TTL slightly over 2 heartbeats.
const PRESENCE_TTL = 75

async function register(userId: string, connId: string, room: string) {
  await redis.set(`presence:${room}:${userId}:${connId}`, nodeId, 'EX', PRESENCE_TTL)
}

// Called from the same heartbeat loop that pings the socket
async function refresh(userId: string, connId: string, room: string) {
  await redis.expire(`presence:${room}:${userId}:${connId}`, PRESENCE_TTL)
}

// A user is present if ANY of their connections is
async function whoIsHere(room: string): Promise<string[]> {
  const users = new Set<string>()
  for await (const key of redis.scanIterator({ MATCH: `presence:${room}:*` })) {
    users.add(key.split(':')[2])
  }
  return [...users]
}
Keying by connection rather than user is what makes multiple tabs and devices work. A crashed node's keys expire in seventy-five seconds with no cleanup logic anywhere.
  • Debounce join and leave events. A brief reconnect should not emit "left" then "joined" - hold leave notifications for a grace period longer than a typical reconnect.
  • Presence is eventually consistent, and that is fine. Do not build features that require it to be exact. Nobody is harmed by a name lingering for thirty seconds; a system that stalls trying to be precise is worse.
  • Typing indicators are ephemeral, not presence. Fire-and-forget with a short client-side timeout. They do not need durability, replay or a Redis key.
  • Never derive authorisation from presence. Being listed in a room is a UI fact, not a permission.
  • Use SCAN, never KEYS, for enumerating presence keys - the reasoning is the same as in any Redis deployment.

Horizontal scaling

Here is the central problem, stated plainly. Alice is connected to node A. Bob is connected to node B. Bob sends a message to their shared room. Node B holds Bob's socket and has no way to write to Alice's, because Alice's socket is a file descriptor on a different machine.

text
        Alice                     Bob
          │                        │
     ┌────▼────┐              ┌────▼────┐
     │ node A  │              │ node B  │
     └────┬────┘              └────┬────┘
          │                        │
          └──────► Redis ◄──────────┘
                  pub/sub

  Node B publishes to `room:42`. Every node subscribed to that
  channel receives it and writes to its own local sockets.

Sticky sessions come first

Before any of that, the load balancer has to keep a client on the node it connected to. A WebSocket is a single long-lived TCP connection, so once established it stays put - but the *upgrade* request must reach a node that can serve it, and any reconnect must be able to land anywhere. Configure session affinity, and make sure your idle timeout exceeds your heartbeat interval.

Redis pub/sub as the message bus

src/ws/bus.ts
import Redis from 'ioredis'

// Two connections: a subscriber cannot issue other commands
const sub = new Redis(url)
const pub = new Redis(url)

const localRooms = new Map<string, Set<Socket>>()

sub.on('messageBuffer', (channel, payload) => {
  const room = channel.toString().slice('room:'.length)
  const sockets = localRooms.get(room)
  if (!sockets) return

  const frame = payload.toString()
  for (const ws of sockets) {
    if (ws.readyState === WebSocket.OPEN) ws.send(frame)
  }
})

export async function joinRoom(ws: Socket, room: string) {
  let sockets = localRooms.get(room)
  if (!sockets) {
    sockets = new Set()
    localRooms.set(room, sockets)
    await sub.subscribe(`room:${room}`)   // subscribe once per node, not per socket
  }
  sockets.add(ws)
}

export async function leaveRoom(ws: Socket, room: string) {
  const sockets = localRooms.get(room)
  if (!sockets) return
  sockets.delete(ws)
  if (sockets.size === 0) {
    localRooms.delete(room)
    await sub.unsubscribe(`room:${room}`)  // stop paying for rooms with nobody here
  }
}
Subscribing per node rather than per socket is what keeps this affordable. Ten thousand sockets in one room is one Redis subscription, not ten thousand.

Where Redis pub/sub stops being enough

LimitationWhat it meansWhat to do instead
No durabilityA node that is down when a message publishes never receives itRedis Streams with consumer groups, or a real broker
No replayNothing to serve a reconnecting client fromKeep a bounded stream per room for the resume window
Fan-out costEvery node receives every message for rooms it subscribes toShard rooms across nodes, or use a purpose-built layer
Cluster mode caveatsPub/sub across a Redis Cluster needs care with slot routingUse sharded pub/sub (SSUBSCRIBE) or keep the bus on one instance
Backpressure is invisibleRedis will happily deliver faster than a node can write to socketsHandle it at the socket layer - the next section

Redis Streams are usually the better default once you need resumption anyway. You get an ordered log per room with IDs you can hand to clients as resume tokens, bounded by XTRIM, and pub/sub-like fan-out through consumer groups. The same infrastructure then serves both live delivery and replay.

Backpressure

This is the failure mode that takes down the process, and it is invisible until it happens. ws.send() does not block. If a client stops reading - a phone on a train, a tab throttled in the background, a laptop asleep - your sends queue in the server's memory. Enough slow clients and the process runs out of heap.

ts
const MAX_BUFFERED = 1024 * 1024      // 1MB of unsent data per socket

function safeSend(ws: Socket, frame: string): boolean {
  if (ws.readyState !== WebSocket.OPEN) return false

  if (ws.bufferedAmount > MAX_BUFFERED) {
    // This client cannot keep up. Do not keep queueing for it.
    metrics.increment('ws.slow_consumer_dropped')
    ws.close(4008, 'too slow')
    return false
  }

  ws.send(frame)
  return true
}
bufferedAmount is the number of bytes queued but not yet written to the network. It exists on both the browser and server APIs and almost nobody checks it.

Strategies, and choosing between them

StrategyBehaviourRight for
Disconnect the slow consumerClose above a buffer threshold; the client reconnects and resumesMost systems. Simple, bounded, and the resume path already exists
Drop intermediate messagesKeep only the newest state per keyTickers, cursors, presence - where only the latest value matters
Coalesce and batchAccumulate for 50ms, send one combined frameHigh-frequency updates. Cuts frame overhead dramatically
Per-client queue with a capExplicit bounded queue, drop oldest on overflowWhen you need control over which messages are lost
Unbounded bufferingQueue everything and hopeNever. This is how the process dies
ts
// Coalescing: one frame per tick instead of one per event
class Batcher {
  private pending = new Map<string, unknown>()   // keyed: newest wins
  private timer?: NodeJS.Timeout

  push(key: string, value: unknown) {
    this.pending.set(key, value)
    this.timer ??= setTimeout(() => this.flush(), 50)
  }

  private flush() {
    this.timer = undefined
    if (this.pending.size === 0) return

    const batch = [...this.pending.entries()]
    this.pending.clear()
    safeSend(this.ws, JSON.stringify({ type: 'batch', updates: batch }))
  }
}
For live cursors or a ticker, fifty cursor updates in a tick should be one frame with fifty positions - or better, one position per user, since the older ones are already stale.

Apply the same discipline inbound. A client can send as fast as it likes, so rate limit per connection, cap message size at the server (maxPayload in ws), and drop connections that exceed either. An unbounded inbound path is a denial of service you built yourself.

Failure handling and operations

Deploys are a mass disconnection event

Every rolling restart disconnects every connected client. With a naive client, they all reconnect within a second or two of each other, arrive together at whichever nodes are up, and can take those down as well.

ts
// Graceful shutdown: stagger the disconnect, tell clients why
async function shutdown() {
  server.close()                       // stop accepting new upgrades

  const clients = [...wss.clients]
  const windowMs = 20_000
  const step = windowMs / Math.max(clients.length, 1)

  clients.forEach((ws, i) => {
    setTimeout(() => {
      // 1012 = service restarting. Clients can reconnect promptly.
      ws.close(1012, 'restarting')
    }, i * step)
  })

  await sleep(windowMs + 5_000)
  process.exit(0)
}

process.on('SIGTERM', shutdown)
Spreading closures over twenty seconds turns a spike into a ramp. Make sure your orchestrator's termination grace period is longer than the window, or it will SIGKILL you mid-drain.

What to monitor

  • Concurrent connections per node, and the spread across nodes. A skewed distribution means affinity or reconnect behaviour is wrong.
  • Connection duration histogram. A cluster of very short connections means clients are failing and retrying - often an auth problem you cannot see any other way.
  • Close codes, broken out. A rise in 1006 is a network or proxy problem; a rise in your 4001 is an auth problem.
  • bufferedAmount percentiles and slow-consumer drops. These are your early warning for memory pressure.
  • Reconnect rate. Spikes align with deploys, proxy restarts and mobile network events.
  • Messages published versus messages delivered. A widening gap means the fan-out layer is losing traffic.
  • File descriptor usage against the process limit. Sockets are file descriptors, and the default ulimit is often far too low.

Degrade rather than fail

  • If the socket cannot connect, fall back to polling. A slower app beats a broken one, and some corporate proxies genuinely strip Upgrade.
  • Show connection state in the UI. A quiet "reconnecting" indicator prevents the worst outcome, which is a user trusting stale data.
  • Never make a socket a hard dependency of a page load. Render from HTTP-fetched state, then let the socket deliver updates.
  • Make writes go over HTTP where they matter. A POST you can retry with an idempotency key is more reliable than a frame with no acknowledgement.
  • Version your message format - in the subprotocol, or a v field on every message. Clients update on their own schedule, and old ones will still be connected during your deploy.

Mistakes that keep recurring

MistakeConsequence
No heartbeatHalf-open connections accumulate; users see a connected app that never updates
No Origin check with cookie authCross-site WebSocket hijacking - any page can open an authenticated socket
Reconnecting without jitterA restart becomes a thundering herd that prevents recovery
No bufferedAmount checkOne slow client's queue grows until the process runs out of memory
Presence stored as a durable flagGhost users after every crash, and one closed tab removing an active user
No message resumptionSilent gaps after every reconnect; users see stale data and do not know
In-memory room state on multiple nodesHalf your users never receive the message
Long-lived token in the connection URLCredentials in access logs, proxy logs and error reports
Idle timeout shorter than the heartbeatThe load balancer cuts healthy connections on a timer
Rolling restart with no staggerEvery client reconnects in the same second
Using WebSockets for a one-way feedAll the statefulness, none of the benefit - SSE would have done it
No inbound rate limit or payload capA denial of service any client can trigger

Verdict

WebSockets are the right tool for genuinely bidirectional, high-frequency communication - chat, multiplayer, collaborative editing, live cursors. Nothing else gives you that with comparable latency and overhead. For anything where the client mostly listens, SSE does the job with automatic reconnection, standard authentication and stateless servers, and you should take that deal.

When you do need them, the work is not in the protocol. It is in accepting that you have introduced long-lived state into a stateless architecture and handling the consequences deliberately: heartbeats because connections lie, jittered backoff because clients reconnect together, leases because presence cannot be durable, a message bus because sockets live on one node, and buffer limits because slow clients are the ones that take down the process.

Assume every connection is already broken and you just have not noticed yet. Design the reconnect and resume path first, and the rest of the system follows from it.

Our starting position on any real-time feature

If you build three things properly - heartbeats with terminate(), jittered reconnection with a resume token, and a bufferedAmount check on every send - you will have avoided the majority of production WebSocket incidents. The rest is operational care: staggered deploys, close codes that mean something, and monitoring that shows you connection churn before your users report it.

Sources

  • RFC 6455 - the WebSocket protocol, including the close code registry
  • MDN WebSocket API - the browser interface and its constraints
  • ws - the Node.js implementation used in the examples, including maxPayload and terminate
  • MDN Server-Sent Events - the simpler option, with built-in reconnection
  • Redis Streams - ordered logs with consumer groups, for replay and fan-out
  • WebTransport - the HTTP/3 successor, and its current browser support
Back to Blog
Share:

Related Posts