Serving GeoArrow over WebSockets to a WebGPU Client

HTTP is the right transport for tiles, because tiles are cacheable, immutable and requested on demand. A live feed is none of those: positions update continuously, the client wants everything for a region rather than a specific URL, and a request per update would spend more on headers than on data. A WebSocket carrying framed GeoArrow batches is the natural fit, and the engineering is almost entirely about backpressure — a producer that outruns the client’s ability to upload will fill a buffer somewhere, and where it fills decides whether the map degrades gracefully or falls over. This page is the framing, the flow control, and the cases where HTTP is still the better answer. It is one stage of Python to GPU streaming with Arrow and GeoParquet.

A send window keeps the queue on the server Two lanes compare an unwindowed stream with a windowed one. Without a window the server sends continuously, batches queue in the network and in the browser where the application cannot see them, and the client renders data that is seconds old while memory grows. With a window the server sends at most three unacknowledged batches, the client acknowledges after issuing each upload, and the queue forms in the server where stale updates can be coalesced or dropped. BACKPRESSURE · WHERE THE QUEUE FORMS No window Windowed Server sends freely no feedback Queue in the network invisible to the app Stale data, growing memory looks like a leak Send up to 3 then wait for an ack Client acks after upload honest signal Queue on the server coalesce or drop Acknowledge after issuing the upload, not on receipt — otherwise the signal is a lie.
The window does not make the client faster; it makes the backlog visible to the one participant that can do something useful about it.

Runnable reference implementation

Each message is one Arrow IPC record batch, framed by the WebSocket itself, so no length prefix is needed. The client uploads it and acknowledges.

typescript
function connect(device: GPUDevice, target: StreamTarget, url: string): WebSocket {
  const ws = new WebSocket(url);
  ws.binaryType = "arraybuffer";

  let inFlight = 0;
  const MAX_IN_FLIGHT = 3;         // batches the client will accept unacknowledged

  ws.onmessage = (e) => {
    const batch = decodeArrowBatch(e.data as ArrayBuffer);
    const view = coordinateView(batch);
    device.queue.writeBuffer(target.buffer, target.written * target.stride, view);
    target.written += batch.numRows;

    // Acknowledge only after the upload has been issued, so the server's
    // window tracks what the client has actually consumed.
    inFlight--;
    ws.send(JSON.stringify({ ack: batch.numRows }));
  };

  ws.onopen = () => {
    ws.send(JSON.stringify({ subscribe: "vessels", window: MAX_IN_FLIGHT }));
  };
  return ws;
}

The acknowledgement is the whole flow-control mechanism, and it has to be sent after the upload rather than on receipt. Acknowledging on receipt tells the server the client is keeping up when what it is really doing is queueing.

Parameter reference

Value Setting here Guidance
Window 3 batches Enough to keep the connection busy, small enough that the client never holds more than a few megabytes of unprocessed data.
Batch size 0.5–1 MiB Smaller than the HTTP case: latency matters more and the connection is already open, so per-message overhead is lower.
binaryType "arraybuffer" The default is "blob", which forces an extra async read before the bytes are usable.
Reconnect backoff 1 s, doubling to 30 s A dropped socket on a flaky network should not become a reconnect storm.
Ack payload row count Lets the server track consumption in the same units it produces.
WebSocket against HTTP for spatial payloads A comparison across four properties. Caching is available for HTTP through the browser and any CDN, and unavailable over a WebSocket. Resumption after an interruption is supported by HTTP range requests and requires a full restart over a socket. Server-initiated updates are impossible over HTTP without polling and are native to a socket. Scaling is stateless for HTTP and requires session affinity for a socket. TRANSPORT · HTTP vs WEBSOCKET HTTP WebSocket Caching browser + CDN none Resumption range requests full restart Server-initiated polling only native Scaling stateless session affinity A map with cached tiles over HTTP and live positions over a socket is the normal arrangement.
Three of four favour HTTP, and the fourth is decisive when it applies. Use the socket for data that arrives unbidden and HTTP for everything that is asked for.

Backpressure, and where the queue forms

Without flow control, a producer faster than the consumer fills a queue, and there are four places it can form — each worse than the last.

In the server’s send buffer is the best case: the server knows it is ahead, can drop or coalesce updates, and can apply policy. In the network is next: TCP will throttle the sender eventually, but the latency of everything already in flight grows, so the client renders data that is seconds old. In the browser’s receive buffer is worse, because the application cannot see it and the only symptom is growing memory. In the application’s own queue is worst, because it looks like a memory leak and is not.

A window makes the first case the only one. The server sends at most window unacknowledged batches, so the queue forms where policy can be applied to it, and the natural policy for a live feed is to coalesce: if three updates for the same vessel are waiting, send only the newest. That is a decision only the server can make, and it is why the queue belongs there.

