import WebSocket from "ws";
import type { VolumeData } from "./types";
interface Trade {
timestamp: number;
volumeSol: number;
side: "buy" | "sell";
}
const tradeHistory: Map<string, Trade[]> = new Map();
async function getStreamToken(): Promise<string> {
const { data } = await axios.post(`${MADEONSOL_API}/stream/token`, null, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
return data.token; // does not expire; the same token comes back on every call
}
export async function startVolumeTracker(mint: string): Promise<void> {
const token = await getStreamToken();
const ws = new WebSocket("wss://madeonsol.com/ws/v1/dex-stream", {
headers: { Authorization: `Bearer ${token}` },
});
ws.on("open", () => {
ws.send(JSON.stringify({
type: "subscribe",
sub_id: `vol-${mint.slice(0, 6)}`,
filters: { token_mint: mint },
}));
});
ws.on("message", (raw: Buffer) => {
const msg = JSON.parse(raw.toString());
if (msg.channel !== "dex:trades") return; // ignore acks / replay / control frames
const trade = msg.data;
const trades = tradeHistory.get(mint) || [];
trades.push({
timestamp: Date.now(),
volumeSol: trade.sol_amount,
side: trade.action,
});
tradeHistory.set(mint, trades);
});
ws.on("close", () => {
setTimeout(() => startVolumeTracker(mint), 3000);
});
}
export function getVolume(mint: string): VolumeData {
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
const trades = (tradeHistory.get(mint) || []).filter(
(t) => t.timestamp > cutoff
);
return {
volume24h: trades.reduce((sum, t) => sum + t.volumeSol, 0),
buyVolume24h: trades
.filter((t) => t.side === "buy")
.reduce((sum, t) => sum + t.volumeSol, 0),
sellVolume24h: trades
.filter((t) => t.side === "sell")
.reduce((sum, t) => sum + t.volumeSol, 0),
tradeCount24h: trades.length,
};
}
Step 5: Fetch liquidity data
Liquidity depth determines how much slippage a trade will experience. DexScreener provides a convenient API for pool-level liquidity data:
import type { LiquidityData } from "./types";
export async function fetchLiquidity(mint: string): Promise<LiquidityData> {
const { data } = await axios.get(
`https://api.dexscreener.com/latest/dex/tokens/${mint}`
);
const pools = (data.pairs || []).map(
(pair: { dexId: string; pairAddress: string; liquidity: { usd: number } }) => ({
dex: pair.dexId,
pairAddress: pair.pairAddress,
liquidityUsd: pair.liquidity?.usd || 0,
})
);
return {
totalUsd: pools.reduce(
(sum: number, p: { liquidityUsd: number }) => sum + p.liquidityUsd,
0
),
pools: pools.slice(0, 10),
};
}
Step 6: Assemble the dashboard endpoint
Now tie everything together in a single aggregator that returns the full TokenMetrics object:
import type { TokenMetrics } from "./types";
import { fetchPrice } from "./price";
import { fetchHolders } from "./holders";
import { getVolume } from "./volume";
import { fetchLiquidity } from "./liquidity";
export async function getTokenMetrics(
mint: string,
symbol: string,
name: string
): Promise<TokenMetrics> {
const [price, holders, liquidity] = await Promise.all([
fetchPrice(mint),
fetchHolders(mint),
fetchLiquidity(mint),
]);
const volume = getVolume(mint);
return {
mint,
symbol,
name,
price,
holders,
volume,
liquidity,
lastUpdated: Date.now(),
};
}
You can serve this from an Express or Fastify endpoint, or call it from a Next.js API route. The Promise.all call ensures price, holder, and liquidity data fetch in parallel, keeping latency low.
Adding a caching layer
Fetching all four data sources on every request is wasteful and will hit rate limits quickly. Add a simple in-memory cache with per-source TTLs:
const cache = new Map<string, { data: unknown; expiry: number }>();
function getCached<T>(key: string): T | null {
const entry = cache.get(key);
if (!entry || Date.now() > entry.expiry) return null;
return entry.data as T;
}
function setCache(key: string, data: unknown, ttlMs: number): void {
cache.set(key, { data, expiry: Date.now() + ttlMs });
}
Recommended TTLs: price data at 10 seconds, holder data at 5 minutes, liquidity at 60 seconds. Volume is already tracked in memory via the WebSocket stream, so it needs no cache.
Error handling and resilience
Production dashboards need to handle failures gracefully. Each data source can fail independently, so never let one source block the entire response:
async function safeFetch<T>(
fn: () => Promise<T>,
fallback: T,
label: string
): Promise<T> {
try {
return await fn();
} catch (err) {
console.error(`[${label}] fetch failed:`, err);
return fallback;
}
}
Wrap each data fetcher in safeFetch so the dashboard returns partial data rather than a 500 error when one provider is down.
For the WebSocket volume tracker, the reconnection logic in Step 4 handles disconnections automatically. In production, add exponential backoff and a maximum retry count.
Extending the dashboard
Once the core metrics are in place, consider these additions:
- Price charts: Store price snapshots in a time-series database (TimescaleDB or QuestDB) and render candlestick charts on the frontend.
- Whale alerts: Flag any trade above a USD threshold from the WebSocket stream. See our guide on reading Solana smart money for strategies.
- Fresh-launch filtering: Reuse the same DEX firehose from Step 4 to surface brand-new Pump.fun tokens instead of established ones — our guide to building a Solana memecoin sniper bot covers the filtering and execution pattern in full.
- gRPC streaming: For lower latency than WebSocket polling, connect directly to Solana validators via gRPC. Our Solana gRPC streaming guide covers the setup.
- Multi-token views: Track a watchlist of tokens and render a sortable table with all metrics side-by-side.
- Risk scoring: Combine holder concentration, liquidity depth, and volume patterns into a single risk score.
- History plus live updates in one view: Load the starting state over REST, then apply WebSocket events as increments. The onchain analytics solution walks through that pattern, including how to reconcile the two when they drift.
FAQ
What is the best API for building a Solana token analytics dashboard?
It depends on which metrics matter most. The MadeOnSol API covers price feeds, live holder distribution (/tokens/{mint}/holders — exact holder count plus top holders and concentration from a full census), and real-time DEX trade streaming across all major Solana programs. DexScreener provides reliable liquidity and pair-level data. For most dashboards, combining these two sources gives you complete coverage without redundancy.
How do I get real-time token price updates without polling?
The most efficient approach is subscribing to a DEX trade WebSocket stream and computing price from incoming trades. MadeOnSol provides a filtered WebSocket feed that covers Raydium, Jupiter, Orca, Pump.fun, and other Solana DEXs. Each trade event includes the SOL amount, token amount and the pool-perspective executed_price_sol for that swap, so you can derive the latest price from the most recent trade. This eliminates polling entirely and gives you sub-second price updates.
How often should I refresh holder data for a Solana token?
Holder data changes much slower than price or volume, so refreshing every 1 to 5 minutes is plenty for most use cases. The MadeOnSol holders endpoint runs a full census at read time and caches the result per mint for a short window, so polling faster than that just returns the cached snapshot. If you need near-real-time holder tracking, consider subscribing to token account changes via Solana gRPC streams rather than polling the full holder list repeatedly.
Can I build this dashboard without paying for any APIs?
Partly. DexScreener's token endpoint is publicly accessible, and the MadeOnSol free tier (200 calls/day, 60/min burst) covers price lookups while you build. The holders endpoint is PRO ($49/mo) and the DEX firehose is ULTRA ($149/mo), so a production dashboard with live holder concentration and streaming volume needs a paid MadeOnSol plan. The main limitation on the free tier is the daily quota, which matters once you are tracking more than a handful of tokens or need sub-minute refresh intervals.
September 2026 refresh
The dashboard's DEX example now uses the permanent stream-token lifecycle introduced in August: no 24-hour expiry/refresh timer, Authorization-header auth for server clients, and ULTRA/BUSINESS firehose access. The stream is described as 14 program IDs across 8 venues, and token_refresh is no longer presented as a routine control frame; it is only relevant to an explicit credential rotation.
Sources: streaming reference, API reference, and the stream-token reconnect guide.