Real-time tick data applications are a classic example of real-time analytics. Like tracking user behavior in web apps or monitoring metrics from IoT devices, they involve high-frequency event streams that need to be ingested, stored, and queried with low latency.
In financial markets, the difference is the urgency. Even a few seconds of delay can turn a profitable trade into a loss. Every trade and quote update generates a new tick, and these can number in the thousands per second across multiple symbols.
ClickHouse is a strong fit for this type of workload. It handles high-frequency inserts, time-based queries, and low latency queries. Built-in compression helps reduce storage overhead, even with billions of rows per symbol. Materialized views can be used to pre-aggregate or reorganize data as it's written, optimizing query performance without needing a separate processing layer.
In this post, we'll walk through how to build a real-time tick data application using Massive (formerly Polygon.io) to access market data and ClickHouse to store and query ticks in real time. We'll put that together using Node.js for the backend operation and React for the live visualization. Let's dive in.
What is a tick?
Before we begin, it helps to understand what a quote is and what a trade is. A quote represents the current prices at which market participants are willing to buy or sell a security. Specifically, it includes the best bid (the highest price someone is willing to pay) and the best ask (the lowest price someone is willing to sell for). These are continuously updated as new orders enter or exit the market.
| sym | bx | bp | bs | ax | ap | as | c | i | t | q | z | inserted_at |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| SPY | 12 | 602.73 | 2 | 11 | 602.74 | 6 | 1 | [1,93] | 1749583478206 | 63152225 | NYSE | 1749583479396 |
A trade, on the other hand, is an actual transaction between a buyer and a seller. It occurs when someone agrees to the current ask or bid price and an order is matched and executed. Trades are recorded with the executed price, the size of the trade, and a timestamp.
| sym | i | x | p | s | c | t | q | z | trfi | trft | inserted_at |
|---|---|---|---|---|---|---|---|---|---|---|---|
| SPY | 52983525034825 | 11 | 607.26 | 1 | [12,37] | 1750842255972 | 22126 | NYSE | 0 | 0 | 1750842257842 |
Tick data typically comes in two streams. One contains quote updates, and the other contains trade executions. Both are essential for understanding market behavior, but they serve different purposes in analysis and strategy development.
Access to real-time market data
Now we understand the type of data we're going to ingest, let's have a look at how to access it. We first need to find and subscribe to a stock market API. There are many available, the one we picked to build this demo is Massive. Choose a plan that explicitly includes both stock trades and quotes over WebSockets. A REST API key or a plan with aggregate data alone is not sufficient. Check the current channel entitlements before subscribing.
WebSockets are essential for streaming market data because they eliminate the latency and overhead of polling REST APIs. Instead of establishing new connections for each data request and potentially missing ticks between calls, WebSockets maintain a persistent connection that pushes data the moment it's available which is critical for high-frequency market data where milliseconds matter.
Starting to ingest data using Massive API is fairly straightforward, simply establish a connection with the /stocks endpoint, authenticate using your Massive API key and start processing messages.
The following standalone Node.js diagnostic authenticates, subscribes after authentication succeeds, and logs the received events. Run it from the example directory after installing dependencies and configuring .env. Stop it before starting the full application; account connection limits can prevent multiple clients from using the same feed.
require("dotenv").config();
const WebSocket = require("ws");
if (!process.env.MASSIVE_API_KEY) throw new Error("Set MASSIVE_API_KEY in .env");
const symbols = (process.env.MASSIVE_SYMBOLS || "AAPL,MSFT,NVDA")
.split(",").map((symbol) => symbol.trim()).filter(Boolean);
const ws = new WebSocket("wss://socket.massive.com/stocks");
ws.on("open", () => {
ws.send(JSON.stringify({ action: "auth", params: process.env.MASSIVE_API_KEY }));
});
ws.on("message", (data) => {
const payload = JSON.parse(data.toString());
for (const row of payload) {
if (row.ev === "status") {
console.log(row.status, row.message);
if (row.status === "auth_success") {
ws.send(JSON.stringify({
action: "subscribe",
params: symbols.flatMap((symbol) => [`T.${symbol}`, `Q.${symbol}`]).join(","),
}));
} else if (row.status === "auth_failed") {
ws.close();
}
} else if (row.ev === "T" || row.ev === "Q") {
console.log(row);
}
}
});
ws.on("error", (error) => console.error(error.message));
process.on("SIGINT", () => ws.close());Ingesting Data into ClickHouse
Modeling Tick Data in ClickHouse
Tick data is relatively straightforward to model since it consists of just two event types, one for trade and another one for quote, each with a small set of mostly numeric fields. Below is the DDL for creating two separate tables. t is the SIP timestamp in milliseconds, while q is the per-symbol sequence number. Condition and indicator arrays use UInt32, since values can exceed 255. The trade schema also retains optional ds (fractional size as text) and pt (participant timestamp) fields. Missing optional fields use defaults. Current quote sizes are expressed in shares. The example uses Float64 for prices and sums the integer trade size s; use suitable decimal types and fractional-volume handling where your application requires them.
CREATE TABLE IF NOT EXISTS quotes
(
`sym` LowCardinality(String),
`bx` UInt8,
`bp` Float64,
`bs` UInt64,
`ax` UInt8,
`ap` Float64,
`as` UInt64,
`c` UInt8,
`i` Array(UInt32),
`t` UInt64,
`q` UInt64,
`z` Enum8('NYSE' = 1, 'AMEX' = 2, 'Nasdaq' = 3),
`inserted_at` UInt64 DEFAULT toUnixTimestamp64Milli(now64())
)
ENGINE = MergeTree
ORDER BY (sym, t - (t % 60000));
CREATE TABLE IF NOT EXISTS trades
(
`sym` LowCardinality(String),
`i` String,
`x` UInt8,
`p` Float64,
`s` UInt64,
`ds` String DEFAULT '',
`pt` UInt64 DEFAULT 0,
`c` Array(UInt32),
`t` UInt64,
`q` UInt64,
`z` Enum8('NYSE' = 1, 'AMEX' = 2, 'Nasdaq' = 3),
`trfi` UInt64,
`trft` UInt64,
`inserted_at` UInt64 DEFAULT toUnixTimestamp64Milli(now64())
)
ENGINE = MergeTree
ORDER BY (sym, t - (t % 60000));The data volume can grow quickly.
Subscribe to a small symbol list while developing, then measure throughput before subscribing to the entire market. Choosing an effective order key is essential for performance. In this case, rows are ordered first by sym (the stock symbol), grouping all events for the same symbol. Within each symbol group, rows are ordered by t - (t % 60000), which creates 1-minute time buckets. This approach works well in our case, as we aggregate data by symbol to generate the visualization. The minute bucket groups nearby events, while queries still use the original timestamp and sequence number to determine event order. Benchmark the sorting key and filters against your workload; a time bucket alone does not guarantee efficient pruning for every query.
Ingestion strategy
There are several ways to design an ingestion pipeline for this type of application, including using a message queue like Kafka. However, to minimize latency, it's often better to keep the system simple and push data directly from the WebSocket connection into ClickHouse when possible.
Once that setup is in place, the next step is to choose the right ingestion method. ClickHouse supports both synchronous and asynchronous inserts.
With synchronous ingestion, data is batched on the client side before being sent. The batch size should strike a balance between memory usage, latency, and system overhead. Larger batches reduce the number of insert requests and improve throughput, but they can increase memory usage and delay individual records. Smaller batches reduce memory pressure but may create more load on ClickHouse by generating too many small data parts.
With asynchronous ingestion, data is sent to ClickHouse continuously, and batching is handled internally. Incoming records are first written to an in-memory buffer, which is then flushed to storage based on configurable thresholds. This method is useful when client-side batching isn't practical, such as when data comes from many small clients.
In our case, synchronous ingestion is a better fit. Since there's only one client pushing data from the WebSocket API, batching can be managed on the client side for better control over performance and resource usage.
This compact batching class illustrates the ingestion path. Create a TickBatcher and pass incoming WebSocket frames to its handleMessage method, alongside the authentication handler above. Stop incoming messages before awaiting close(). The full example adds bounded concurrent inserts, reconnect handling, shutdown deadlines, and metrics. Both use synchronous inserts so success means ClickHouse has acknowledged the write.
const { createClient } = require("@clickhouse/client");
class TickBatcher {
constructor() {
this.client = createClient({
url: process.env.CLICKHOUSE_URL || "http://localhost:8123",
username: process.env.CLICKHOUSE_USERNAME || "default",
password: process.env.CLICKHOUSE_PASSWORD || "",
database: process.env.CLICKHOUSE_DATABASE || "default",
compression: { request: true, response: true },
clickhouse_settings: { async_insert: 0 },
});
this.batches = { trades: [], quotes: [] };
this.flushing = { trades: false, quotes: false };
this.failedRecords = 0;
this.droppedRecords = 0;
this.timer = setInterval(() => {
void this.flushBatch("trades");
void this.flushBatch("quotes");
}, 2000);
}
handleMessage(data) {
const payload = JSON.parse(data.toString());
for (const { ev, ...fields } of payload) {
const table = ev === "T" ? "trades" : ev === "Q" ? "quotes" : null;
if (!table) continue;
if (this.batches[table].length >= 10000) {
this.droppedRecords++;
continue;
}
this.batches[table].push(fields);
if (this.batches[table].length >= 1000) void this.flushBatch(table);
}
}
async flushBatch(table) {
if (this.flushing[table]) return;
this.flushing[table] = true;
try {
while (this.batches[table].length) {
const values = this.batches[table].splice(0, 1000);
try {
await this.client.insert({ table, values, format: "JSONEachRow" });
} catch (error) {
this.failedRecords += values.length;
console.error(`Insert failed for ${table}:`, error.message);
}
}
} finally {
this.flushing[table] = false;
}
}
async close() {
clearInterval(this.timer);
while (Object.values(this.flushing).some(Boolean)) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
await Promise.all([this.flushBatch("trades"), this.flushBatch("quotes")]);
await this.client.close();
}
}The compact class bounds each pending table buffer at 10,000 records and counts discarded or failed records; it does not retry failed writes. For a lossless pipeline, add durable buffering, replay, and deduplication. The full demo flushes at 1,000 rows or every two seconds and runs at most four inserts concurrently.
Visualize live market data
Once the data is stored in ClickHouse, building the visualization layer is straightforward. The main challenge lies in writing the right SQL queries. Let’s have a look on how to achieve this. We’ll focus on the queries needed to power two key visualizations. The first is a real-time table that updates continuously to show the latest trading data for a specific stock.

