Guides
Build a Production FOMO WebSocket Client
A resilient Node.js client for the FOMO app-feed stream, including heartbeats, stable event IDs, filtered subscriptions, gap recovery, and reconnect control.

A WebSocket that connects once is a demo. A production FOMO WebSocket client must survive quiet filters, network changes, server restarts, replayed events, malformed frames, and repeated disconnects without losing control of its retry rate.
This guide builds that client against the app-feed stream at wss://api.fomoapi.io/ws/alerts. The separate on-chain stream at /ws/trades has different plan requirements and a different message shape.
Understand the connection lifecycle
An alerts connection sends three important message types:
welcomedescribes the stream, active filter, heartbeat interval, and delivery delay.alertcarries an event. Replayed events havereplay: true.heartbeatproves the application connection is alive even when no alert matches your filter.
The server also uses WebSocket ping and pong frames, but browsers and some libraries do not expose those frames to application code. Monitor the JSON heartbeat rather than reconnecting simply because a selected trader has not traded recently.
The current message contract and filter list are in the realtime documentation.
Define only the fields the client needs
type Welcome = {
type: "welcome";
stream: "alerts";
realtime: boolean;
delaySeconds: number;
heartbeatSeconds: number;
filter: unknown;
};
type Heartbeat = {
type: "heartbeat";
ts: number;
lastEventAt: number | null;
quietSeconds: number | null;
buffered: number;
};
type Alert = {
type: "alert";
alertType: string;
eventId: string | null;
userId: string | null;
tradeId: string | null;
trader: string | null;
token: string | null;
tokenAddress: string | null;
chainId: number | null;
chain: string | null;
source: "feed" | "push";
ts: number;
replay?: boolean;
};
type StreamMessage = Welcome | Heartbeat | Alert;
Push-sourced alerts do not necessarily carry the same stable IDs as feed events. Code must accept null rather than substituting a handle or token symbol as if it were a durable event identifier.
Connect with bounded backoff
Node applications can use the ws package. The API key appears in the query string because browser WebSocket clients cannot set an Authorization header. Treat the complete URL as a secret and never include it in logs.
import WebSocket from "ws";
const apiKey = process.env.FOMO_API_KEY;
if (!apiKey) throw new Error("FOMO_API_KEY is required");
let socket: WebSocket | null = null;
let reconnectAttempt = 0;
let reconnectTimer: NodeJS.Timeout | null = null;
let stopped = false;
function reconnectDelay(attempt: number) {
const ceiling = Math.min(30_000, 500 * 2 ** attempt);
return Math.round(ceiling * (0.5 + Math.random() * 0.5));
}
function scheduleReconnect() {
if (stopped || reconnectTimer) return;
const delay = reconnectDelay(reconnectAttempt++);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, delay);
}
function connect() {
if (stopped) return;
const url = new URL("wss://api.fomoapi.io/ws/alerts");
url.searchParams.set("key", apiKey);
url.searchParams.set("chain", "robinhood");
socket = new WebSocket(url);
socket.on("message", data => receive(String(data)));
socket.on("close", scheduleReconnect);
socket.on("error", () => socket?.close());
}
Do not reset reconnectAttempt as soon as the TCP connection opens. Reset it after a valid welcome frame, which proves the application protocol is functioning.
Use the heartbeat as the liveness clock
Maintain the last time any valid application frame arrived. The server announces its expected heartbeat interval in the welcome message, so the watchdog can allow more than one interval before declaring the connection stale.
let lastFrameAt = 0;
let heartbeatMs = 20_000;
let watchdog: NodeJS.Timeout | null = null;
function armWatchdog() {
if (watchdog) clearInterval(watchdog);
watchdog = setInterval(() => {
if (!socket || socket.readyState !== WebSocket.OPEN) return;
if (Date.now() - lastFrameAt > heartbeatMs * 2.5) socket.terminate();
}, Math.max(5_000, heartbeatMs));
}
function receive(raw: string) {
let message: StreamMessage;
try {
message = JSON.parse(raw) as StreamMessage;
} catch {
return;
}
lastFrameAt = Date.now();
if (message.type === "welcome") {
reconnectAttempt = 0;
heartbeatMs = Math.max(5_000, message.heartbeatSeconds * 1000);
armWatchdog();
console.log({ realtime: message.realtime, delaySeconds: message.delaySeconds });
return;
}
if (message.type === "heartbeat") return;
if (message.type === "alert") processAlert(message);
}
The delivery delay is product state. A client should expose it in health telemetry instead of labeling every connected socket “realtime.”
Deduplicate replayed events
A reconnect receives recent matching alerts for context. Process that replay when it fills a gap, but do not execute the same downstream action twice.
const recentIds = new Map<string, number>();
const DEDUPE_TTL_MS = 30 * 60 * 1000;
function seen(eventId: string | null) {
if (!eventId) return false;
const now = Date.now();
for (const [id, at] of recentIds) {
if (now - at > DEDUPE_TTL_MS) recentIds.delete(id);
}
if (recentIds.has(eventId)) return true;
recentIds.set(eventId, now);
return false;
}
function processAlert(alert: Alert) {
if (seen(alert.eventId)) return;
if (!['buy', 'sell'].includes(alert.alertType)) return;
console.log({
trader: alert.trader,
side: alert.alertType,
token: alert.token,
contract: alert.tokenAddress,
chain: alert.chain,
replay: alert.replay === true,
});
}
For financial or notification side effects, persist the event ID with a uniqueness constraint. An in-memory cache protects one process but cannot deduplicate across replicas or restarts.
Change filters without reconnecting
The alerts stream accepts filters in the connection URL and at runtime. A runtime subscription lets one socket switch traders without creating a reconnect gap:
function followTrader(handle: string) {
if (socket?.readyState !== WebSocket.OPEN) return;
socket.send(JSON.stringify({ action: "subscribe", trader: handle }));
}
function clearFilter() {
if (socket?.readyState !== WebSocket.OPEN) return;
socket.send(JSON.stringify({ action: "unsubscribe" }));
}
The server confirms the active filter with a subscribed frame. Filters can cover trader, token, chain, source, and alert type. Use contract addresses and stable IDs for joins whenever they are present; handles and symbols can change or collide.
Recover a suspected gap through REST
The REST endpoint GET /v2/alerts is the fallback for recent app-feed activity. Store the newest processed timestamp and, after reconnecting, request events since that point:
async function recoverSince(lastTimestamp: number) {
const url = new URL("https://api.fomoapi.io/v2/alerts");
url.searchParams.set("since", String(lastTimestamp));
url.searchParams.set("limit", "100");
const response = await fetch(url, {
headers: { authorization: `Bearer ${apiKey}` },
});
if (!response.ok) throw new Error(`gap recovery failed: ${response.status}`);
const body = await response.json() as { alerts?: Alert[] };
for (const alert of [...(body.alerts || [])].reverse()) processAlert(alert);
}
REST recovery is bounded by the server's available recent history. It is a recovery aid, not a promise of an unlimited event archive.
Shut down deliberately
function stop() {
stopped = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
if (watchdog) clearInterval(watchdog);
socket?.close(1000, "shutdown");
}
process.once("SIGTERM", stop);
process.once("SIGINT", stop);
connect();
A production client should also emit connection state, reconnect attempts, welcome delay, heartbeat age, replay count, duplicate count, and downstream failures to monitoring.
For the product-level differences between the streams, read the realtime alerts overview. For HTTP gap-recovery failures, use the bounded policy in the FOMO API error-handling guide.
Sources
- FOMO API OpenAPI 3.1 specification FOMO API, accessed 2026-09-17
- FOMO API realtime stream documentation FOMO API, accessed 2026-09-17
Ship on fomo.family trader data
Both-chain wallets, PnL and holdings, plus two live streams: the app feed on every plan, the on-chain stream on Growth. One API.
Get an API key