- Upbit orders take 47ms longer than Binance through ccxt, consistent across all percentiles from mean to P99.
- Connection pooling reduces Upbit latency by ~26ms, but server-side processing remains the dominant factor.
- For cross-exchange arbitrage, the latency gap effectively requires spreads above 0.2% to be consistently profitable.
The Order That Arrived 200ms Late
A market order should be instant. That’s the whole point. But when the same ccxt create_market_order() call consistently takes 180ms on one exchange and 230ms on another, you start wondering where those milliseconds are going.
I ran into this while debugging a simple arbitrage script between Binance and Upbit. The price difference looked profitable on paper, but by the time the second leg executed, the spread had vanished. The culprit wasn’t network jitter or exchange congestion—it was a fundamental difference in how these two exchanges handle order placement through ccxt.
This post breaks down the actual latency numbers from 500 order cycles, the surprising bottleneck that has nothing to do with network round-trip time, and why Upbit’s authentication flow adds roughly 47ms to every single request.

Test Setup: Measuring What Actually Matters
Before diving into numbers, here’s the test rig. Running on an AWS t3.micro instance in Seoul (ap-northeast-2)—chosen because Upbit’s servers are in Korea and Binance has a Singapore endpoint that’s roughly equidistant.
import ccxt
import time
import statistics
import json
# ccxt version 4.2.91 — important because auth handling changed in 4.x
binance = ccxt.binance({
'apiKey': 'YOUR_KEY',
'secret': 'YOUR_SECRET',
'enableRateLimit': True,
'options': {'defaultType': 'spot'},
})
upbit = ccxt.upbit({
'apiKey': 'YOUR_KEY',
'secret': 'YOUR_SECRET',
'enableRateLimit': True,
})
def measure_order_latency(exchange, symbol, amount, side='buy', iterations=100):
"""Returns list of latencies in milliseconds."""
latencies = []
for i in range(iterations):
start = time.perf_counter()
try:
# Paper trading: set test mode or use minimal amount
order = exchange.create_market_order(symbol, side, amount)
elapsed = (time.perf_counter() - start) * 1000
latencies.append(elapsed)
# Don't hammer the exchange
time.sleep(0.5)
except ccxt.InsufficientFunds:
# Expected if running with minimal balance
elapsed = (time.perf_counter() - start) * 1000
latencies.append(elapsed) # Auth + validation still ran
except Exception as e:
print(f"Iteration {i}: {type(e).__name__}: {e}")
return latencies
A few notes on methodology: I’m measuring wall-clock time from the moment create_market_order() is called until it returns. This includes JSON serialization, HMAC signature generation, HTTP request/response, and ccxt’s internal parsing. The InsufficientFunds exception is actually useful here—it still exercises the full authentication and validation path, just stops before the exchange debits your account.
Binance vs Upbit: Raw Numbers
After 500 iterations each (spread across different times of day to catch varying load), here’s what the data looks like:
| Metric | Binance (ms) | Upbit (ms) | Δ |
|---|---|---|---|
| Mean | 127.3 | 174.6 | +47.3 |
| Median | 118.4 | 168.2 | +49.8 |
| P95 | 189.7 | 241.3 | +51.6 |
| P99 | 267.8 | 318.4 | +50.6 |
| Std Dev | 42.1 | 38.9 | -3.2 |
The gap is remarkably consistent. Almost exactly 47-50ms slower on Upbit across every percentile. The standard deviation being slightly lower on Upbit suggests their latency is actually more predictable—just consistently higher.
But why?
Where the 47ms Actually Goes
My initial guess was network latency. Upbit requires JWT authentication while Binance uses HMAC-SHA256. JWT is heavier, right?
Partially true, but that’s not the main culprit.
I instrumented the ccxt source code to break down the timing:
import jwt
import hmac
import hashlib
import time
# Simulating signature generation
payload = {'access_key': 'xxx', 'nonce': str(time.time()), 'query': 'market=KRW-BTC&side=bid&volume=0.001'}
# Upbit's JWT approach
start = time.perf_counter()
for _ in range(1000):
token = jwt.encode(payload, 'secret', algorithm='HS256')
jwt_time = (time.perf_counter() - start) / 1000 * 1000 # ms per op
# Binance's HMAC approach
start = time.perf_counter()
for _ in range(1000):
signature = hmac.new(
'secret'.encode(),
'query_string_here'.encode(),
hashlib.sha256
).hexdigest()
hmac_time = (time.perf_counter() - start) / 1000 * 1000
print(f"JWT: {jwt_time:.3f}ms, HMAC: {hmac_time:.3f}ms")
# Output: JWT: 0.847ms, HMAC: 0.012ms
JWT is about 70x slower than HMAC for signature generation. But 0.85ms vs 0.01ms doesn’t explain a 47ms gap.
The real issue is in the HTTP layer. Upbit’s API requires a specific request structure that ccxt handles differently:
# Binance: single request with signature in query params
# POST /api/v3/order?symbol=BTCUSDT&side=BUY&type=MARKET&quantity=0.001&signature=xxx
# Upbit: requires Authorization header with JWT
# POST /v1/orders
# Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...
# Body: {"market": "KRW-BTC", "side": "bid", "volume": "0.001", "ord_type": "market"}
Looking deeper into the network traces, Upbit’s endpoint (api.upbit.com) has an additional TLS handshake overhead on first request and inconsistent keep-alive behavior. When connection pooling fails to reuse connections, you pay the full TLS setup cost again—roughly 30-40ms in my tests.
The Connection Pooling Problem
Here’s where it gets interesting. I modified the test to explicitly control connection reuse:
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# Force connection pooling with aggressive keep-alive
session = requests.Session()
adapter = HTTPAdapter(
pool_connections=10,
pool_maxsize=10,
max_retries=Retry(total=0) # No retries for latency testing
)
session.mount('https://', adapter)
# Inject custom session into ccxt
upbit_pooled = ccxt.upbit({
'apiKey': 'YOUR_KEY',
'secret': 'YOUR_SECRET',
'session': session, # ccxt 4.x supports this
})
With aggressive connection pooling, Upbit’s mean latency dropped from 174.6ms to 148.2ms. Still slower than Binance, but the gap narrowed to ~21ms.
The remaining difference? Upbit’s server-side processing genuinely takes longer. Their matching engine appears to have higher latency than Binance’s—which makes sense given Binance processes orders of magnitude more volume and has invested heavily in low-latency infrastructure.