To build this visualization, one query is enough, the data can be formatted using ClickHouse's powerful SQL query language and custom functions.
with
{syms: Array(String)} as symbols,
toDate(now('America/New_York')) as curr_day,
trades_info as (
select
sym,
argMax(p, tuple(t, q)) as last_price,
round(((last_price - (argMin(p, tuple(t, q)))) / nullIf(argMin(p, tuple(t, q)), 0)) * 100, 2) as change_pct,
sum(s) as total_volume,
max(t) as latest_t
from
trades
where
toDate(fromUnixTimestamp64Milli(toInt64(t), 'America/New_York')) = curr_day
and sym in symbols
group by
sym
order by
sym asc
),
quotes_info as (
select
sym,
argMax(bp, tuple(t, q)) as bid,
argMax(ap, tuple(t, q)) as ask,
max(t) as latest_t
from
quotes
where
toDate(fromUnixTimestamp64Milli(toInt64(t), 'America/New_York')) = curr_day
and sym in symbols
group by
sym
order by
sym asc
)
select
t.sym as ticker,
t.last_price as last,
q.bid as bid,
q.ask as ask,
t.change_pct as change,
t.total_volume as volume,
toUnixTimestamp64Milli(now64()) - toInt64(greatest(t.latest_t, q.latest_t)) as latency
from
trades_info as t
left join quotes_info as q on t.sym = q.sym;Let's break down what this query does.
First, it defines two variables: symbols, which holds the list of stock tickers to analyze, and curr_day, which captures the current date in the New York timezone.
The query then retrieves trade data, including:
last_price: The most recent trade price, usingargMax(p, tuple(t, q))to break millisecond timestamp ties with the sequence numberchange_pct: The percentage change from the first ingested trade of the current New York calendar day, with a zero-denominator guard. This is not the official opening price or the previous close.total_volume: Total integer share volume ingested for that calendar day
It also fetches quote data:
bid: Most recent bid price usingargMax(bp, tuple(t, q))ask: Most recent ask price usingargMax(ap, tuple(t, q))
Finally, the trade and quote results are joined. The additional latency column reports the age of the latest received event in milliseconds, rather than measuring only database insertion time. Rows below are illustrative historical values. The demo does not filter trade conditions, process cancellations/corrections, or reconstruct an official exchange OHLCV series.
| ticker | last | bid | ask | change | volume |
|---|---|---|---|---|---|
| NVDA | 151.2099 | 151.2 | 151.21 | 2.17 | 65269276 |
The second visualization we’re going to analyze is a candlestick visualization that shows the price evolution and volume for a given stock.

