Jesús Torres

All work

Swapstracker: one binary, 3.1 billion trades, one small board

How I took Swapstracker from a one-year backfill estimate on my first, TypeScript version to a 20-day backfill with a Go rewrite and TimescaleDB on a Radxa ROCK 5C.

Stack
Go, PostgreSQL, TimescaleDB, Redis, WebSockets, SvelteKit
Links
swapstracker.com
Both versions are my own proprietary code. This write-up describes the design at a high level only.

What it does

Swapstracker follows DEX trading on EVM chains. It reads swap events from Uniswap V2, V3 and V4-style pools, prices each trade in USD and serves trades, pair statistics and OHLCV candles through an API. New trades are pushed live to a SvelteKit frontend.

This write-up covers two chains, Ethereum and BSC, over two years of history.

The platform supports more EVM chains. Every figure on this page refers only to Ethereum and BSC.

Swapstracker market screener: a table of Ethereum pairs with price, 24-hour volume, liquidity change, liquidity sparkline, order flow, concentration and flags.
What the indexed trades turn into: a screener with liquidity, order flow and holder concentration per pair.

Where the time went

I wrote the first version of Swapstracker as a set of TypeScript microservices writing to MongoDB. Each service made sense on its own. Together they spent a large share of CPU and memory turning objects into messages and messages back into objects, only to hand the data to the next service.

I track progress in blocks per second, logged continuously. At the rate my first version sustained, the logs projected roughly a year to backfill two years of both chains. That number is a projection: I did not let the first version run for a year to confirm it.

What I changed

  • One binary for the path from logs to trades. Extraction, pair resolution, timestamps, pricing and persistence became stages of a single Go process, passing in-memory structs to each other.
  • Fewer boundaries, fewer transformations. Removing the hops between services removed the serialization and parsing that came with each one.
  • PostgreSQL with TimescaleDB instead of MongoDB. Trades are time series. Hypertables, compression and continuous aggregates fit that shape.
  • Measurement stayed on. Blocks per second remained in logs and metrics, so every change showed up as a number.

This is not a Go versus TypeScript result. Architecture, data access and storage changed together.

My first version

EVM RPCTS serviceserialize, send, parseTS serviceserialize, send, parseTS serviceMongoDB

Current indexer

EVM RPCGo indexer, one processextract logsresolve pairstimestampsprice in memorywrite batchTimescaleDB
Simplified. The exact number of old services is not the point; the hops are.

The indexer is one process; the platform is not. The API, Redis, the database and the frontend run as their own services.

Swapstracker monitoring page for Ethereum: pipeline items per second and stage latencies on top, CPU, RAM and temperature below, over about ten minutes.
The indexer reports its own throughput, stage latencies, block lag, CPU, RAM and temperature. Ten minutes of live Ethereum indexing on 23 Sep 2026; a monitoring view, not a benchmark.

Parallel, but in block order

Inside the indexer, an allocator hands out consecutive block ranges, sized by the batch optimizer. Several workers take ranges at the same time. Each one fetches its logs, then resolves pair metadata and block timestamps concurrently, checking the local cache before asking the network.

Workers finish in whatever order the network allows, so their batches pass through a reorder buffer. A single committer prices and writes them strictly in block order, so the stored history grows contiguously even while extraction runs in parallel.

A fixed number of in-flight slots caps how many prepared batches can wait at once. When one range is slow, the other workers stop taking new work instead of filling memory.

The worker count adapts at runtime. The ordering does not depend on it: only the committer writes.

Isometric ink illustration of the indexer as a small factory: external antennas feed a selector switch; a valve sets how long each crate of data is; three parallel lanes sift, look up and time-stamp crates; a rack holds finished crates until their turn; a single line weighs each crate and fills a layered tank that feeds a candlestick chart and a live screen; a single-board computer with a portable SSD powers it all.
  1. RPC providers, checked against baselines, with failover between them
  2. Batch size adapts to provider health and log density
  3. Parallel workers extract logs, resolve pairs and assign timestamps
  4. Reorder buffer: finished batches wait for their turn
  5. One committer prices trades in memory and writes in block order
  6. TimescaleDB, feeding candles and live trades
  7. The Radxa ROCK 5C and Samsung T7 running it