The client’s side of the contract is to acknowledge honestly. An acknowledgement means “this batch is on the queue to the GPU”, which is the closest the client can get to “consumed” without awaiting a fence — and awaiting a fence per batch would serialise the pipeline for a guarantee nobody needs.

When HTTP is still better

A WebSocket is not a general upgrade, and three properties of HTTP are worth giving up deliberately rather than by accident.

Caching. A tile fetched over HTTP is cached by the browser, by any intermediary, and by the origin’s CDN. The same tile over a WebSocket is cached nowhere, so every client pays full price and a reconnect pays it again. For static tiles this alone settles it.

Range requests and resumption. An interrupted HTTP download resumes; an interrupted WebSocket stream restarts. For a large one-off payload that difference is a user waiting twice.

Simplicity of scaling. HTTP is stateless, so any server can answer any request. A WebSocket pins a client to a server for the life of the connection, which turns a deployment into one with session affinity and makes rolling restarts visible to users.

The rule that follows: WebSockets for data that changes without being asked for, HTTP for data that is requested. A map with both — cached basemap tiles over HTTP, live vessel positions over a socket — is the common and correct arrangement.

Subscribing to a region rather than a dataset

A live feed on a map is almost never wanted in full: the client cares about what is on screen, and a producer sending everything wastes bandwidth on entities the user cannot see. Making the subscription spatial changes the protocol slightly and the economics substantially.

The subscription message carries a bounding box and a zoom, and the client re-sends it when the viewport settles — on moveend rather than continuously, for the same reason a tile reconcile happens there. The server maintains a per-connection region and filters its updates against it, which is a cheap test it was already doing to decide what changed.

Two details make it behave well. The region should be padded beyond the viewport, so a small pan does not immediately produce a gap while a new subscription round-trips. And a region change should produce a snapshot followed by incremental updates, not just incremental ones: an entity that was outside the old region and is inside the new one has no state on the client at all, so the server has to send its current position rather than waiting for it to move.

The failure to watch for is a subscription that changes faster than the server can respond. A user dragging continuously with a move-triggered subscription generates dozens of region updates a second and a server permanently answering the one before last. Debouncing on the client and coalescing on the server both help; doing only one of them leaves the other end doing the wasted work.

Failure modes

  • Memory grows steadily while the map looks fine. No window, so batches are queueing in the browser or the application. Add flow control before optimising anything else.
  • The map shows data seconds old. The queue is in the network. A window fixes it by moving the queue to the server, where stale updates can be dropped.
  • Every reconnect re-sends the whole dataset. The subscription has no resume point. Send a sequence number with each batch and let the client resume from the last one it acknowledged.
  • The socket drops under load and reconnects immediately, repeatedly. No backoff. Exponential backoff with jitter, exactly as an adapter retry loop uses.
  • Batches decode as garbage after a reconnect. The Arrow IPC schema message is sent once at the start of a stream; a reconnect needs it again before any batch.
What a reconnect has to replay Three steps in order after a dropped connection. First the client backs off with exponential delay and jitter so a network blip does not become a reconnect storm across every open tab. Second it re-subscribes and the server re-sends the Arrow schema message, without which no subsequent batch can be decoded. Third the client sends the last sequence number it acknowledged, and the server resumes from there rather than replaying the whole dataset. RECONNECT · THREE STEPS 1 Back off with jitter 1 s doubling to 30 s a blip must not become a storm 2 Re-send the schema once, before any batch batches are undecodable without it 3 Resume from the last ack sequence number per batch not a full replay Sequence numbers cost four bytes per batch and make resumption a one-line negotiation.
The middle step is the one that produces the strangest bug when it is missed: the connection is healthy, batches arrive, and every one of them decodes as garbage.

Backend / Python interop note

pyarrow frames batches for a socket the same way it does for HTTP, with one difference that matters: the schema message must precede the batches on every new connection.

python
import pyarrow as pa
import pyarrow.ipc as ipc

class BatchFramer:
    """One instance per connection — the schema is sent once, first."""
    def __init__(self, schema: pa.Schema):
        self.schema = schema
        self.started = False

    def frame(self, batch: pa.RecordBatch) -> bytes:
        sink = pa.BufferOutputStream()
        with ipc.new_stream(sink, self.schema) as w:
            w.write_batch(batch)
        self.started = True
        return sink.getvalue().to_pybytes()

The server side of the window is a counter per connection: increment on send, decrement on ack, and stop sending when it reaches the client’s stated window. Coalescing on top of that is application-specific — for positional data, keeping only the newest update per entity in the pending set is both correct and dramatically cheaper than sending every intermediate position to a client that is behind.