Confirm a Solana Transaction over a WebSocket
Check a transaction landed on-chain: subscribe with signatureSubscribe before you submit, run the websocket inside a Web Worker so it never blocks the interface thread, and fall back to getSignatureStatuses if the websocket cannot answer.
Before We Begin
This guide picks up where sendTransaction left off. In that guide, we built a transfer, had the wallet sign it, and submitted it through FluxRPC. That left us holding a signature and a status line admitting the network has not confirmed anything yet. Now we find out what actually happened to it, so we can show the user a "success" (or "failure"!) message.
You will need the app from sendTransaction. Both index.html and app.js should be exactly as that guide left them. You'll also need a FluxRPC key (a free plan key is fine). Sign in and come back to this page, and we will substitute the key you select into every snippet below.
In this guide, we will accomplish the following:
- Read the signature out of the signed transaction bytes so we can subscribe first
- Move the WebSocket into a Web Worker
- Watch the signature with signatureSubscribe
- Handle errors and timeouts
We will provide examples in JavaScript in this guide.
This spends a small amount (0.001 by default) of real SOL! You can edit the example so that you send it to a wallet you control.
Why the WebSocket Belongs in a Worker
A WebSocket runs on whichever thread opened it. Open one from a <script> tag and every frame it receives is parsed and handled on the same thread that runs your layout, your event handlers, and your rendering.
A Worker has its own thread. Move the socket into one and the main thread only ever receives a small object already in the shape it needs, no matter how busy the socket gets.
The Files
One file joins the two you already have:
send-transaction/
├── index.html
├── app.js
└── confirm-worker.js
index.html does not change, no script tag loads a Worker, because app.js creates it in code. app.js gains the signature derivation and the Worker glue, and its send handler changes. confirm-worker.js runs on its own thread and only handles the websocket.
The snippets below follow the order of the two files, though some show one piece of a function rather than all of it. If you would rather have both finished files in one piece first, they are the first item below.
One constant goes in at the top of app.js, next to the two endpoints that are already there:
const FLUXRPC_WS_URL = "wss://ws.eu.fluxrpc.com?key=<Your-API-Key>";
The WebSocket URL carries your account key, not a Shield key
Shield keys cover the HTTP endpoints. The WebSocket host takes the account API key in its query string, and anything reachable from browser JavaScript is readable by anyone who loads the page.
On localhost that is fine, because the key never leaves your machine. Before this goes on a public site, put the actual websocket behind your own backend. Your server holds the account key, opens the FluxRPC socket, and relays notifications to the browser over a websocket of your own. None of the Worker code below changes; only the URL it is handed does.
Read the Signature Off the Signed Bytes
There is an ordering problem hiding in the send path!
The obvious sequence is: send the transaction, take the signature out of the response, then subscribe to it. But between sendTransaction returning and your subscription being registered, the transaction could already be on-chain, causing the websocket never to receive a notification.
Luckily, the wallet handed you that signature when it handed back the signed bytes. We read it out ourselves so we can subscribe first, then send.
function signatureOf(signedBytes) {
return getSignatureFromTransaction(getTransactionDecoder().decode(signedBytes));
}
Hand the Signature to the Worker
The main thread's part of the confirmation is deliberately small: create the Worker once, send it a message, and render whatever comes back. ensureConfirmWorker builds one on first use and hands back the same instance every time after: the Worker is idle between sends, so there is no reason to spawn a thread per transaction.
let confirmWorker = null;
let pendingSubscription = null;
function ensureConfirmWorker() {
if (confirmWorker) {
return confirmWorker;
}
confirmWorker = new Worker("./confirm-worker.js");
confirmWorker.addEventListener("message", (event) => {
handleWorkerMessage(event.data);
});
return confirmWorker;
}
function watchSignature(signature) {
const worker = ensureConfirmWorker();
worker.postMessage({
type: "watch",
signature,
wsUrl: FLUXRPC_WS_URL,
httpUrl:
FLUXRPC_SHIELD_RPC_URL + "?key=" + encodeURIComponent(FLUXRPC_SHIELD_KEY),
commitment: "processed",
});
return new Promise((resolve, reject) => {
pendingSubscription = { resolve, reject };
});
}
function cancelWatch() {
pendingSubscription = null;
confirmWorker?.postMessage({ type: "cancel" });
}
It returns a promise rather than firing and forgetting, and that promise is what makes the previous section's ordering work. Posting a message to a Worker is asynchronous, and the Worker still has to open a socket and get a subscription accepted after that. Our "Subscribe first" strategy only means anything if the caller waits for the subscription to actually exist, so the promise settles when the Worker says subscribed, and the send happens after.
handleWorkerMessage renders what comes back. subscribed resolves the promise above and failed reports a reason; the third and only interesting one is settled.
case "settled":
statusOutput.textContent = message.err
? "The transaction was included but failed on-chain."
: "Succeeded.";
resultOutput.textContent = JSON.stringify(
{ signature: message.signature, slot: message.slot, err: message.err, settledVia: message.via },
null,
2,
);
sendButton.disabled = false;
return;
err: null means the transaction landed and succeeded. A non-null err means it landed and failed: the fee was still paid, and the transfer did not happen. Those two outcomes look identical if all you check is that a notification arrived. Do not ship that bug!
Watch the Signature Inside the Worker
confirm-worker.js never touches the DOM, because it has no access to one. It opens a socket, subscribes, waits, and reports.
It starts with the state one watch needs:
const SUBSCRIBE_REQUEST_ID = 1;
const UNSUBSCRIBE_REQUEST_ID = 2;
const CONFIRMATION_TIMEOUT_MS = 5_000;
let activeSocket = null;
let activeSubscriptionId = null;
let timeoutId = null;
self.addEventListener("message", (event) => {
const request = event.data;
if (request.type === "watch") {
startWatch(request);
return;
}
if (request.type === "cancel") {
stopWatch();
}
});
Every watch ends by posting a settled or a failed back to the main thread and then calling stopWatch, which unsubscribes, closes the socket, and clears the deadline.
Opening the socket and subscribing:
function startWatch(request) {
stopWatch();
let socket;
try {
socket = new WebSocket(request.wsUrl);
} catch {
post({
type: "failed",
signature: request.signature,
message: "The worker could not open the WebSocket.",
});
return;
}
activeSocket = socket;
socket.addEventListener("open", () => {
if (activeSocket !== socket) return;
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id: SUBSCRIBE_REQUEST_ID,
method: "signatureSubscribe",
params: [request.signature, { commitment: request.commitment }],
}),
);
});
socket.addEventListener("message", (messageEvent) => {
if (activeSocket !== socket) return;
handleFrame(messageEvent.data, request);
});
socket.addEventListener("close", () => {
if (activeSocket !== socket) return;
activeSocket = null;
void settleOverHttp(
request,
"The WebSocket closed before the transaction was confirmed.",
);
});
socket.addEventListener("error", () => {
if (activeSocket !== socket || socket.readyState !== WebSocket.CLOSED) return;
activeSocket = null;
void settleOverHttp(request, "The WebSocket connection failed.");
});
timeoutId = setTimeout(() => {
void settleOverHttp(
request,
"The transaction was not confirmed in time. It may have been dropped.",
);
}, CONFIRMATION_TIMEOUT_MS);
}
settleOverHttp belongs to the fallback path, so it lives in the next section.
Reading what comes back:
function handleFrame(raw, request) {
let payload;
try {
payload = JSON.parse(raw);
} catch {
// Keepalives and anything else non-JSON are not our business.
return;
}
if (payload.id === SUBSCRIBE_REQUEST_ID) {
if (payload.error) {
fail(request, payload.error.message || "signatureSubscribe was rejected.");
return;
}
activeSubscriptionId = payload.result;
post({ type: "subscribed", subscriptionId: payload.result });
return;
}
if (payload.method === "signatureNotification") {
const result = payload.params?.result;
activeSubscriptionId = null;
settle(request, {
slot: result?.context?.slot ?? null,
err: result?.value?.err ?? null,
via: "websocket",
});
}
}
The notification carries no id. It names itself in method, and the outcome sits under params.result:
{
"jsonrpc": "2.0",
"method": "signatureNotification",
"params": {
"result": {
"context": {
"slot": 5207624
},
"value": {
"err": null
}
},
"subscription": 24006
}
}
signatureSubscribe sends exactly one of those and then has nothing left to say, so the Worker closes the socket as soon as it arrives.
When the Socket Cannot Answer
A subscription answers one question: did this signature reach my commitment level?. It answers it only while the websocket is alive and the transaction eventually lands. Two cases fall outside that, and both are common enough that production code has to cover them:
- The socket dropped. Networks do that. The transaction may well have confirmed during the gap.
- Nothing ever arrives. A transaction that gets dropped produces no notification at all, so a healthy socket will sit there quietly forever.
The answer for both is the same: ask over HTTP RPC to confirm in these cases. getSignatureStatuses is the request-response form of the same question.
async function settleOverHttp(request, failureMessage) {
clearTimer();
try {
const response = await fetch(request.httpUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "getSignatureStatuses",
params: [[request.signature], { searchTransactionHistory: true }],
}),
});
const payload = await response.json();
const status = payload.result?.value?.[0];
if (status) {
settle(request, { slot: status.slot ?? null, err: status.err ?? null, via: "http" });
return;
}
} catch {
// The network check itself did not answer, so fall through to the failure below.
}
fail(request, failureMessage);
}
The status puts err at the top level rather than under a value, so it reads { "err": null, "slot": 312884411, "confirmationStatus": "processed" }. An entry that is null means the network has never heard of the signature, which at the deadline means the transaction was dropped.
That is confirm-worker.js complete.
Rewire the Send Button
We're going to change the send handler from sendTransaction a little. Replace the handler with this:
sendButton.addEventListener("click", async () => {
sendButton.disabled = true;
resultOutput.textContent = "";
try {
statusOutput.textContent = "Building the transaction...";
const transactionBytes = await buildTransferTransaction(
recipientInput.value.trim(),
Number(amountInput.value),
);
statusOutput.textContent = "Waiting for you to approve it in the wallet...";
const signedBytes = await signTransaction(transactionBytes);
const signature = signatureOf(signedBytes);
statusOutput.textContent = "Subscribing to " + signature.slice(0, 8) + "...";
await watchSignature(signature);
statusOutput.textContent = "Sending through FluxRPC...";
await sendSignedTransaction(signedBytes);
} catch (error) {
cancelWatch();
statusOutput.textContent = error.message;
sendButton.disabled = false;
}
});
window.addEventListener("beforeunload", () => {
confirmWorker?.terminate();
});
The five steps read in the order they happen: build, sign, derive, subscribe, send. Putting the await on watchSignature is what fixes the ordering, because the send cannot start until the Worker has a live subscription.
cancelWatch() in the catch ends a subscription opened for a send that then failed, and the button stays disabled once the handler returns: the click is over, but the transaction is not. handleWorkerMessage re-enables it when the Worker reports back.
Run It on localhost
Serve the folder holding your three files:
npx serve . --listen 8000
Then open http://localhost:8000 in your browser. Type the address yourself rather than clicking whatever the terminal printed, because wallets refuse to connect to a non-HTTPS site unless it is specifically localhost.
Connect, fill in a recipient you control, leave the amount at 0.001, and send. The status line now moves through (subscribing, sending, succeeded) usually inside a second or two, without you opening an explorer:
{
"signature": "5wHu1qwD4kLp1nH8mQTSDZ9E3rTJ9k5eYqk2K8LQnW1sN1Qm4d7ZzB2FyRxNaVYTgKcPjHM3AoLdEuXqTbBhZfSc",
"slot": 312884411,
"err": null,
"settledVia": "websocket"
}
settledVia is stamped by the Worker rather than read off the network: an http there means the socket dropped or timed out and the RPC fallback answered instead.
Troubleshooting
| Symptom | Cause |
|---|---|
| Nothing happens, and the console shows a Worker error | confirm-worker.js is not sitting next to app.js, or is not being served over HTTP. A 404 for it surfaces as a silent Worker failure in some browsers. |
| The status shows a WebSocket error right after "Subscribing to…" and nothing is ever submitted | The socket opens before the transaction is sent, so a WebSocket URL that cannot connect stops the send outright. That URL takes the account API key, not the Shield key, and it needs a region host: ws.eu or ws.us, never the global CDN host, which has no WebSocket endpoint. |
Next Steps
The full list of subscriptions the socket supports is on the WebSocket reference. accountSubscribe and programSubscribe stream until you unsubscribe, so they are where the Worker you just built and the signatureUnsubscribe path earn their keep.
getBalance covers the read side of the same wallet connection, and sendTransaction is the first half of this app if you arrived here without it.