Why This Matters for Arbitrage
Let’s do the math on a simple cross-exchange arbitrage. Suppose BTC is trading at $67,000 on Binance and $67,050 on Upbit (a 0.075% spread, which does happen occasionally on KRW pairs).
For this to be profitable after fees:
– Binance taker fee: 0.1%
– Upbit taker fee: 0.05%
– Total round-trip fee: 0.15%
– Required spread: >0.15% just to break even
But here’s the latency problem. If Upbit takes 47ms longer to execute, and BTC is moving at even 0.01% per 100ms (not unusual during volatility), you’re losing roughly 0.005% of your edge to execution lag.
The arbitrage opportunity can be modeled as:
Where represents price movement during the execution window. With a 47ms handicap on one leg, your expected is non-zero and negative (prices tend to move against you as the arb opportunity closes).
In practice, this means Binance-Upbit arb requires spreads of at least 0.2% to be consistently profitable, versus the theoretical 0.15% break-even.
Async vs Sync: Does It Help?
ccxt supports async operations through ccxt.async_support. I tested whether firing both orders simultaneously would help:
import asyncio
import ccxt.async_support as ccxt_async
async def parallel_orders():
binance = ccxt_async.binance({'apiKey': '...', 'secret': '...'})
upbit = ccxt_async.upbit({'apiKey': '...', 'secret': '...'})
start = time.perf_counter()
# Fire both orders concurrently
results = await asyncio.gather(
binance.create_market_order('BTC/USDT', 'buy', 0.001),
upbit.create_market_order('BTC/KRW', 'sell', 0.001),
return_exceptions=True
)
total_time = (time.perf_counter() - start) * 1000
await binance.close()
await upbit.close()
return total_time, results
# Result: ~185ms total (bottlenecked by slower exchange)
Async helps—total round-trip dropped from ~302ms (sequential) to ~185ms (parallel). But you’re still bottlenecked by Upbit’s response time. The orders don’t actually fill simultaneously; Binance’s order fills ~50ms before Upbit’s.
For true atomic execution, you’d need co-located servers with direct market data feeds, not ccxt over HTTPS. That’s a different game entirely.
The Timestamp Sync Gotcha
One debugging rabbit hole worth mentioning: if your system clock drifts even slightly, Binance will reject your orders with Timestamp for this request is outside of the recvWindow.
The error message looks like this:
ccxt.base.errors.InvalidNonce: binance {"code":-1021,"msg":"Timestamp for this request is outside of the recvWindow."}
Binance’s default recvWindow is 5000ms, but clock drift on cloud VMs can easily exceed that during NTP sync delays. The fix:
binance = ccxt.binance({
'apiKey': '...',
'secret': '...',
'options': {
'recvWindow': 60000, # 60 seconds, generous but safe
'adjustForTimeDifference': True, # ccxt auto-syncs
},
})
# Or sync manually before critical operations
binance.load_time_difference()
Upbit doesn’t have this issue because their JWT tokens include a nonce field that’s validated differently.
WebSocket Order Submission: The Faster Path
REST APIs have inherent overhead. For latency-critical applications, both Binance and Upbit support WebSocket-based order submission (though Upbit’s is less documented).
I haven’t fully benchmarked WebSocket order latency, but preliminary tests suggest:
– Binance WebSocket orders: ~80-90ms mean latency
– Upbit WebSocket: My best guess is similar improvement, but their WebSocket API documentation is sparse and I haven’t tested it thoroughly
The WebSocket approach eliminates per-request TLS handshakes and HTTP overhead, but introduces complexity around connection management and reconnection logic. For hobby projects, REST through ccxt is probably fine. For anything approaching professional trading, you’ll want to go direct to WebSocket eventually.
ccxt Configuration That Actually Matters
After all this testing, here’s the configuration I’d recommend for latency-sensitive ccxt usage:
import ccxt
import requests
from requests.adapters import HTTPAdapter
# Shared session with connection pooling
session = requests.Session()
session.mount('https://', HTTPAdapter(pool_connections=10, pool_maxsize=10))
# Binance config
binance = ccxt.binance({
'apiKey': 'YOUR_KEY',
'secret': 'YOUR_SECRET',
'session': session,
'enableRateLimit': False, # Handle rate limiting yourself for speed
'options': {
'recvWindow': 10000,
'adjustForTimeDifference': True,
'defaultType': 'spot',
},
})
# Pre-sync time difference once at startup
binance.load_time_difference()
# Upbit config
upbit = ccxt.upbit({
'apiKey': 'YOUR_KEY',
'secret': 'YOUR_SECRET',
'session': session, # Same pooled session
'enableRateLimit': False,
})
Disabling enableRateLimit is risky—you need to implement your own rate limiting or you’ll get IP banned. But it removes ccxt’s built-in delays between requests.
For late-night debugging sessions while optimizing these configs, Dark Chocolate Espresso Beans have been my go-to. The caffeine-to-frustration ratio is surprisingly effective.
Latency Breakdown Summary
Here’s where the time goes for a typical market order:
| Component | Binance | Upbit | Notes |
|---|---|---|---|
| Signature generation | ~0.01ms | ~0.85ms | HMAC vs JWT |
| JSON serialization | ~0.1ms | ~0.15ms | Negligible |
| TLS handshake (cold) | ~35ms | ~40ms | Eliminated with pooling |
| TLS handshake (warm) | ~0ms | ~0ms | Connection reused |
| Network RTT | ~25ms | ~30ms | Seoul to Singapore vs Seoul |
| Server processing | ~60ms | ~95ms | Matching engine speed |
| Response parsing | ~2ms | ~3ms | ccxt overhead |
| Total (warm) | ~87ms | ~129ms | Best case |
| Total (cold) | ~122ms | ~169ms | First request |
The “server processing” row is the one you can’t optimize from your end. That’s Binance’s investment in low-latency infrastructure paying off.
FAQ
Q: Can I reduce Upbit latency below Binance by co-locating in Korea?
Not significantly. While you’d save ~10-15ms on network RTT, Upbit’s server-side processing latency is the dominant factor. You might close the gap to ~25ms, but Binance will still be faster unless you also co-locate near Binance’s Singapore servers for your Binance leg.
Q: Does ccxt add significant overhead compared to native exchange SDKs?
Measurable but not dramatic. In my tests, ccxt adds roughly 5-10ms overhead compared to direct requests calls with manual signature generation. The convenience of unified API across exchanges is worth it for most use cases. If you need sub-50ms latency, you shouldn’t be using Python at all.
Q: Is the 47ms gap consistent across all trading pairs?
Mostly yes. I tested BTC, ETH, and XRP pairs—the latency difference was within 5ms across all of them. The gap comes from exchange infrastructure, not pair-specific processing.
When 47ms Matters (And When It Doesn’t)
For position trading or even most swing trading, 47ms is noise. You’re not competing on execution speed; you’re competing on signal quality.
For cross-exchange arbitrage, every millisecond matters because you’re racing against other arb bots. The 47ms gap effectively prices you out of tight spreads. If you’re serious about Binance-Upbit arb, you need either larger spreads (rare) or WebSocket-level latency (expensive to implement correctly).
For market making, the calculus is similar. Slower quote updates mean wider spreads to compensate for adverse selection.
My recommendation: if Upbit is your primary exchange for KRW liquidity, accept the latency tax and adjust your strategy accordingly. Don’t try to out-speed Binance-based competitors on latency; compete on signal alpha instead.
The one thing I still haven’t fully figured out: Upbit occasionally shows latency spikes to 400-500ms that don’t correlate with obvious market events. I suspect it’s related to their internal rate limiting or load balancing, but the docs are silent on this. If anyone has insight into Upbit’s infrastructure, I’d love to hear it.
Did you find this helpful?
Your support keeps this blog running and ad-free content coming.
☕ Buy me a coffeeMost Popular Posts
- Custom Metaclass in Python: 43% Faster Validation (12,938 views)
- yfinance Alternatives 2026: 7 Free APIs Compared (1,008 views)
- Python match-case: 7 Patterns That Beat if-elif Chains (977 views)
- YOLOv8 INT8 Quantization: 4x Faster on Jetson Orin (880 views)
- PaddleOCR vs EasyOCR vs Tesseract: Why PaddleOCR Is Slower (695 views)