Blog

If your software architecture employs polling, you should rethink your architecture.

– Albert Einstein, probably

A long time ago, I started working on an open-source library called ws-wrapper. I was intrigued with WebSockets, had kicked the tires of Socket.IO, but I wanted something more lightweight. The goal was to provide a simple protocol that let developers use WebSockets in JavaScript, primarily between web browsers and Node.js servers (and eventually between browsers and Go servers). After building a few projects with it, I started to realize that REST APIs are actually terrible...

REST APIs are actually terrible...

But Why?

Because we live in a universe with a constant speed of causality, any data fetched from a remote location becomes stale as soon as it is requested, even before it is received. To get a more recent version of the data, one has to poll the server, which is exactly what REST APIs encourage you to do.

Polling is simple to implement, but it comes with real costs:

With request-response polling, you're forced to choose between responsiveness and efficiency. WebSockets let you have both.

WebSockets to the Rescue

WebSockets keep a persistent, full-duplex connection open between client and server. Instead of polling, clients can subscribe to changes and get notified in real time when data is updated. The result is a more responsive application with significantly less overhead than a flood of HTTP requests.

The catch? Added complexity. WebSockets operate at a lower abstraction level than HTTP; you're working with raw messages, so you need to design and implement your own protocol to pass data around, call remote procedures, handle errors, and route messages.

This is where ws-wrapper comes in.

What ws-wrapper Gives You

ws-wrapper is a lightweight (~4 KB minified and gzipped), isomorphic library that wraps a native WebSocket and gives you:

It works in web browsers, Node.js, and Go – all speaking the same thin wire protocol over a native WebSocket connection.

Getting Started

Install the Node.js packages:

npm install ws ws-wrapper ws-server-wrapper

Here's a simple server with a request handler that returns a value:

// server.js (Node.js)
import { WebSocketServer } from "ws"
import WebSocketServerWrapper from "ws-server-wrapper"

const wss = new WebSocketServer({ port: 3000 })
const server = new WebSocketServerWrapper(wss)

server.on("greet", (name) => {
	return `Hello, ${name}!`
})

console.log("Server listening on port 3000")

And the client:

// client.js (browser or Node.js)
import WebSocketWrapper from "ws-wrapper"

const socket = new WebSocketWrapper(new WebSocket("ws://localhost:3000"))
const greeting = await socket.request("greet", "World")
console.log(greeting) // "Hello, World!"

This is just a simple remote procedure call using Promises. ws-wrapper handles the encoding, decoding, namespacing, routing, and the protocol.

You can also namespace events into channels so that different parts of your application don't step on each other:

socket.of("chat").emit("message", "Hello!")
socket.of("metrics").on("update", (data) => updateDashboard(data))

Both channels share the same underlying WebSocket connection.

Streaming with Async Generators

Here's an example where ws-wrapper really shines. Starting in v4.2, you can stream a sequence of values from the server to the client using anonymous channels and the iterableHandler helper.

The client sends a single request, but instead of getting back a plain value, it gets back a request-scoped channel it can iterate over.

On the server, you write an async generator function. ws-wrapper handles the channel setup, the handshake, and the teardown automatically:

import WebSocketWrapper, { iterableHandler } from "ws-wrapper"

// Stream rows from a database cursor
socket.on(
	"fetchResults",
	iterableHandler(async function* (query) {
		const cursor = await db.query(query)
		for await (const row of cursor) {
			yield row
		}
	})
)

On the client, you await the request and iterate with for await...of:

const chan = await socket.request("fetchResults", { table: "users" })
for await (const row of chan) {
	console.log(row)
}
chan.close()

That's it! The generator on the server streams data to the client, and the for await...of loop on the client consumes it. The server does not need to prepare the entire response ahead of time, and the client can abort the stream at any time.

Live Sensor Data Example

Here's another example of a server pushing temperature readings once per second:

// Server
socket.on(
	"watchTemperature",
	iterableHandler(async function* () {
		while (true) {
			yield await getSensorReading()
			await sleep(1000)
		}
	})
)
// Client
const chan = await socket.request("watchTemperature")
for await (const temp of chan) {
	document.querySelector("#temp").textContent = temp
	if (userClickedStop) break
}
chan.abort() // signals the server to stop generating

The server generator runs indefinitely until the client breaks out of the loop and closes the channel. On the next yield, the generator sees the cancellation and exits gracefully.

Cancellation

ws-wrapper has first-class support for cancellation via the Web standard AbortSignal API. You can cancel any in-flight request or anonymous channel, and it will notify the remote end of the cancellation. Here's our updated client with a cancel button wired up:

// Client
const controller = new AbortController()
document
	.querySelector("#cancelButton")
	.addEventListener("click", () => controller.abort())

const chan = await socket
	// Add the signal for the next request only
	.signal(controller.signal)
	.request("watchTemperature")
// Since the request returned an anonymous channel, the
// channel / stream inherits the request's AbortSignal
for await (const temp of chan) {
	document.querySelector("#temp").textContent = temp
}
chan.close() // closes the channel without notifying the remote

On the server side, the generator can check this.signal to cooperate with cancellation:

socket.on(
	"longStreamingJob",
	iterableHandler(async function* () {
		for (const batch of hugeDataset) {
			// this.signal refers to the AbortSignal for this anonymous channel
			if (this.signal?.aborted) return
			yield await processBatch(batch)
		}
		// iterableHandler implicitly closes the anonymous channel when done
	})
)

You can also combine a timeout with user cancellation:

const result = await socket
	.timeout(30_000)
	.signal(controller.signal)
	.request("heavyComputation", input)

How Small Is It?

ws-wrapper weighs under 4 KB minified and gzipped. For comparison:

Library Browser bundle (min + gzip) Streaming AbortSignal cancellation
ws-wrapper ~4 KB
Socket.IO ~25 KB
SockJS ~8 KB
Raw WebSockets N/A

If you're already using a native WebSocket, ws-wrapper gives you everything you actually want without the overhead.

Try It

ws-wrapper is my attempt to make WebSockets more pleasant. If your app needs real-time updates, streaming, or cancellable work, ws-wrapper might be a good fit.

Now go and be free of the wretched REST-ful API, and embrace the world of crazy-responsive web applications! 🚀 🌔


Blog Post Index