TL;DR
Stop polling for payment status. Use the Blockonomics public WebSocket endpoint to push status updates to your frontend the instant they occur. For production, relay these events through your backend to maintain a "source of truth" while keeping your UI responsive.
Polling Blockonomics every 5 seconds for payment status burns CPU on your server and adds up to 5 seconds of latency before your customer sees "Payment confirmed!" Blockonomics offers a better option: a public WebSocket endpoint at wss://www.blockonomics.co/payment/{address} that pushes status updates the instant they happen. The cleanest way to wire this up is to put a small Node.js relay between the browser and Blockonomics the browser opens a WebSocket to your relay, and the relay subscribes to Blockonomics on its behalf. That keeps the watched address and your order-handling logic on the server, where they belong, and gives you a single place to handle auto-reconnect if either leg of the connection drops.
I built a Bitcoin checkout with a 5-second polling loop on the payment page. It works, but it has two problems:
- It's slow. When the payment confirms on-chain, the customer waits up to 5 seconds before seeing the success message. Feels broken.
- It scales poorly. 1,000 customers staring at payment pages = 12,000 requests/minute hitting your server, almost all of them returning "still pending."
Blockonomics solved this with a public WebSocket endpoint. The moment a transaction status changes (detected on mempool→ 1 confirmation → 2 confirmations), the WS pushes a JSON message. No polling, no wasted requests, instant UX.
Here's how to wire it in.
The endpoint
wss://www.blockonomics.co/payment/{address}
One WebSocket per address. You open it, and immediately start receiving updates for any transaction sent to that address. Each message looks like this:
{
"status": 0,
"timestamp": 1470371749,
"value": 167377096,
"txid": "aed36253434b90e45ded86ccf1729f5d2acd78bd7665c54e62d5000035a8f6d8"
}
The fields are the same shape as the HTTP callback — status, value (satoshis), txid — plus a timestamp. The status progression is identical: 0 (detected on mempool) → 1 (partially confirmed) → 2 (confirmed).
Two important behaviours to know:
- When you connect, the WebSocket auto-detects the most recent payment to that address and starts tracking it. You don't need to send anything after connecting it just streams updates.
- You'll receive multiple messages per payment — one at status 0 (unconfirmed), one at 1 (partially confirmed), one at 2 (confirmed). Make your handler idempotent, since a reconnect can replay messages you've already processed.
Two patterns: browser-direct vs server-relay
There are two valid ways to wire this up, and the right choice depends on your trust model.
Pattern A: Browser connects directly to Blockonomics
The payment page in the customer's browser opens the WebSocket directly. Simplest setup, zero server load.
Use this when: your payment status doesn't need to drive any server-side logic until the HTTP callback (which fires separately) lands. Good for purely-visual updates on the payment page.
!image.png
Pattern B: Server relays WebSocket events to the browser
Your Node server opens the WebSocket to Blockonomics. When events arrive, you run business logic (mark order as paid, send email, etc.) and broadcast the event to the customer's browser over your own WebSocket or Server-Sent Events.
Use this when: you want WebSocket events to drive server-side logic in addition to UI updates for instance, if you want faster feedback than the HTTP callback can give, or want to display server-derived state to the user.
!image.png
I'll show both patterns in code. Pattern A is 15 lines of code; Pattern B is the production-grade choice and pairs cleanly with Express server.
Pattern A: Browser-direct (the 15-line version)
Open src/server.js, find the inline <script> on the /pay/:orderId page, and replace the setTimeout(check, 5000) polling block with this:
// In the payment page <script> block
const address = "${order.address}";
const ws = new WebSocket(`wss://www.blockonomics.co/payment/${address}`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
const el = document.getElementById('status');
if (data.status === 0) {
el.textContent = '⏳ Payment detected, waiting for confirmation…';
} else if (data.status === 1) {
el.textContent = '🔄 Partially confirmed, finalising…';
} else if (data.status === 2) {
el.textContent = '✓ Payment confirmed. Thank you!';
el.className = 'status confirmed';
ws.close();
}
};
ws.onerror = (err) => console.error('WS error', err);
ws.onclose = () => console.log('WS closed');
That's it. The browser opens a WebSocket directly to Blockonomics, the customer sees status updates the instant they happen, and your Express server doesn't see any payment-status traffic at all.
The HTTP callback (from /webhook/blockonomics endpoint) still fires separately to update your database — they're two independent channels for the same events. Belt and braces.
Pattern B: Server-relay with reconnection
For production, you want your server in the loop. Here's the full setup.
Step 1: Add ws and a small event bus
npm install ws
// src/payments-relay.js
import WebSocket from "ws";
import { EventEmitter } from "node:events";
import { updateOrderStatus, getOrderByAddress } from "./db.js";
// Internal event bus — your /pay page subscribes to this via SSE
export const paymentBus = new EventEmitter();
// Track open upstream WebSockets by address so we don't open duplicates
const upstreams = new Map();
export function watchAddress(address) {
if (upstreams.has(address)) return; // already watching
const connect = () => {
const ws = new WebSocket(`wss://www.blockonomics.co/payment/${address}`);
upstreams.set(address, ws);
ws.on("message", (raw) => {
let data;
try {
data = JSON.parse(raw.toString());
} catch (e) {
return console.error("bad WS payload", e);
}
handlePaymentEvent(address, data);
});
ws.on("close", () => {
upstreams.delete(address);
console.log(`[ws] ${address} closed, reconnecting in 5s`);
setTimeout(connect, 5000);
});
ws.on("error", (err) => {
console.error(`[ws] error on ${address}:`, err.message);
// 'close' fires after 'error', so reconnect happens there
});
};
connect();
}
function handlePaymentEvent(address, data) {
const order = getOrderByAddress(address);
if (!order) {
console.warn("[ws] event for unknown address", address);
return;
}
// Sanity-check the amount
const valueBtc = data.value / 1e8;
if (valueBtc < order.amountBtc * 0.95) {
updateOrderStatus(order.orderId, "underpaid", { txid: data.txid });
paymentBus.emit(order.orderId, { ...data, valueBtc, derived: "underpaid" });
return;
}
// Map status
let derived = "pending";
if (data.status === 0) derived = "partial";
if (data.status === 1) derived = "partial";
if (data.status === 2) derived = "paid";
updateOrderStatus(order.orderId, derived, { txid: data.txid });
paymentBus.emit(order.orderId, { ...data, valueBtc, derived });
}
A few things worth highlighting:
- One WS connection per address, tracked in a Map so reconnects don't duplicate.
- Auto-reconnect with 5s backoff on close. In a real production system you'd want exponential backoff capped at 60s — keeping it simple here for clarity.
- The paymentBus EventEmitter lets the rest of the app subscribe to events for a specific orderId. The SSE endpoint below uses this.
Step 2: Add a getOrderByAddress helper to db.js
// src/db.js (add to the existing module)
export function getOrderByAddress(address) {
return db.prepare("SELECT * FROM orders WHERE address = ?").get(address);
}
Step 3: Start watching when an order is created
In /checkout handler, after createOrder(...), add:
import { watchAddress } from "./payments-relay.js";
// ...inside the /checkout handler, right after createOrder(...)
watchAddress(address);
Now every new order automatically spins up a WebSocket to Blockonomics for that address.
Step 4: Stream events to the browser with SSE
You don't need to expose a WebSocket to the browser — Server-Sent Events are simpler, work over plain HTTP, and Express handles them with no extra dependencies:
// src/server.js — replace the /pay/:orderId/status endpoint with this
import { paymentBus } from "./payments-relay.js";
app.get("/pay/:orderId/events", (req, res) => {
const { orderId } = req.params;
const order = getOrder(orderId);
if (!order) return res.status(404).end();
// SSE headers
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no", // disable nginx buffering
});
res.flushHeaders();
// Send current state immediately so the page doesn't sit blank on reload
res.write(`data: ${JSON.stringify({ derived: order.status })}\n\n`);
const listener = (payload) => {
res.write(`data: ${JSON.stringify(payload)}\n\n`);
};
paymentBus.on(orderId, listener);
// Heartbeat every 25s so the connection doesn't time out behind proxies
const heartbeat = setInterval(() => res.write(": ping\n\n"), 25000);
req.on("close", () => {
clearInterval(heartbeat);
paymentBus.off(orderId, listener);
});
});
Step 5: Update the payment page to use SSE
Replace the polling <script> block on the /pay/:orderId page with:
<script>
const es = new EventSource('/pay/${order.orderId}/events');
const el = document.getElementById('status');
es.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.derived === 'pending') {
el.textContent = 'Waiting for payment…';
} else if (data.derived === 'partial') {
el.textContent = '⏳ Payment detected — confirming on-chain…';
} else if (data.derived === 'paid') {
el.textContent = '✓ Payment confirmed. Thank you!';
el.className = 'status confirmed';
es.close();
} else if (data.derived === 'underpaid') {
el.textContent = '⚠️ Underpayment received — please contact support.';
}
};
es.onerror = () => {
// EventSource auto-reconnects by default, but log it
console.warn('SSE connection lost, retrying…');
};
</script>
That's the full server-relay flow. The customer's browser holds one HTTP connection (SSE), your server holds one WS connection per active order, and Blockonomics pushes events the moment they happen.
Should you still use the HTTP callback?
Yes. Keep both channels.
The WebSocket is great for UX latency, but it has two properties that make it unsafe as your only source of truth:
- It's not retried. If your server crashes for 30 seconds, you miss any events that occurred during the outage.
- It's only active while you're connected. If you restart your process and a payment landed during the restart, the WS won't replay it.
The HTTP callback is retried up to 7 times with exponential backoff and is the authoritative record. Use the WebSocket for the live UI; trust the callback for "yes, this order is actually paid, fulfil it."
This pattern — fast channel for UX, slow channel for truth — is the standard architecture for any real-time payment system. Stripe does the same with their Elements SDK vs webhooks.
Common pitfalls
Opening a WS per request instead of per address. If you call watchAddress() from inside an HTTP request handler with no dedup, every page refresh opens another WebSocket. The upstreams Map prevents this — keep it.
Forgetting to close the WS after status=2. Blockonomics will keep the connection alive indefinitely. For a one-off payment, close the upstream after confirmation:
if (derived === "paid") {
const ws = upstreams.get(address);
ws?.close();
upstreams.delete(address);
}
SSE behind nginx. nginx buffers responses by default, which silently breaks SSE — events queue up and only flush in bursts. Set proxy_buffering off; and proxy_cache off; on the SSE location, or use the X-Accel-Buffering: no header (already in the code above).
Connection limits. If you're handling thousands of concurrent active orders, that's thousands of upstream WebSockets. Most VPS providers cap open sockets at ~1024 per process. Raise the ulimit (ulimit -n 65536) or shard across multiple processes.
Top comments (0)