An illustration of the design, made with an image model from my description of the indexer. Not a screenshot.

Results

With those changes, the two-year backfill of both chains finished in 20 days. The history now holds more than 3.1 billion trades in about 1 TB of compressed storage, and the indexer uses around 80% less RAM than my first version did.

I also saw CPU temperature drop by roughly 15 °C. Temperature depends on load and room conditions, so I keep it as an observation, not a result.

Measurement log
3.1+ billiontrades stored, two years of Ethereum and BSCobserved
1 TBcompressed history, on a 2 TB SSDobserved
20 daysto backfill those two years, both chains togetherobserved
≈ 1 yearwhat my first version projected for the same jobestimate from logged blocks/s
80% lessRAM after the rewritereported, no reproducible benchmark yet
Two-year backfill, both chainsBar chart. My first, TypeScript version projected roughly 365 days to backfill two years of both chains, estimated from logged blocks per second. The rewritten indexer finished the same backfill in 20 days of observed wall-clock time.Two-year backfill, both chainsMy first version, projected≈ 365 daysMy Go indexer, observed20 days0100200300
An estimate against an observation, not a controlled benchmark. Architecture, data access and storage all changed together, so no single factor gets the credit.

The machine

All of this runs on a Radxa ROCK 5C single-board computer with a 2 TB Samsung T7 SSD attached over USB. The compressed history takes about half of that disk. The same board also serves HabitsFlow and GroupBrain, and the three apps reach the internet through Cloudflare Tunnel.

The board does not run full EVM nodes. Chain data comes from RPC providers; the indexer’s job is to get as much as possible out of every request and do the rest locally.

A Radxa ROCK 5C single-board computer in a black case, connected by USB to a blue Samsung T7 portable SSD.
Radxa ROCK 5C, Samsung T7, 2 TB. Runs Swapstracker, HabitsFlow and GroupBrain, published to the internet through Cloudflare Tunnel.

Serving candles and live trades

  • Candles. TimescaleDB continuous aggregates, one set per chain, keep OHLCV rolled up in advance, so chart queries read summaries instead of scanning raw trades.
  • Concurrent reads. Identical requests that arrive at the same time share one database query through Go’s singleflight. It collapses in-flight reads; it does not deduplicate stored trades.
  • Live trades. New trades go out on Redis Pub/Sub and fan out to WebSocket clients, so the frontend does not poll.

I call OHLCV latency low. I have not published a p95 yet, so there is no millisecond figure here.

Swapstracker pair page for WBNB/USD on BSC: one-minute candlestick chart, the most recent trades with side, amounts and price, and buy versus sell flow over the last 1,000 trades.
A BSC pair page: one-minute candles from post-swap pool state, the latest trades as they are indexed and buy/sell flow over the last 1,000 trades.
Swapstracker daily statistics for a WETH pair: 24-hour volume, depth, seven-day liquidity change and age, a bar chart of daily volume with an effective liquidity line over eight days, and per-token volume, pool count and concentration.
Daily statistics per pair and per token, rolled up from the indexed trades: volume, depth, liquidity change and pool concentration.

Keeping the RPC side honest

Public RPC endpoints vary a lot. Some drop old history, some time out, some return incomplete logs. The indexer fails over between providers, and candidate endpoints are checked against baselines, known results for fixed block ranges, before they are trusted.

Pricing itself is in-memory math over pool state. RPC is needed for logs, token metadata and block timestamps, and the last two are cached locally. Batch sizes adapt to log density: the optimizer aims for about 35,000 logs per batch and backs off when a provider struggles.

35,000 is a batch-size target, not a throughput figure and not a hard limit.

Swapstracker RPC proxy page for one UTC day: about 155,000 calls with 14,314 failures across BSC and Ethereum, and a table of provider endpoints with calls, requests per second, failures and average latency.
One UTC day of the RPC proxy: calls, failures and latency per provider endpoint. About 9% of calls failed that day, which is why failover and baselines exist.

What I have not measured yet

The numbers above come from my own logs. To make them something another engineer can reproduce, these are next:

  • sustained logs per second on this board, for a fixed chain and block range;
  • how far behind the chain head the live indexer runs;
  • p95 latency of OHLCV queries;
  • recovery time after an RPC provider fails.

They will land on this page, with the method, once I have them.