Why Pulse?
Raw WebSocket gives you an untyped message stream — no event routing, no reconnection, no presence, no lifecycle management. Building those primitives for every project is repetitive and error-prone.
ts
// Before — raw WebSocket
const ws = new WebSocket('wss://api.example.com/ws');
ws.addEventListener('message', (ev) => {
const { type, payload } = JSON.parse(ev.data); // untyped
if (type === 'chat:message') renderMessage(payload); // manual routing
});
ws.addEventListener('close', () => setTimeout(reconnect, 3_000)); // manual reconnect
// No channels, no presence, no heartbeat, no disposal
// After — Pulse
const pulse = createPulse<ServerEvents, ClientEvents>('wss://api.example.com/ws', {
reconnect: { maxAttempts: 5 },
heartbeat: true,
});
pulse.on('chat:message', ({ user, text }) => renderMessage({ user, text })); // fully typed
pulse.send('chat:send', { text: 'Hello!' });
effect(() => console.log('status:', pulse.status.value)); // reactive via ripple| Feature | Pulse | Native WebSocket | socket.io-client |
|---|---|---|---|
| Bundle size | 4.5 KB | 0 B (native) | ~44 kB gzip |
| TypeScript inference | |||
| Auto-reconnect | |||
| Heartbeat (ping/pong) | |||
| Channel multiplexing | |||
| Reactive presence | |||
| Reactive status | |||
| Server lock-in | |||
| Zero dependencies |
Use Pulse when you need typed, multiplexed real-time messaging with reactive state and a clean disposal lifecycle — without being locked to a specific server stack.
Consider native WebSocket when you need the absolute minimum footprint and are building a one-off, untyped connection with no reuse patterns.
Installation
sh
pnpm add @vielzeug/pulse @vielzeug/ripplesh
npm install @vielzeug/pulse @vielzeug/ripplesh
yarn add @vielzeug/pulse @vielzeug/rippleQuick Start
ts
import { createPulse } from '@vielzeug/pulse';
import { effect } from '@vielzeug/ripple';
type ServerEvents = {
'chat:message': { user: string; text: string };
'user:joined': { userId: string };
};
type ClientEvents = {
'chat:send': { text: string };
};
const pulse = createPulse<ServerEvents, ClientEvents>('wss://api.example.com/ws', {
reconnect: { maxAttempts: 5, delay: (n) => Math.min(1000 * 2 ** n, 30_000) },
heartbeat: true,
});
// Reactive status via ripple signal
effect(() => console.log('connection:', pulse.status.value));
// Typed server events
pulse.on('chat:message', ({ user, text }) => console.log(`${user}: ${text}`));
// Typed client messages
pulse.send('chat:send', { text: 'Hello!' });
// Isolated channel namespace
const notif = pulse.channel<{ alert: { level: string; msg: string } }>('notifications');
notif.on('alert', ({ level, msg }) => showNotification(level, msg));
// Reactive presence tracking
const lobby = pulse.presence<{ name: string; status: string }>('lobby');
effect(() => console.log('online:', [...lobby.state.value.keys()]));
lobby.update({ name: 'Alice', status: 'active' });
// Clean disposal
using _ = pulse;Features
- Typed event maps —
TServerandTClientgenerics enforce payload types on both sides of the wire on()/once()/wait()— persistent, one-shot, and async-await event subscriptionschannel()— isolated namespaces multiplexed over the shared connection; auto-resubscribed on reconnect;dispose()sends anunsubscribeframejoin()/leave()— room membership with server-confirmation promisespresence()— reactiveSignal<Map<memberId, T>>state, withonJoin/onLeavecallbacks andupdate()for broadcasting state- Middleware pipeline — intercept every outgoing
send()call; omitnext()to suppress - Auto-reconnect — exponential backoff (full-jitter by default), configurable
maxAttempts, customdelayfunction, andonReconnectcallback - Heartbeat — configurable ping/pong keep-alive with dead-connection detection and automatic reconnect trigger
- Reactive
statussignal —'connecting' | 'open' | 'reconnecting' | 'closed'exposed as a rippleReadonlySignal - Reactive
roomssignal — current room membership as aReadonlySignal<ReadonlySet<string>> disposalSignal—AbortSignalthat fires ondispose(); ties external cleanup to the connection lifetimedispose()and[Symbol.dispose]— deterministic teardown; closes the socket, clears all listeners, aborts pendingwait()calls- Protocol-agnostic — works with any WebSocket server that speaks the Pulse JSON frame format
- Single dependency — only requires
@vielzeug/ripplefor reactive state
Documentation
See Also
- Ripple — the reactive signal library powering
pulse.status,pulse.rooms, andpresence.state - Herald — typed in-process event bus; complement Pulse by bridging incoming WebSocket events to application-wide bus dispatches
- Courier — typed HTTP client for the request/response traffic that runs alongside your WebSocket connection
- Clockwork — finite state machine; model complex reconnection or auth-handshake logic as a proper state machine