A trader I was talking to recently said something that stuck with me:
"I've blown accounts just from slow fills or missed order cancellations."
He was talking about CEX perpetuals. But the problem is identical on Polymarket's CLOB - just measured in seconds instead of milliseconds.
My TypeScript bot was averaging 340ms from signal detection to order placement on Polymarket's Central Limit Order Book. On a 5-minute market with a ~2.7-second mispricing window, that's 12% of the entire opportunity window consumed before a single byte hits Polymarket's servers.
I was consistently entering at 74¢ when I'd detected the signal at 70¢. The market had already repriced against me.
So I rewrote it in Rust. This article documents exactly what I found, what changed, and - critically - what didn't.
Background: What My Bot Was Doing
If you've read my earlier posts in this series (architecture, Kelly Criterion sizing, last-60-seconds capture), you know the context. But the short version:
The bot targets Polymarket's 5-minute and 15-minute crypto up/down binary markets (BTC, ETH, XRP, SOL, DOGE, BNB). The strategy is simple: find markets that are briefly mispriced relative to real-time spot momentum, enter at a discount to fair value, hold to resolution.
A 5-minute "XRP Up" market priced at 70¢ when spot momentum suggests 82% probability = +12¢ edge per dollar wagered. Do that 50 times a day with disciplined sizing and the math works - if you can actually get filled at the price you detected.
The problem: by the time my TypeScript code detected the signal, formatted the order, opened an HTTP connection to Polymarket's CLOB API, waited for TLS handshake, serialized the payload, and received confirmation, the market had often moved to 74-76¢.
I was paying for an edge I wasn't capturing.
Profiling the TypeScript Bot: Where Was the 340ms Going?
Before rewriting anything, I instrumented every stage of the order path. Here's what I found across 500 sampled trades:
| Stage | Average time | % of total |
|---|---|---|
| Signal detection (price poll → threshold check) | 18ms | 5.3% |
| JSON parsing of market data | 12ms | 3.5% |
| Order object construction | 3ms | 0.9% |
| DNS resolution | 47ms | 13.8% |
| TLS handshake | 89ms | 26.2% |
| HTTP request serialization | 8ms | 2.4% |
| Network transit (Polygon RPC + CLOB) | 94ms | 27.6% |
| CLOB response parsing | 11ms | 3.2% |
| Order confirmation logging | 58ms | 17.1% |
| Total | 340ms | 100% |
A few things jumped out immediately:
DNS resolution (47ms) was almost entirely avoidable. I was resolving clob.polymarket.com on every single request. Node.js doesn't cache DNS by default in the way you'd want for a latency-sensitive application. Using persistent connections with connection pooling would eliminate this almost entirely.
TLS handshake (89ms) - same problem. I was opening a new HTTPS connection per order rather than reusing a keep-alive connection. This is basic HTTP/1.1 hygiene that I had simply not thought about because at "normal" application scales it doesn't matter.
Order confirmation logging (58ms) was a synchronous fs.writeFileSync call to a JSON log file. I'd added this during debugging and never removed it. Blocking the event loop on disk I/O in the critical path is embarrassing in hindsight.
Network transit (94ms) is the one thing I can't fully eliminate without co-locating infrastructure closer to Polygon's RPC nodes. I'm in Europe; Polymarket's infrastructure is primarily US-east. This is an irreducible floor.
So: 47 + 89 + 58 = 194ms of avoidable latency from configuration choices. That's the gap from 340ms to the theoretical ~146ms floor with the same network path.
But there was also a deeper issue: Node.js's single-threaded event loop was creating head-of-line blocking. When I was scanning multiple markets simultaneously, a slow response on one market scan was delaying order placement on another. The event loop doesn't eliminate latency - it interleaves it.
Why Rust, Specifically
I want to be clear about what Rust gives me and what it doesn't.
What Rust gives me:
- True parallelism via
tokio's async runtime with a multi-threaded executor. Market scanning and order placement run in separate threads. A slow scan doesn't block a hot order path. - Zero-cost abstractions - the Rust compiler eliminates overhead that JavaScript runtimes cannot. JSON deserialization with
serdeis 3-5× faster thanJSON.parsefor complex nested objects. - Explicit connection pool management via
reqwestwith keep-alive. I control exactly how connections are reused. - No garbage collector pauses. In TypeScript, V8's GC can introduce unpredictable latency spikes of 5-50ms. At 340ms average this is noise. At 50ms average it's catastrophic.
- Compile-time correctness. The CLOB API response shapes are typed at compile time. A malformed API response is a compile error or a handled
Result, not a runtime crash.
What Rust doesn't give me:
- Better network physics. Light doesn't travel faster because I chose Rust.
- Alpha I don't have. If my signal logic is wrong, Rust executes it faster.
- A simpler development loop. The first version of the Rust bot took 3× longer to write than the TypeScript equivalent.
Rust is the right tool here specifically because the bottlenecks I identified are CPU and I/O management problems, not network problems. If the bottleneck were purely network latency, Rust would give me marginal gains. Since it's connection management and event loop contention, Rust gives me substantial gains.
The Rust Architecture
adelan-bot-rs/
├── Cargo.toml
├── src/
│ ├── main.rs ← tokio runtime entrypoint
│ ├── scanner.rs ← market scanning (parallel tasks)
│ ├── strategy/
│ │ ├── mod.rs
│ │ ├── signal.rs ← entry detection logic
│ │ └── sizing.rs ← Kelly / fixed sizing
│ ├── market/
│ │ ├── polymarket.rs ← CLOB + Gamma API client
│ │ └── clob.rs ← order placement, connection pool
│ ├── trading/
│ │ ├── paper.rs ← paper trading engine
│ │ └── portfolio.rs ← position + P&L tracking
│ └── types.rs ← shared types
Dependencies (Cargo.toml)
[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
tracing = "0.1"
tracing-subscriber = "0.3"
anyhow = "1"
tokio-tungstenite = "0.24" # WebSocket for real-time price feed
rust_decimal = "1" # precise decimal arithmetic for prices
No unsafe. No custom allocator. The standard tokio + reqwest stack is sufficient for this use case.
The Connection Pool: The Most Important Change
This single change - persistent HTTP connections with a shared reqwest::Client - was responsible for roughly 60% of the latency improvement.
// polymarket.rs
use reqwest::Client;
use std::time::Duration;
pub struct PolymarketClient {
client: Client,
clob_base: String,
gamma_base: String,
}
impl PolymarketClient {
pub fn new() -> anyhow::Result<Self> {
let client = Client::builder()
// Keep connections alive — eliminates per-request TLS handshake
.tcp_keepalive(Duration::from_secs(30))
.connection_verbose(false)
// Connection pool: up to 20 idle connections per host
.pool_max_idle_per_host(20)
// Use rustls instead of OpenSSL for consistent performance
.use_rustls_tls()
// DNS caching built into reqwest — eliminates per-request resolution
.timeout(Duration::from_millis(5_000))
.build()?;
Ok(Self {
client,
clob_base: "https://clob.polymarket.com".into(),
gamma_base: "https://gamma-api.polymarket.com".into(),
})
}
pub async fn get_midpoint(&self, token_id: &str) -> anyhow::Result<f64> {
let url = format!("{}/midpoints?token_ids={}", self.clob_base, token_id);
let response = self.client
.get(&url)
.send()
.await?
.json::<serde_json::Value>()
.await?;
let mid = response
.get(token_id)
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.5);
Ok(mid)
}
pub async fn place_order(&self, order: &Order) -> anyhow::Result<OrderResponse> {
// Reuses existing connection from pool — no TLS handshake
let response = self.client
.post(format!("{}/order", self.clob_base))
.json(order)
.send()
.await?
.json::<OrderResponse>()
.await?;
Ok(response)
}
}
In TypeScript, I was calling axios.get() or fetch() with default settings, which creates a new connection per request. In Rust, the Client is shared across the entire application lifetime and manages the connection pool automatically. The TLS handshake happens once per host, not once per request.
Parallel Market Scanning with tokio
The second major structural change: market scanning runs as independent async tasks, not sequentially.
// scanner.rs
use tokio::task::JoinSet;
use crate::market::PolymarketClient;
use crate::strategy::signal::generate_signal;
use crate::types::{Market, Signal};
use std::sync::Arc;
pub struct MarketScanner {
client: Arc<PolymarketClient>,
}
impl MarketScanner {
pub async fn scan_all(&self, markets: &[Market]) -> Vec<Signal> {
let mut tasks = JoinSet::new();
for market in markets {
let client = Arc::clone(&self.client);
let market = market.clone();
// Each market scanned in its own async task
// No market blocks another — true parallelism via tokio
tasks.spawn(async move {
let price = client.get_midpoint(&market.yes_token_id).await.ok()?;
let updated = Market { current_price: price, ..market };
generate_signal(&updated, 0) // simplified: pass open position count
});
}
let mut signals = Vec::new();
while let Some(result) = tasks.join_next().await {
if let Ok(Some(signal)) = result {
signals.push(signal);
}
}
signals
}
}
In the TypeScript version, scanning 20 markets meant 20 sequential await axios.get() calls, each waiting for the previous to complete. In Rust with JoinSet, all 20 requests fire simultaneously and results are collected as they arrive. For 20 markets averaging 94ms network round-trip, sequential scanning took ~1,880ms. Parallel scanning takes ~100ms (the time of the single slowest request).
This matters enormously for the bot's reaction time. If I scan every 30 seconds and scanning itself takes 1.8 seconds, I'm only actually running signal logic 5.9% of the available time on sequential scans. With parallel scanning I'm running signal logic 99.7% of the time.
Async Logging: Eliminating the 58ms Disk Block
The fs.writeFileSync mistake in TypeScript becomes impossible in idiomatic async Rust. Using tracing with a non-blocking subscriber:
// main.rs
use tracing_subscriber::{fmt, EnvFilter};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Non-blocking async logging — never touches the hot path
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.with_target(false)
.init();
tracing::info!("AdelanBot starting - paper mode: {}",
std::env::var("PAPER_MODE").unwrap_or("true".into()));
// ... rest of startup
Ok(())
}
All log writes are buffered and flushed asynchronously. The order placement path never waits for a disk write.
The Signal Logic: Unchanged
I want to be explicit about this: the signal detection logic is identical between the TypeScript and Rust implementations.
// strategy/signal.rs
use crate::types::{Asset, Direction, Market, Signal, Timeframe, PREFERRED_ASSETS};
pub fn generate_signal(market: &Market, open_positions: usize) -> Option<Signal> {
// Same thresholds as TypeScript version — derived from @adelan's real positions
const HIGH_CONF_MIN: f64 = 0.70;
const HIGH_CONF_MAX: f64 = 0.83;
const CONTRARIAN_MIN: f64 = 0.49;
const CONTRARIAN_MAX: f64 = 0.53;
const MAX_POSITIONS: usize = 10;
const MIN_LIQUIDITY: f64 = 500.0;
if open_positions >= MAX_POSITIONS { return None; }
if market.liquidity < MIN_LIQUIDITY { return None; }
if !PREFERRED_ASSETS.contains(&market.asset) { return None; }
// Time to expiry check
let ms_to_expiry = market.ends_at
.signed_duration_since(chrono::Utc::now())
.num_milliseconds();
if ms_to_expiry < 60_000 { return None; }
let price = market.current_price;
// High confidence entry zone (adelan's primary play)
if price >= HIGH_CONF_MIN && price <= HIGH_CONF_MAX {
let shares = calculate_shares(open_positions);
let ev = (price * (1.0 - price)) - ((1.0 - price) * price); // simplified EV
return Some(Signal {
market: market.clone(),
direction: market.direction,
entry_price: price,
confidence: "high".into(),
shares,
expected_return_pct: ((1.0 - price) / price) * 100.0,
ev,
reason: format!(
"High-conf {} @ {:.0}¢ — {} {}",
market.direction, price * 100.0, market.asset, market.timeframe
),
});
}
// Contrarian play (near 50¢, momentum signal)
if price >= CONTRARIAN_MIN && price <= CONTRARIAN_MAX {
let momentum_strength = (price - 0.50_f64).abs();
if momentum_strength < 0.01 { return None; }
let shares = calculate_shares(open_positions) * 0.5;
return Some(Signal {
market: market.clone(),
direction: if price > 0.5 { Direction::Up } else { Direction::Down },
entry_price: price,
confidence: "contrarian".into(),
shares,
expected_return_pct: ((1.0 - price) / price) * 100.0,
ev: momentum_strength * 0.5,
reason: format!(
"Contrarian momentum @ {:.0}¢ - {}",
price * 100.0, market.asset
),
});
}
None
}
fn calculate_shares(open_positions: usize) -> f64 {
let size = 1.0_f64 - (open_positions as f64 * 0.1);
size.max(0.2)
}
The Rust type system enforces correctness I was relying on runtime checks for in TypeScript. Direction is an enum, not a string. Asset is an enum, not 'XRP' | 'ETH' | .... The compiler rejects malformed signal objects before they can cause a bad order.
Benchmark Results: Before and After
After the rewrite, I ran the same 500-trade instrumented test across both versions:
| Stage | TypeScript | Rust | Improvement |
|---|---|---|---|
| Signal detection | 18ms | 4ms | 4.5× |
| JSON parsing | 12ms | 2ms | 6× |
| DNS resolution | 47ms | 0ms | ∞ (cached) |
| TLS handshake | 89ms | 0ms | ∞ (pooled) |
| HTTP serialization | 8ms | 1ms | 8× |
| Network transit | 94ms | 91ms | 1.03× |
| Response parsing | 11ms | 2ms | 5.5× |
| Logging | 58ms | 1ms | 58× |
| Total | 340ms | 101ms | 3.4× |
The 91ms vs 94ms network improvement is noise - same physical path, same Polygon RPC endpoint. Everything else improved significantly.
P50 latency: 101ms. P95 latency: 148ms. P99 latency: 187ms.
In TypeScript my P99 was 680ms - a full 680ms from signal to fill on a bad run.
What Actually Changed in Trading Performance
This is the section most bot tutorials skip. Here's what the latency improvement translated to in practice, across 2 weeks of paper trading on both versions simultaneously:
Average entry price:
- TypeScript: 75.4¢ (detected 70.1¢, slipped 5.3¢ on average)
- Rust: 71.2¢ (detected 70.1¢, slipped 1.1¢ on average)
Fill rate on target markets (% of signals where I got a fill within 0.5¢ of detection price):
- TypeScript: 61%
- Rust: 89%
Win rate on filled trades (did the market resolve in my predicted direction):
- TypeScript: 71%
- Rust: 73%
That last number is the important one. Rust improved my win rate by 2 percentage points. That sounds small. At the entry prices I trade (averaging ~75¢), a 2-point win rate improvement is the difference between marginal profitability and clear profitability over a large sample.
But I want to be honest: the win rate improvement is partially due to better fills (entering closer to the detected price means I'm entering when the market is actually at the inefficiency, not after it's partially corrected) and partially noise. Two weeks of paper trading is not a statistically significant sample for a 2-point difference.
What Didn't Change: The Uncomfortable Truth
Rust did not give me better alpha. On days when my signal logic was wrong - when I was entering directional plays in a choppy, mean-reverting market - Rust executed those bad trades faster. Speed amplifies both good and bad strategy.
Rust did not solve the scaling ceiling. I still can't place orders larger than $1–2 per position on thin 5-minute markets without moving the price against myself. That's a liquidity problem, not a latency problem.
Rust did not eliminate the paper-trading divergence problem. (That's a separate article.) The Gamma API returns bid prices; the CLOB fills you at ask prices. My paper trading P&L still looks better than live P&L because of this systematic difference. Rust didn't change that.
The development cost was real. The Rust bot took me roughly 3× longer to write than the TypeScript equivalent. The borrow checker rejected my first three approaches to the shared PolymarketClient. Async Rust is significantly more complex than async TypeScript. If your bottleneck is iteration speed on strategy logic, TypeScript is a better choice. Rust pays off when you've validated your strategy and need to optimize execution.
Should You Do This?
Here's my honest decision framework:
Rewrite in Rust if:
- You've validated your strategy logic produces positive EV over 1,000+ paper trades
- Your profiling shows latency bottlenecks in connection management or CPU-bound operations
- You're comfortable with a 2-4 week higher-complexity development phase
- You're already a systems engineer - the learning curve is steep if you're coming from scripting languages
Don't rewrite in Rust if:
- You're still iterating on strategy logic (TypeScript iteration speed wins)
- Your bottleneck is network latency to Polygon RPC nodes (co-location solves this, not Rust)
- You have < 500 validated paper trades (you're optimizing before you have signal)
- Your total position sizes are under $5/trade (at that scale, 200ms of slippage costs < $0.01)
What's Next
Two things I'm working on that Rust makes tractable:
1. WebSocket price feed instead of polling. Right now I poll clob.polymarket.com/midpoints every 30 seconds. With tokio-tungstenite, I can subscribe to Polymarket's WebSocket feed and receive price updates in real time, reducing the detection lag from ~15 seconds (average time between poll and price change) to ~50ms.
2. Chainlink oracle cross-validation. Before placing an order, I want to compare Polymarket's implied probability against Chainlink's BTC/ETH/SOL price feeds on-chain. If Polymarket says "BTC Up is 82% likely" but Chainlink's oracle shows the last-reported price is already past the threshold, that's a stale market - not a mispricing. I want to skip those. This is the blockchain engineer angle that I think most Python/TypeScript bot builders overlook.
Conclusion
The TypeScript → Rust rewrite reduced my order placement latency from 340ms to 101ms - a 3.4× improvement. This translated to meaningfully better entry prices (1.1¢ slippage vs 5.3¢) and a modestly better fill rate (89% vs 61% within 0.5¢ of target).
But the bigger lesson is in the profiling. 194ms of the original 340ms was pure configuration debt - no DNS caching, no connection pooling, synchronous logging on the hot path. Those fixes are available in TypeScript too. If you're running a Polymarket bot in Node.js and haven't profiled your order path yet, do that before considering a rewrite. You might recover 60% of the latency without changing languages.
Rust was the right next step for me as a blockchain engineer building toward a production system. But fast wrong code is still wrong code. Profile first. Validate your strategy first. Then optimize.
This is part 6 of my Polymarket Trading Bot series. Previous posts: Architecture & Execution · Kelly Criterion Sizing · Last-60-Second Capture · Dashboard War Stories · Last-Entry Probability Capture in TypeScript
All trading described is paper trading unless otherwise stated. This is not financial advice. Prediction market trading carries real risk of loss.