keepline
The line stays up.
A typed WebSocket client for automatic reconnection, half-open detection, outbound queuing,
reconnect-safe subscriptions, schema validation, and observability — with first-class React bindings.
Dependency-free core · ESM + CJS · React 18+ · Node.js 18+
Quick start · Features · Entry points · Migrate from react-use-websocket
npm install keepline
Why
A WebSocket is a socket, not a connection. Everything that makes it usable in production — noticing it died, coming back, not hammering the server on the way back, remembering what you were subscribed to, telling you why it dropped — is left to you. So every app writes it, and every app writes a slightly broken version.
The specific failures this exists to fix:
- A socket that is
OPENand dead. A NAT entry expires, a proxy goes away, andreadyStatestays1forever while nothing arrives. Only a heartbeat notices. readyStatecannot express "down but coming back." Nor "down for good." Both read asCLOSED, so a UI built on it cannot tell a blip from an outage.- Reconnecting without jitter. A server restart drops every client at the same instant; an un-jittered fleet then retries at the same instant, repeatedly, turning a 2-second restart into a 5-minute outage.
- Reconnecting after a close-coded auth failure. Retrying rejected credentials forever is how a client earns a rate limit.
- A reconnect that comes back subscribed to nothing. The socket is open, the server has forgotten your subscriptions, and the effect that sent them does not re-run.
- Silent close codes. The single most useful fact about a failure — 1006 network vs 1008 auth vs 4001 application — is the one thing most wrappers throw away.
JSON.parsein an event handler. A throw inside a WebSocket callback reaches no error boundary and notry/catch; it becomes an unhandled window error.- Message history in React state. One re-render per message, in every consumer. On a tick feed that is the entire frame budget.
Quick start
React
import { useSocket, useSocketSubscription } from 'keepline/react';
const Feed = ({ symbols, enabled }) => {
const { socket, status, isReconnecting } = useSocket<ServerMessage>({
// `null` means "not yet" — no conditional hooks, no placeholder connection.
url: enabled ? 'wss://api.example.com/feed' : null,
heartbeat: { message: { type: 'ping' }, isPong: (m) => m.type === 'pong' },
onMessage: (message) => applyToChart(message)
});
// Re-sent automatically on every reconnect.
useSocketSubscription(socket, symbols.length ? {
subscribe: { type: 'add', symbols },
unsubscribe: { type: 'remove', symbols }
} : null);
return <Badge status={status} reconnecting={isReconnecting} />;
};
Callbacks are read from a ref, so inline arrow functions never cause a reconnect. Only the connection identity does: key, a string url, protocols, and resetKeys.
Anything else
import { createSocket } from 'keepline';
const socket = createSocket({
url: () => `wss://api.example.com/feed?token=${getToken()}`, // re-resolved per attempt
schema: serverMessage, // any Standard Schema validator
staleAfterMs: 30_000,
onMessage: (message) => handle(message)
});
What you get
| Reconnection | Truncated exponential backoff with jitter on by default; linearBackoff/constantBackoff provided; per-attempt shouldReconnect veto; retry budget; hard-stops delivered auth and protocol close codes; honours a server's 1013 "try again later" with a longer delay |
| Liveness | heartbeat ping/pong with RTT measurement and half-open detection; staleAfterMs silence watchdog; connectTimeoutMs for handshakes that hang against a black-holed host |
| Status | 8 real states, including reconnecting, paused and gave-up |
| Outbound | Bounded queue while connecting, flushed in order on open; drop-oldest / drop-newest / reject overflow policies |
| Subscriptions | socket.subscription({ subscribe, unsubscribe }) — replayed on every reconnect, released on unsubscribe |
| Request/response | socket.request(payload, { match, timeoutMs, signal }) returns a promise |
| Validation | schema accepts anything implementing Standard Schema — zod ≥ 3.24, valibot, arktype — with no dependency on any of them |
| Errors | Decode and validation failures become events, never throws inside a socket handler |
| Browser awareness | pauseWhenHidden to stop burning battery and server capacity in a background tab; instant reconnect on online instead of waiting out the backoff |
| Auth rotation | url/protocols resolvers are re-invoked per attempt, so tokens are fresh at connect time — not at render time |
| Observability | One onEvent stream of 20 typed events, with keepline/sentry and keepline/logger adapters built on it |
| Testing | keepline/testing ships a scriptable MockWebSocket — the real state machine runs, only the wire is fake |
| Sharing | share: true or <SocketProvider>; reference-counted with a grace period so StrictMode and route transitions don't churn the connection |
| SSR | No-ops without a WebSocket global. No guards, no dynamic imports |
Core stays under 6 kB brotli and React bindings under 8 kB, with both budgets enforced in CI.
Entry points
| Import | Contents |
|---|---|
keepline | createSocket, backoff strategies, close-code helpers, errors. No React. |
keepline/react | useSocket, useSocketStatus, useSocketMessage, useLastMessage, useSocketSubscription, useSocketMetrics, SocketProvider |
keepline/compat | Migration-focused useWebSocket shaped like react-use-websocket — see MIGRATION.md |
keepline/testing | MockWebSocket, installMockWebSocket, mockSocketFactory |
keepline/sentry | createSentryReporter — breadcrumbs and exceptions, no @sentry/* dependency |
keepline/logger | createConsoleLogger |
keepline/socket-io | socketIo, socketIoUrl — talk to a Socket.IO v4 server without socket.io-client |
Recipes
Auth with short-lived tokens
A resolver runs on every attempt, so the token is fresh at connect time — including the reconnect three minutes after the tab was opened. When the browser delivers an auth close code (1008, 4001, 4401, ...), it is a hard stop; shouldReconnect can narrow that policy but cannot widen it.
A rejected WebSocket upgrade may instead emit only error, or deliver close after Keepline's 50ms grace period. Browser error events expose neither the HTTP status nor a close code, so that path is governed by retryOnError (default true). For a protected endpoint, set it to false; the errored transport then settles closed, and your application can call socket.reconnect() after refreshing credentials.
const socket = createSocket({
url: async () => {
const token = await auth.getFreshToken(); // may refresh
return `wss://api.example.com/feed?token=${token}`;
},
reconnect: { retryOnError: false },
onClose: ({ category, willReconnect }) => {
if (category === 'policy' && !willReconnect) redirectToLogin();
}
});
In React, when the token lives in protocols instead of the URL, tell the hook it is part of the connection's identity:
useSocket({
url: 'wss://api.example.com/feed',
protocols: ['app-proto', `bearer.${token}`],
resetKeys: [token] // new token -> new connection
});
A gateway that refuses the upgrade
Not every rejection arrives as a useful close code. A gateway refusal can surface in the browser as error and close: 1006 effectively together — no status, no reason, and the same code an ordinary network drop produces. In that event shape the close owns recovery, so neither retryOnError nor the close-code table can identify the rejection. A socket that retries drops aggressively can therefore retry a rejected token just as aggressively for as long as the tab stays open.
wasOpen reports the distinction the close event does not carry: whether the attempt that just failed had reached open. It does not identify why an attempt failed. false covers every pre-open failure, including a rejected upgrade, DNS or TLS failure, unavailable network, connection timeout, or an error while resolving the URL or creating the transport.
const postOpen = linearBackoff({ stepMs: 200, maxDelayMs: 5_000 });
const preOpen = exponentialBackoff({ initialDelayMs: 200, maxDelayMs: 60_000 });
const socket = createSocket({
url: 'wss://api.example.com/feed',
reconnect: {
// Recover a dropped session quickly. Back off any attempt that never
// opened — including a rejected handshake — to roughly one per minute.
backoff: (attempt, context) =>
context?.wasOpen ? postOpen(attempt) : preOpen(attempt)
}
});
When the goal is to keep retrying at a cheaper cadence, prefer this to capping attempts: one hard budget is shared by pre-open failures and long outages, and can strand a socket that would otherwise have recovered. attempt resets on every successful open, so a healthy connection never accumulates delay.
Request/response over the socket
const socket = createSocket<ServerMessage, ClientMessage>({
url,
// Default matcher: correlate by id once, instead of per call.
matchResponse: (message, sent) => message.replyTo === sent.id
});
const reply = await socket.request(
{ id: nextId(), type: 'get-portfolio' },
{ timeoutMs: 5_000, signal: controller.signal }
);
Sent while down? The payload queues, flushes on open, and the promise still resolves when the reply arrives — or rejects with RequestTimeoutError, ConnectionClosedError, or GaveUpError, so the failure mode is always a typed error, never a hang.
If it rejects before opening, its queued payload is removed too — a timed-out or aborted request is never sent later as a stale side effect.
A status badge users can believe
status distinguishes a blip from an outage, which readyState cannot:
const { status, isReconnecting, isFailed, reconnect } = useSocket({ url });
if (isFailed) return <button onClick={reconnect}>Connection lost — retry</button>;
if (isReconnecting) return <Spinner label="Reconnecting…" />;
return <Dot color={status === 'open' ? 'green' : 'grey'} />;
Binary protocols and custom codecs
decode/encode replace the default JSON pipeline; a throw in either becomes a decode-error/error event instead of an unhandled exception:
import { decode, encode } from '@msgpack/msgpack';
const socket = createSocket<ServerMessage, ClientMessage>({
url,
binaryType: 'arraybuffer',
decode: (data) => decode(data as ArrayBuffer),
encode: (payload) => encode(payload)
});
Socket.IO servers
keepline/socket-io lets a keepline socket talk to a Socket.IO v4 server (Engine.IO v4, Socket.IO protocol v5) over a plain WebSocket. Use it when the server speaks Socket.IO but you want keepline's reconnection, status and observability, or simply don't want socket.io-client in the bundle. It is a codec, not a second client: it plugs into createSocket and useSocket through their options.
import { createSocket } from 'keepline';
import { socketIo, socketIoUrl } from 'keepline/socket-io';
interface ServerEvents { NEW: (notification: Notification) => void }
interface ClientEvents { markRead: (id: string) => void }
const io = socketIo<ServerEvents, ClientEvents>({
auth: async () => ({ token: `Bearer ${await auth.getFreshToken()}` }), // re-run on every connect
namespace: '/', // the default
onConnectError: (error) => log.warn(error.reason, error.message, error.data)
});
const socket = createSocket({
url: socketIoUrl('https://api.example.com', { language: 'en' }),
...io.options
});
io.on(socket, 'NEW', (notification) => show(notification));
io.emit(socket, 'markRead', id); // queued until the server admits us
The event maps use the same shape as socket.io's typed events, so existing interfaces can be reused. Every inbound event is also an ordinary keepline message, { event, args }, so socket.onMessage, useSocketMessage and useLastMessage work as usual; message.event === 'NEW' narrows args. In React, spread io.options into useSocket and pass its result to io.emit. Create the codec once (module scope or useMemo), not per render: the hook keeps the codec it was created with. Make auth a function that reads the current token, so it is fresh on every reconnect. With share: true, give each namespace its own key, because the namespace is not part of the URL.
What it handles, per connection:
| Frame | From | Handling |
|---|---|---|
0{…pingInterval, pingTimeout…} | server | Engine.IO handshake. Answered with CONNECT: 40, 40{auth}, or 40/ns,{auth} for a namespace. |
40{"sid":…} | server | CONNECT acknowledged. Only now does keepline report open and flush its queue. |
2 | server | Ping, answered with 3 at once. If none arrives within pingInterval + pingTimeout, the connection is closed as 1006 socket.io: ping timeout and retried. |
42[name, …args], 42/ns,[…] | both | Events. |
44{"message":…} | server | CONNECT_ERROR: a refusal, see below. |
41, 1 | server | Namespace disconnect / Engine.IO close: after the ack, an ordinary disconnect, closed as 1000/1001 and retried; in answer to the CONNECT, a refusal. |
status === 'open' therefore means the server acknowledged the CONNECT, not merely that the WebSocket opened, and keepline's connectTimeoutMs covers the whole handshake. Liveness comes from the server's own ping cadence, so leave heartbeat and staleAfterMs unset.
Refused connections are not retried. A server refuses with a 44 CONNECT_ERROR (an auth middleware calling next(new Error(...))), by ending the session (1 or 41) instead of acknowledging, or by closing the WebSocket right after the client's 40, typically with a bare 1006. All are reported as a 1008 close (category: 'policy', exported as REFUSED_CLOSE_CODE), which keepline never retries, so an expired token does not turn into a reconnect loop. A throwing auth() is treated the same way. The socket settles closed, and the app calls socket.reconnect() once it has fresh credentials:
onClose: ({ category, willReconnect }) => {
if (category === 'policy' && !willReconnect) refreshThenReconnect();
}
onConnectError is called exactly once for every one of these refusals, just before the 1008 close, so it is the one place to report "connection refused". Its SocketIoConnectError says how the refusal arrived in error.reason:
reason | What happened | Detail |
|---|---|---|
'connect-error' | 44 CONNECT_ERROR | The server's message, and its data |
'auth-failed' | auth() threw, rejected or returned something unsendable; nothing was sent | The original error as cause |
'session-ended' | The server sent 1 or 41 instead of acknowledging | — |
'closed-before-ack' | The WebSocket closed after the 40, before the ack | closeCode, also in the message |
onConnectError: (error) => {
if (error.reason === 'connect-error') showLoginError(error.message, error.data);
else log.warn(`socket.io refused (${error.reason}): ${error.message}`);
}
A close before the CONNECT is sent (before the Engine.IO handshake, or while an async auth() resolves) is still an ordinary network failure and is retried, as is a close carrying a deliberate "come back later" code (1001, 1012, 1013, 1014) even while the CONNECT is pending. Any other close in that window can't be told apart from a refusal, so it counts as one.
A server-side socket.disconnect() is retried like any other disconnect. If your server uses it to kick clients, veto that one case, or the client reconnects straight back:
reconnect: {
shouldReconnect: ({ reason }) => reason !== 'socket.io: server disconnected the namespace'
}
Tokens don't have to come from the browser at all: a proxy in front of the Socket.IO server can inject them into the CONNECT, in which case the client sends a bare 40 and needs no auth option.
Pass a custom WebSocket constructor (ws on Node < 22, mockSocketFactory in tests) as socketIo({ socketFactory }), not to createSocket, where it would replace the codec's own.
Out of scope, by design: the long-polling transport (the URL always asks for transport=websocket), binary attachments (45/46), and acknowledgements (43, or an event that asks for one). These arrive as decode-error events naming what came in, never as silently wrong messages, and an outbound payload containing binary is refused at encode rather than sent as {}.
In a Node service
Node 22+ has a global WebSocket, so nothing changes. On older Node, hand in ws:
import WebSocket from 'ws';
import type { WebSocketLike } from 'keepline';
const socket = createSocket({
url: 'wss://api.example.com/feed',
socketFactory: (url, protocols) =>
new WebSocket(url, protocols) as unknown as WebSocketLike
});
await socket.waitForOpen();
One connection for the whole app
<SocketProvider options={{ url, heartbeat: { message: { type: 'ping' } } }}>
<App />
</SocketProvider>
// anywhere below:
const socket = useSocketContext<ServerMessage>();
useSocketMessage(socket, (m) => store.apply(m));
Or keep components independent and let them share by identity: useSocket({ url, share: true }) reference-counts one connection across every hook with the same key/url.
Rendering, deliberately
Three ways to consume messages, in order of cost:
useSocketMessage(socket, (m) => ref.current?.update(m)); // no re-render (default)
const last = useLastMessage(socket, { filter }); // one re-render per match
const metrics = useSocketMetrics(socket); // sampled once a second
useLastMessage is opt-in on purpose. A library that stores every message in state by default makes the fast path the one you have to disable — which is why so much react-use-websocket code carries filter: () => false.
Observability
import * as Sentry from '@sentry/react';
import { createSentryReporter } from 'keepline/sentry';
createSocket({ url, onEvent: createSentryReporter({ sentry: Sentry }) });
Breadcrumbs for connects, close codes, retries and validation failures; captured exceptions for exhausted retries and every failure that carries a real error — everything except the transport's contentless error event. URLs are stripped of query strings by default, and payloads are never recorded unless you ask — tokens live in query strings and breadcrumbs are forever.
When an unrelated exception is reported ten seconds after the feed quietly dropped and retried four times, that trail is the difference between a five-minute diagnosis and an unreproducible ticket.
Wiring onError into a tracker yourself? Filter on phase, and socket is the only one to drop. It is the browser's bare error event — no status, no reason, and it fires on ordinary disconnects — so capturing it yields one exception per user per session and nothing to act on. Every other phase carries a real error: decode and validation mean a frame arrived your app could not use, send means the transport refused a write, and encode and listener are a throw from your own code.
createSocket({
url,
onError: (error, phase) => {
if (phase === 'socket') return;
Sentry.captureException(error);
}
});
createSentryReporter applies exactly this rule, so onEvent: createSentryReporter({ sentry: Sentry }) needs no filter of its own.
Testing
import { createSocket } from 'keepline';
import { MockWebSocket, mockSocketFactory } from 'keepline/testing';
const socket = createSocket({ url: 'wss://x', socketFactory: mockSocketFactory });
const ws = MockWebSocket.last();
ws.acceptConnection();
ws.serverSend({ type: 'tick' });
ws.serverClose({ code: 1006 }); // a real retry, with real backoff
expect(socket.status).toBe('reconnecting');
The usual approach — vi.mock('the-websocket-library') — stubs out the transport and the reconnection logic, which means the reconnection logic is the one part never tested. Here it runs.
Design notes
Why useSyncExternalStore. The socket is an external store, so React reads its status at render time instead of mirroring it into state via an effect. That removes the window in which a component renders a status that is already stale, and makes the bindings correct under concurrent rendering and StrictMode double-mounting.
Why a core/bindings split. Non-React consumers — a Node service, a chart controller, a Vue app — are a first-class audience, and keepline pulls in no React for them. It also keeps the React layer thin enough to audit: it holds no connection logic of its own.
Why no any. Enforced by lint. unknown plus a narrowing point, or a real type.
Contributing
bun install
bun run test # vitest
bun run verify # lint + typecheck + test + build
Changes are released with changesets — run bun run changeset and describe the change.
License
MIT