Let’s have a look at the SQL query to power this visualization.
select
toUnixTimestamp64Milli(toDateTime64(toStartOfInterval(fromUnixTimestamp64Milli(toInt64(t)), interval 2 minute), 3)) as x,
argMin(p, tuple(t, q)) as o,
max(p) as h,
min(p) as l,
argMax(p, tuple(t, q)) as c,
sum(s) as v
from trades
where x > toUnixTimestamp64Milli(now64() - interval 1 hour)
and sym = {sym: String}
group by x order by x asc;This query groups the last hour into two-minute buckets and computes open, high, low, close, and volume. Open and close use (t, q) to resolve trades with identical timestamps. The bucket filter excludes a partially overlapping bucket at the start of the window.
The browser calls a named-query Express endpoint; only server-defined, parameterized SQL is accepted. ClickHouse credentials remain on the server and are never placed in NEXT_PUBLIC_ variables. To visualize the query result, we use click-ui components for the table display and Chart.js for the candlestick visualization.
Scaling and practical tips
Handling high-frequency market data in production requires more than just a fast database. The following tips and techniques help ensure your system remains performant and reliable as data volume grows.
Scaling ingestion
When dealing with tick-level data across many symbols, sustained throughput can easily exceed tens of thousands of records per second.
To handle this there are different things to look for:
- Use client-side batching with insert sizes optimized for your system's memory and latency constraints.
- Use compression: Compressing insert data reduces the size of the payload sent over the network, minimizing bandwidth usage and accelerating transmission.
- Monitor the number of parts created in ClickHouse to prevent excessive merging. This blog talks about asynchronous insert, but the part creation section can be applied for a synchronous ingestion. You can also use advanced dashboards to monitor the number of data parts.
- Massive also provides performance tips to handle high volume data consumption.
Monitoring ingest latency
As discussed earlier, having the freshest data is critical for a financial application. So it does make sense to monitor it.
You can easily calculate and track the difference between the event timestamp (when the tick occurred) and the ingestion timestamp (when it was stored). This event-to-insert delay includes feed delivery, client batching, network time, and database ingestion; it is not pure database latency. The query below measures it for the latest trade per symbol within the last hour. Signed arithmetic avoids unsigned underflow when timestamps differ unexpectedly.
SELECT
sym,
count() AS trade_count,
argMax(toInt64(inserted_at) - toInt64(t), tuple(t, q)) AS ingest_latency_ms
FROM trades
WHERE t >= toUnixTimestamp64Milli(now64() - INTERVAL 1 HOUR)
GROUP BY sym
ORDER BY trade_count DESC
LIMIT 100;Visualizing this metric in a dashboard helps you catch slowdowns early.
Take advantage of materialized views
Materialized views are useful when you want to pre-aggregate data as it arrives. This helps optimize specific query patterns that rely on time-based summaries. A typical example is computing OHLCV (Open, High, Low, Close, Volume) metrics for financial data at fixed intervals, such as 1-minute windows. By generating these aggregates during ingestion, you can serve results quickly without recalculating them each time.
Start by creating a destination table to store the 1-minute OHLCV aggregates. Use AggregatingMergeTree to merge aggregate states. Include every grouping dimension in the sorting key: here, symbol, tape, and minute. Open and close states use the same (t, q) ordering as the raw queries.
CREATE TABLE trades_1min_ohlcv
(
`sym` LowCardinality(String),
`z` Enum8('NYSE' = 1, 'AMEX' = 2, 'Nasdaq' = 3),
`minute_bucket_ms` UInt64,
`open_price_state` AggregateFunction(argMin, Float64, Tuple(UInt64, UInt64)),
`high_price_state` AggregateFunction(max, Float64),
`low_price_state` AggregateFunction(min, Float64),
`close_price_state` AggregateFunction(argMax, Float64, Tuple(UInt64, UInt64)),
`volume_state` AggregateFunction(sum, UInt64),
`trade_count_state` AggregateFunction(count)
)
ENGINE = AggregatingMergeTree
ORDER BY (sym, z, minute_bucket_ms);The next step is to create the materialized view.
CREATE MATERIALIZED VIEW trades_1min_ohlcv_mv TO trades_1min_ohlcv
AS SELECT
sym,
z,
intDiv(t, 60000) * 60000 AS minute_bucket_ms,
argMinState(p, tuple(t, q)) AS open_price_state,
maxState(p) AS high_price_state,
minState(p) AS low_price_state,
argMaxState(p, tuple(t, q)) AS close_price_state,
sumState(s) AS volume_state,
countState() AS trade_count_state
FROM trades
GROUP BY sym, z, minute_bucket_ms;Create the destination and view before starting ingestion. The view processes new inserted blocks; it does not automatically backfill existing rows. Backfill existing data with a coordinated INSERT ... SELECT if needed, taking care not to count overlapping live data twice. The query uses the -Merge functions to combine states even before background merges finish.
To view the data, execute this query.
-- Query the table
SELECT
sym,
z,
minute_bucket_ms,
fromUnixTimestamp64Milli(toInt64(minute_bucket_ms)) as minute_timestamp,
argMinMerge(open_price_state) AS open_price,
maxMerge(high_price_state) AS high_price,
minMerge(low_price_state) AS low_price,
argMaxMerge(close_price_state) AS close_price,
sumMerge(volume_state) AS volume,
countMerge(trade_count_state) AS trade_count
FROM trades_1min_ohlcv
GROUP BY sym, z, minute_bucket_ms
ORDER BY sym, z, minute_bucket_ms;Run the complete example
The example source now lives under blog-examples/stock-data-demo. Use Node.js 22 or later and an existing local or Cloud ClickHouse database.
git clone https://github.com/ClickHouse/examples.git
cd examples/blog-examples/stock-data-demo
npm ci
cp env.example .env
# Set CLICKHOUSE_URL, CLICKHOUSE_USERNAME, CLICKHOUSE_PASSWORD,
# CLICKHOUSE_DATABASE, and MASSIVE_API_KEY in .env.
npm run setup
npm run build
npm startOpen http://localhost:34567/stocks/ for charts or http://localhost:34567/admin for ingestion controls. By default, the service subscribes to AAPL, MSFT, and NVDA. Change MASSIVE_SYMBOLS to choose other symbols; the dashboard watchlist does not alter upstream subscriptions. The server binds to loopback and its APIs have no authentication, so add access controls before exposing it publicly.
If your key lacks the required channels, the admin page reports authentication failure. Markets can also be quiet or closed on weekends and holidays. To try the dashboard without market access, use a separate ClickHouse database, leave the API key empty, and run npm run seed before npm start. This inserts explicitly synthetic prices timestamped over the last three minutes; it does not fetch real market data. Rerun the seed when those timestamps move outside the chart window.
The README covers local ClickHouse setup, development mode, configuration, and verification.
Conclusion
In this post, we explored how to build a real-time tick data application using Massive for market data and ClickHouse for fast ingestion and querying. We covered how to stream and structure tick data, manage ingestion performance, and build efficient queries and visualizations.
In this GitHub repository, you will find a working example of this using React for the visualization layer. Use it to explore ingestion and query patterns, then add durable delivery, replay, authentication, and workload-specific tuning for a production application.



