getBlock Solana Developer Guide
Build an application that lists Solana blocks as the chain produces them. Start by fetching one block with getBlock, then improve upon your application using Yellowstone-over-websockets ("Yellowsockets").
Reference documentation: getBlock
Before We Begin
getBlock returns one block, so having the current data means calling it repeatedly (polling). This is simple, but presents a problem: how often do you request the latest block? Solana forms new blocks every 400 milliseconds, but exactly timing your requests is not practical. So you end up either making unnecessary requests, or introducing latency. Any polling strategy that ameliorates one of those difficulties makes the other worse; they are competing optimums.
Since we are an RPC provider, and we have metrics on how people are using our RPC, we know that there are some applications out there that are polling getBlock inefficiently. We want your application to be faster and better, so we wrote this guide! We'll cover frontend applications in this guide (although the strategies are general), starting with the polling method and proceeding to use more efficient streaming methods.
You might also be asking "Why would anyone need a guide to get a block in a frontend?". That's a very good question. Our data shows some people doing it, we assume for a good reason. If that's you, then this is your lucky day! Your app is about to get much faster by reading this guide.
You will need a FluxRPC API key. A free key is sufficient for some of this guide. Most parts will require a paid key though (any plan is OK), since we will be using Yellowstone over websockets, which requires a paid API key. Everything here is read-only, so no wallet and no SOL is required.
Frontend Guide
We will build a single page that lists Solana blocks as they are produced, one new row every ~400 milliseconds, showing the slot, the blockhash and how many transactions were in it. Click any row and the page fetches that one block and shows what was inside. We'll use streaming for the high-level block data, only polling getBlock when we need the details of a specific block.
In this guide, we will accomplish the following:
- Read one block with getBlock
- See why a loop over every block is inefficient
- Open a stream and subscribe
- Receive blocks from the stream
- Fetch a full block only when needed
We will provide examples in JavaScript in this guide.
Read One Block with getBlock
Solana measures time in slots. A slot is the 400 millisecond window in which one chosen validator may produce one block. A block is identified by the slot it was produced in, so to ask for a block you need its slot number. We can use getSlot to get the most recent slot, and then fetch the block in it.
Replace the block number in getBlock with the result of running getSlot. If you use the number in the example below it will not succeed.
curl "https://eu.fluxrpc.com?key=<Your-API-Key>" -s -X POST -H "Content-Type: application/json" -d '
{
"jsonrpc": "2.0",
"id": 1,
"method": "getSlot",
"params": []
}'
{
"jsonrpc": "2.0",
"result": 439186001,
"id": 1
}
Pass that number to getBlock as the first parameter:
curl "https://eu.fluxrpc.com?key=<Your-API-Key>" -s -X POST -H "Content-Type: application/json" -d '
{
"jsonrpc": "2.0",
"id": 1,
"method": "getBlock",
"params": [
439186001,
{
"encoding": "json",
"transactionDetails": "none",
"maxSupportedTransactionVersion": 1
}
]
}'
{
"jsonrpc": "2.0",
"result": {
"blockHeight": 417237399,
"blockTime": 1786693960,
"blockhash": "E3LnpvQ8VonK9Nw2rZN6P5PgE9yVco184mCRVS7WwChP",
"parentSlot": 439186000,
"previousBlockhash": "2XxeU8uLoEradwkUv2YU6sH8BryB3dTPgtcDWKLrigCZ",
"rewards": []
},
"id": 1
}
That response is small because transactionDetails was set to "none". That parameter decides most of what the call costs. When we fetch two mainnet blocks, measured at each setting:
| slot | transactions | full | accounts | signatures | none |
|---|---|---|---|---|---|
| 438782542 | 1,353 | 5.90 MB | 3.78 MB | 0.12 MB | 245 B |
| 438782541 | 1,609 | 9.41 MB | 5.78 MB | 0.15 MB | 245 B |
Use "none" when you only want to know a block exists, "signatures" when you want the list of transactions in it, and "full" only when you need what those transactions actually did. For the parameter maxSupportedTransactionVersion, 1 is the highest value there is, now that v1 transactions are live.
For Whom the Bell Polls
That timer would look something like this:
setInterval(async () => {
const slot = await callRpc("getSlot", []);
const block = await callRpc("getBlock", [
slot,
{ encoding: "json", transactionDetails: "full", maxSupportedTransactionVersion: 1 },
]);
render(block);
}, 400);
It calls getBlock every 400 milliseconds. However, it's unlikely that every user's browser will call getBlock the moment a block is formed. So there will be a delay between the block forming, and when their browser requests the block. We've seen some applications try to mitigate this by just... calling getBlock every 200 milliseconds. At the cost of double the bandwidth, it reduces latency only a little.
This is sort of like having a bell that rings every 400 milliseconds, and instead of just listening, you ask someone if the bell has rung, over and over, as quickly as you can.
Our equivalent to "just listen for the bell" is streaming. We'll just send you new blocks as they are formed, no need to ask for them individually. Generally, you will want to handle this streaming from your application's backend, not the frontend (even if it's just a proxy). This is to protect your application's API key. However, if you are building a personal dashboard, or an application where users bring their own FluxRPC API key, we could optimize the frontend enough to be practical! So in the spirit of doing interesting things, we'll continue this example.
Websocket vs. Yellowsocket Streaming
The most powerful way to stream Solana data is Yellowstone gRPC. The stream subscriptions and filters have detailed options that allow you to tailor your stream to have just the data you need. However, Yellowstone gRPC doesn't work from frontend applications!
To resolve this issue, we allow websocket connections to our Yellowstone gRPC server. It still works exactly like Yellowstone, but you can use it from a frontend if you want! It's not a proxy, so you will receive data without any latency penalty.
The Solana RPC standard defines a blockSubscribe method that will also stream blocks to you. However, we will use Yellowsockets for our example instead because:
- Yellowsockets (Yellowstone over websockets) is more powerful and lets you access more data!
blockSubscribeon the standard websocket method gives you all blocks by default. Yellowsockets lets you decide the conditions under which you will be sent a block.- The
blockSubscribewebsocket method is listed as "Unstable" in the Solana RPC standard anyway.
The Files
Create a folder with two files in it. Paste these in, and the rest of the guide walks through app.js one piece at a time.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Solana block feed</title>
</head>
<body>
<h1>Solana block feed</h1>
<p>
<label for="apiKey">FluxRPC API key</label>
<input id="apiKey" type="password" size="40" placeholder="your Yellowstone-enabled key" />
<button id="startButton">Start</button>
<button id="stopButton" disabled>Stop</button>
</p>
<p id="statusLine">Idle.</p>
<table>
<thead>
<tr>
<th>Slot</th>
<th>Blockhash</th>
<th>Transactions</th>
<th>Gap</th>
<th></th>
</tr>
</thead>
<tbody id="blockRows"></tbody>
</table>
<pre id="detailOutput"></pre>
<script src="app.js" defer></script>
</body>
</html>
The API key goes in an input box rather than into the source code. Just so you don't accidentally hand it out.
const YELLOWSTONE_URL = "wss://yellowstone.eu.fluxrpc.com";
const FLUXRPC_SHIELD_KEY = "{{SHIELD_KEY}}";
const FLUXRPC_SHIELD_RPC_URL = "{{SHIELD_RPC_URL}}";
const MAX_ROWS = 25;
const apiKeyInput = document.getElementById("apiKey");
const startButton = document.getElementById("startButton");
const stopButton = document.getElementById("stopButton");
const statusLine = document.getElementById("statusLine");
const blockRows = document.getElementById("blockRows");
const detailOutput = document.getElementById("detailOutput");
let socket = null;
let wantConnection = false;
let reconnectDelay = 1000;
let previousBlockTime = 0;
function buildBlockMetaRequest() {
return {
accounts: {},
slots: {},
transactions: {},
transactionsStatus: {},
blocks: {},
blocksMeta: { new_blocks: {} },
entry: {},
commitment: "PROCESSED",
accountsDataSlice: [],
};
}
function connect() {
const apiKey = apiKeyInput.value.trim();
if (!apiKey) {
statusLine.textContent = "Enter your API key first.";
return;
}
socket = new WebSocket(`${YELLOWSTONE_URL}/?key=${apiKey}&encoding=json`);
socket.addEventListener("open", () => {
reconnectDelay = 1000;
socket.send(JSON.stringify(buildBlockMetaRequest()));
statusLine.textContent = "Subscribed. Waiting for the first block.";
});
socket.addEventListener("message", (event) => {
const update = JSON.parse(event.data);
if (!update.blockMeta) return;
addBlockRow(update.blockMeta);
});
socket.addEventListener("close", (event) => {
socket = null;
if (!wantConnection) {
statusLine.textContent = "Stopped.";
return;
}
statusLine.textContent = `Disconnected (code ${event.code}). Reconnecting.`;
setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
});
}
function addBlockRow(meta) {
const slot = Number(meta.slot);
const transactions = Number(meta.executedTransactionCount ?? 0);
const blockTime = Number(meta.blockTime?.timestamp ?? 0);
const gap = previousBlockTime ? `${blockTime - previousBlockTime}s` : "";
previousBlockTime = blockTime;
const row = document.createElement("tr");
row.innerHTML = `
<td>${slot}</td>
<td>${meta.blockhash.slice(0, 8)}...</td>
<td>${transactions}</td>
<td>${gap}</td>
<td><button data-slot="${slot}">Detail</button></td>
`;
blockRows.prepend(row);
while (blockRows.children.length > MAX_ROWS) {
blockRows.lastElementChild.remove();
}
statusLine.textContent = `Streaming. Latest slot ${slot}.`;
}
async function callRpc(method, params) {
const response = await fetch(
FLUXRPC_SHIELD_RPC_URL + "?key=" + encodeURIComponent(FLUXRPC_SHIELD_KEY),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
},
);
const payload = await response.json();
if (payload.error) throw new Error(payload.error.message);
return payload.result;
}
async function showBlockDetail(slot) {
detailOutput.textContent = `Fetching block ${slot}...`;
try {
const block = await callRpc("getBlock", [
slot,
{ encoding: "json", transactionDetails: "signatures", maxSupportedTransactionVersion: 1 },
]);
detailOutput.textContent = `${block.signatures.length} transactions in block ${slot}\n${block.signatures[0]}`;
} catch (error) {
detailOutput.textContent = `${error.message}\n\nBlocks older than about a minute are no longer fetchable.`;
}
}
blockRows.addEventListener("click", (event) => {
const slot = event.target.dataset?.slot;
if (slot) void showBlockDetail(Number(slot));
});
startButton.addEventListener("click", () => {
wantConnection = true;
startButton.disabled = true;
stopButton.disabled = false;
blockRows.replaceChildren();
previousBlockTime = 0;
connect();
});
stopButton.addEventListener("click", () => {
wantConnection = false;
startButton.disabled = false;
stopButton.disabled = true;
socket?.close();
});
Open the Socket
Yellowstone is a streaming service rather than an RPC endpoint: instead of you asking a node what happened, it pushes chain events to whoever subscribed. FluxRPC serves it over gRPC and over a WebSocket, and the WebSocket version needs nothing but a browser.
The URL takes two query parameters. key is your API key, and encoding=json asks for a JSON form of protobuf so you do not need a decoder:
wss://yellowstone.eu.fluxrpc.com/?key=<Your-API-Key>&encoding=json
In the app that is the first line of connect:
socket = new WebSocket(`${YELLOWSTONE_URL}/?key=${apiKey}&encoding=json`);
Say What You Want to Receive
Opening the socket subscribes you to nothing. Send one message, a SubscribeRequest, and the server pushes matching updates until you close the connection. Filtering happens on its side, so you never pay for updates you did not ask for.
The request has one field per kind of update: accounts, slots, transactions, blocks, and so on. Fill in the one you want. The rest can be left out entirely, but writing them as empty objects keeps it obvious what you are not asking for:
function buildBlockMetaRequest() {
return {
accounts: {},
slots: {},
transactions: {},
transactionsStatus: {},
blocks: {},
blocksMeta: { new_blocks: {} },
entry: {},
commitment: "PROCESSED",
accountsDataSlice: [],
};
}
new_blocks is a label you choose, not a field name. It comes back on every update so you can tell which of your subscriptions matched.
Why blocksMeta and not blocks. Both give you every new block. blocks gives the complete record, some megabytes in size, possibly even heavier than the polling loop we are replacing. blocksMeta gives the summary in 478 bytes, and that summary already contains everything a block list displays.
The request is sent from the socket's open handler, because there is nothing to send it on until the connection is up:
socket.addEventListener("open", () => {
reconnectDelay = 1000;
socket.send(JSON.stringify(buildBlockMetaRequest()));
statusLine.textContent = "Subscribed. Waiting for the first block.";
});
Read What Comes Back
The first message is not a block. It is a pong, sent as soon as the connection is ready, and it is easy to mistake for something being broken:
{
"pong": {},
"createdAt": "2026-08-12T10:06:15.407060434Z"
}
After that a block arrives every 400 milliseconds. Below is an example, with only its rewards block removed to keep it readable:
{
"filters": [
"new_blocks"
],
"blockMeta": {
"slot": "438782462",
"blockhash": "6K9ktBMvDsK47xozu1WaVuRsBvCrrPBiBc91BdcwafrU",
"blockTime": {
"timestamp": "1786525403"
},
"blockHeight": {
"blockHeight": "416834683"
},
"parentSlot": "438782461",
"parentBlockhash": "41mgqCR5skrYPsofXjMY4izjCzrytPNK4WNWLcHK9uAA",
"executedTransactionCount": "1036"
}
}
Each message carries a filters array naming which of your subscriptions matched, and exactly one filled-in payload field. Check which field is present rather than assuming, so the handler still works once you add a second subscription:
socket.addEventListener("message", (event) => {
const update = JSON.parse(event.data);
if (!update.blockMeta) return;
addBlockRow(update.blockMeta);
});
Two things about that JSON are worth knowing in advance.
Every 64 bit number is a string. slot arrives as "438782462", not as a number. So, pass it through Number() or your arithmetic quietly produces NaN.
blockTime and blockHeight are wrapped in an object. The value is at meta.blockTime.timestamp, not at meta.blockTime. Read the wrong one and the page will render [object Object].
Both of those are handled in the first three lines of addBlockRow, which turns one update into one table row:
function addBlockRow(meta) {
const slot = Number(meta.slot);
const transactions = Number(meta.executedTransactionCount ?? 0);
const blockTime = Number(meta.blockTime?.timestamp ?? 0);
const gap = previousBlockTime ? `${blockTime - previousBlockTime}s` : "";
previousBlockTime = blockTime;
const row = document.createElement("tr");
row.innerHTML = `
<td>${slot}</td>
<td>${meta.blockhash.slice(0, 8)}...</td>
<td>${transactions}</td>
<td>${gap}</td>
<td><button data-slot="${slot}">Detail</button></td>
`;
blockRows.prepend(row);
while (blockRows.children.length > MAX_ROWS) {
blockRows.lastElementChild.remove();
}
statusLine.textContent = `Streaming. Latest slot ${slot}.`;
}
Blocks arrive every 400 milliseconds, so MAX_ROWS caps the table at 25 rows. Without it, the page grows until your application eventually makes sad browser noises and your tab crashes.
The slot numbers arrive in approximate order, the order is not 100% guaranteed. Code that assumes the next slot number is always one higher will occasionally break on that, for example if a user is on the beach, and has a high-latency internet connection. With 400ms slots, you will almost always get them in the right order, but not 100% of the time.
Fetch One Block on Click
The stream told you a block exists and gave you its slot number, but not what was in it. That is getBlock's job, and now it runs once, for one block, only when you ask it to:
async function showBlockDetail(slot) {
detailOutput.textContent = `Fetching block ${slot}...`;
try {
const block = await callRpc("getBlock", [
slot,
{ encoding: "json", transactionDetails: "signatures", maxSupportedTransactionVersion: 1 },
]);
detailOutput.textContent = `${block.signatures.length} transactions in block ${slot}\n${block.signatures[0]}`;
} catch (error) {
detailOutput.textContent = `${error.message}\n\nBlocks older than about a minute are no longer fetchable.`;
}
}
transactionDetails: "signatures" rather than "full" is a ~20x difference in bandwidth usage. Since we only need the signatures in our case, we should use that option.
The catch is important. When using an RPC without full archival, fetching old blocks is likely to fail with an error code.
Run It on localhost
The page needs a real server rather than file://. Run one of these inside the folder holding your two files:
# if you have Python
python3 -m http.server 8000
# the same command, on Windows
py -m http.server 8000
# if you have Node.js instead
npx --yes serve -l 8000
Open http://localhost:8000, paste your API key, and press Start. The status line reads "Subscribed", and within a second rows begin appearing at the top of the table, a new one every 400 milliseconds. Click Detail on a fresh row to see how many transactions it held.
Troubleshooting
| Symptom | Cause |
|---|---|
The socket closes with code=1002, reason Expected 101 status code | The API key was rejected. The connection never becomes a WebSocket, so nothing you send matters. |
| The socket opens, the pong arrives, then nothing ever again | The SubscribeRequest was empty or was never sent. An empty request is accepted in silence. |
close code=1003, reason proto: syntax error (line 1:1): invalid value ... | The request was not valid JSON. |
| Messages arrive as binary rather than text | encoding=json is missing from the URL, so the service is replying in binary protobuf. |
Slots render as NaN | The 64 bit fields are strings. Use Number(meta.slot). |
blockTime renders as [object Object] | The value is at meta.blockTime.timestamp, and meta.blockHeight.blockHeight. |
{"code":-32004,"message":"Block not available for slot N"} | The block aged out of the retention window. |
Backend Guide
Coming Soon
The same subscription from a server rather than a browser tab. The SubscribeRequest above is the message a backend sends too, so what changes is the transport and where the results are kept.
Next Steps
If you want to cut the stream down further, drop encoding=json and the same blocksMeta update arrives as binary protobuf, 202 bytes instead of 478, at the cost of decoding it yourself. Try It runs any subscription in your browser without writing code.