Building a Latency-Aware Crypto Signal Bus in Node.js
Most crypto trading bots start as a single loop: fetch candles, calculate indicators, decide, place an order, sleep, repeat. That model is fine for a prototype, but it becomes fragile when you connect multiple exchanges, multiple strategies, account events, alerts, and risk controls. At that point the bot is no longer a loop. It is an event system.
A signal bus is the internal layer that moves information between those parts. It receives market data, normalizes it, timestamps it, deduplicates it, and delivers it to strategy and execution components. In a real-time crypto system, the bus must do more than publish messages. It must protect the system from stale signals, duplicate ticks, clock drift, slow consumers, and accidental order storms.
This article walks through a practical design for a latency-aware signal bus in Node.js. The goal is not to build a full trading platform in one file. The goal is to create a clean internal pattern you can extend as your bot grows.
Why a Signal Bus Matters
Trading code usually fails at the boundaries between components. A WebSocket reconnects and replays data. A strategy receives the same candle twice. A risk module is late by 700 milliseconds. An execution worker acts on an old signal because the process was under load.
Without a bus, every module solves those problems differently. The strategy checks timestamps. The executor checks timestamps again. The logger has its own retry logic. Eventually, the bot becomes difficult to reason about.
A signal bus gives you one place to enforce basic rules:
- Every event has a unique identity.
- Every event has an exchange timestamp and a local receive timestamp.
- Consumers can reject stale messages.
- Slow consumers do not block critical consumers.
- Duplicate messages are ignored before they reach trading logic.
The bus becomes a small operating system for your trading engine.
Event Shape
Start with a consistent event contract. Do not pass raw exchange payloads through your entire application. Normalize them at the edge.
const tick = {
id: "binance:BTCUSDT:trade:932847239",
type: "market.trade",
source: "binance",
symbol: "BTCUSDT",
exchangeTs: 1782839712345,
receivedTs: Date.now(),
data: {
price: 61420.5,
size: 0.013,
side: "buy"
}
};
The exchangeTs field tells you when the event happened at the exchange. The receivedTs field tells you when your process saw it. The difference is transport latency plus any local delay. Both matter.
For generated events, such as a strategy signal, keep the same structure:
const signal = {
id: "mean-reversion:BTCUSDT:1782839715000",
type: "strategy.signal",
source: "mean-reversion",
symbol: "BTCUSDT",
exchangeTs: candle.closeTs,
receivedTs: Date.now(),
data: {
action: "buy",
confidence: 0.71,
reason: "rsi-reversion"
}
};
This makes market events, strategy events, risk decisions, and execution events observable in the same way.
A Minimal Bus
Node.js already has EventEmitter, but a trading bus needs a few extra controls: deduplication, stale-event checks, and safe async delivery.
import { EventEmitter } from "node:events";
class SignalBus {
constructor({ maxAgeMs = 1500, dedupeSize = 5000 } = {}) {
this.emitter = new EventEmitter();
this.maxAgeMs = maxAgeMs;
this.seen = new Map();
this.dedupeSize = dedupeSize;
}
publish(event) {
this.validate(event);
if (this.seen.has(event.id)) {
return { accepted: false, reason: "duplicate" };
}
const age = Date.now() - event.exchangeTs;
if (age > this.maxAgeMs) {
return { accepted: false, reason: "stale", age };
}
this.remember(event.id);
queueMicrotask(() => {
this.emitter.emit(event.type, event);
this.emitter.emit("*", event);
});
return { accepted: true };
}
subscribe(type, handler) {
this.emitter.on(type, async (event) => {
try {
await handler(event);
} catch (error) {
this.emitter.emit("bus.error", { event, error });
}
});
}
remember(id) {
this.seen.set(id, Date.now());
if (this.seen.size > this.dedupeSize) {
const oldest = this.seen.keys().next().value;
this.seen.delete(oldest);
}
}
validate(event) {
if (!event.id || !event.type || !event.symbol) {
throw new Error("Invalid event: id, type, and symbol are required");
}
if (!Number.isFinite(event.exchangeTs)) {
throw new Error("Invalid event: exchangeTs is required");
}
}
}
This bus is intentionally small. It does not pretend to be Kafka or NATS. It gives a single Node.js process the discipline it needs before you introduce external infrastructure.
Measuring Latency
Latency should be measured at every important boundary. A useful trading bot logs more than errors. It logs timing.
bus.subscribe("*", (event) => {
const now = Date.now();
const exchangeLag = now - event.exchangeTs;
const localLag = now - event.receivedTs;
metrics.observe("event.exchange_lag_ms", exchangeLag, {
type: event.type,
symbol: event.symbol,
source: event.source
});
metrics.observe("event.local_lag_ms", localLag, {
type: event.type,
symbol: event.symbol
});
});
The distinction matters. If exchange lag is high, the problem may be network, exchange stream quality, or clock synchronization. If local lag is high, your process is slow. You may be blocking the event loop, doing too much synchronous work, or letting a consumer overload the process.
For a simple bot, logging percentiles every minute is enough:
setInterval(() => {
console.log(metrics.snapshot());
}, 60_000);
When you move toward production, export these values to Prometheus, Grafana, or your preferred observability stack.
Protecting Execution from Old Signals
The most important subscriber is usually the execution path. It should be stricter than the general bus. A market-data event that is two seconds old might still be useful for analytics. A buy signal that is two seconds old may be dangerous.
bus.subscribe("strategy.signal", async (event) => {
const signalAge = Date.now() - event.receivedTs;
if (signalAge > 300) {
audit.warn("signal_rejected_too_old", {
id: event.id,
symbol: event.symbol,
age: signalAge
});
return;
}
const riskDecision = await risk.check(event);
if (!riskDecision.approved) {
audit.info("signal_rejected_by_risk", riskDecision);
return;
}
await execution.enqueue({
signalId: event.id,
symbol: event.symbol,
action: event.data.action,
maxSlippageBps: riskDecision.maxSlippageBps
});
});
This gives you layered safety. The bus rejects globally stale events. The execution subscriber applies an even tighter threshold for order-related decisions.
Handling Backpressure
The easiest way to break a Node.js trading process is to let every subscriber perform heavy work directly inside event handling. Strategy calculation, database writes, chart generation, and notifications should not compete equally with execution.
Separate fast-path and slow-path consumers:
- Fast path: risk checks, execution queue, kill switch state.
- Slow path: analytics, dashboards, long-term storage, notifications.
For slow consumers, publish into a queue instead of doing work directly:
bus.subscribe("market.trade", async (event) => {
await analyticsQueue.add("trade", event, {
removeOnComplete: true,
attempts: 3
});
});
If you use Redis-backed queues such as BullMQ, your analytics worker can fall behind without delaying the strategy and execution path. That separation is often more valuable than micro-optimizing indicator code.
Idempotency Across the Pipeline
The bus deduplicates internal events, but execution needs its own idempotency too. Never assume that an event being unique means the order is unique at the exchange.
A good pattern is to derive a client order ID from the signal:
const clientOrderId = `bot-${event.source}-${event.symbol}-${event.id}`
.replace(/[^a-zA-Z0-9_-]/g, "")
.slice(0, 36);
Then store the execution attempt before calling the exchange:
await db.orders.insert({
clientOrderId,
signalId: event.id,
symbol: event.symbol,
status: "pending",
createdAt: new Date()
});
If the process crashes after sending the order but before receiving the response, you can reconcile by client order ID. This is one of the small details that separates a hobby bot from a system you can debug under pressure.
Testing the Bus
You do not need a live exchange to test this layer. Unit tests should cover the rules that protect money:
- Publishing the same event twice only delivers it once.
- Events older than
maxAgeMsare rejected. - A bad handler emits
bus.errorinstead of crashing the process. - Execution rejects signals older than its stricter threshold.
- Slow analytics consumers do not block the execution queue.
For integration tests, replay recorded exchange payloads at different speeds. Simulate reconnects, duplicates, and delayed messages. A signal bus is only useful if it behaves well during ugly data conditions.
Final Thoughts
A latency-aware signal bus is not the most glamorous part of a crypto trading bot, but it becomes one of the most important. It gives your system a shared language for time, identity, and delivery. It makes strategies easier to plug in. It makes execution safer. It makes failures easier to inspect.
Start small: normalize events, reject stale data, deduplicate IDs, measure lag, and keep slow work away from the critical path. Those decisions will pay for themselves as soon as your bot handles more than one stream, one strategy, or one exchange.
I am building Lucromatic, a trading automation project focused on practical crypto bot infrastructure. Demo: try.lucromatic.com
Top comments (0)