First month 25% off for new traders — code

How to test Polymarket VPS latency for better trade execution in 2026

Written by TradoxVPS Engineering Team
|
VPS Latency Testing For Polymarket Trading

Most traders test latency wrong. They ping an endpoint, see “1 ms,” and assume that is their execution speed. But a ping is not an order. A 1 ms ICMP echo tells you nothing about how long it takes your market data to arrive, your code to react, and your order to reach Polymarket’s matching engine and come back. By the time that round trip finishes, the price you saw may already be gone.

This guide shows you how to test latency of a Polymarket VPS the way a serious bot operator should: by measuring the path that actually decides your fills, from market signal to order confirmation. We cover what latency means for a prediction market, every network phase that adds milliseconds (DNS, TCP, TLS, TTFB), the statistics that matter (p50, p95, p99, max, and jitter), the exact endpoints a real Polymarket bot touches, the four ways to measure them, a complete and tested Python benchmark you can run today, and a fully analyzed Dublin VPS result so you can see what “good” looks like.

Key takeaways

  • A ping is not your trading latency. The number that matters is the warm round-trip on the order path and the WebSocket message latency — not a one-off ping.
  • Watch the tail (p99) and jitter (standard deviation), not just the average. One slow order in fifty is what costs you money.
  • For Polymarket, a Dublin-located VPS sits closest to the order book infrastructure. Use our Dublin VPS as the reference point.
  • A proper Python probe beats a bash one-liner because it measures warm connections, real WebSocket round-trips, and exposes DNS stalls that simpler tools hide.

What “latency” actually means for a Polymarket bot

Latency, for a trader, is the time between a market event happening and your order acting on it. It is not a single number — it is a chain of delays, and your VPS only controls some of them. The clearest way to think about it is in three layers. (For a deeper treatment of why these milliseconds translate into real money, see our explainer on how milliseconds matter for order execution.)

Layer 1 — Network latency

The time for a packet to travel from your VPS to Polymarket’s infrastructure and back. This is the layer your VPS provider and its data-center routing control, and it is the focus of most of this guide. It breaks into four sequential phases — DNS, TCP, TLS, and TTFB — covered in detail in the next section.

Layer 2 — Application latency

The time for your own code to receive data, deserialize it, decide, and serialize an order. This is entirely on your side: a tight, event-driven bot does this in single-digit milliseconds, while a poorly written one with a blocking call or a database query in the hot path can add hundreds. Single-threaded languages like Python are bound by CPU clock speed here, which is why CPU single-core performance matters for trading and why we run Ryzen 9950X hardware. No VPS can fix slow code — see our guide on optimizing a VPS for algorithmic trading to tighten this layer.

Layer 3 — Execution latency

The time for Polymarket’s CLOB matching engine to receive your order, match it, and confirm. You cannot control this, but you need to know it exists: during a high-volume event it can spike as the engine processes thousands of orders per second. It is usually the smallest piece, except during volatility.

One more layer for on-chain activity. Polymarket settles on the Polygon blockchain. Pure CLOB order placement does not wait for a block per order, but if your strategy interacts with on-chain settlement directly, add roughly one to three seconds for Polygon confirmation. Understanding this distinction matters — it is covered in our Polymarket API and real-time order book guide.

The mistake most traders make is testing only Layer 1 — and testing it badly — then blaming the VPS for what is actually Layer 2. This guide keeps the layers separate so you can attribute delay correctly.

polymarket-signal-to-fill-workflow

Who fixes which part: a fast VPS, your code, or no one

Every millisecond between a market move and your fill belongs to one of three owners. Knowing which is which is the whole game, because it tells you where a problem can actually be fixed. Most traders waste effort here: they blame the VPS for delays that are really their own code, or they chase a “faster” provider to shave a millisecond off a number that is mostly Polymarket’s fixed server time. Your total trading-bot execution time is the sum of three buckets, and you can only touch two of them.

polymarket-latency-attribution

What a fast VPS solves (the part you buy). Network distance to Polymarket’s edge, routing quality, DNS resolution, CPU clock speed, and jitter. A VPS sitting at the Dublin internet exchange is physically close to the order book infrastructure; a clean image with a caching resolver removes DNS stalls; a high-clock Ryzen 9950X runs your single-threaded bot faster; and dedicated resources keep jitter low. This is the bucket you solve by choosing the right VPS rather than building anything — it is exactly what server location and single-core performance are about, and from a good Dublin box it is only a few milliseconds.

What your code solves (the part you build). Connection reuse (the warm-versus-cold distinction explained in the next section), an event-driven WebSocket loop instead of REST polling, efficient strategy logic, fast JSON parsing, and no blocking calls in the hot path. This is usually the largest and most variable slice: a tight bot adds single-digit milliseconds, while a poorly written one with a database query in the order path can add hundreds. No VPS, however fast, can rescue slow code — see optimizing a VPS for algorithmic trading and our guide to building a trading algorithm for how to shrink it.

What no one can solve (the shared floor). Polymarket’s matching-engine and server processing time, the speed-of-light minimum for the physical distance, Polygon’s block time for on-chain settlement, and the engine slowdown that happens during a high-volume event. This is the floor every Polymarket trader shares, regardless of provider. You cannot change it — you can only know it exists and design around it (for example, by not waiting on an on-chain confirmation inside a latency-sensitive loop).

The strategic takeaway falls straight out of the diagram: you can only compete on the first two buckets. The third is identical for everyone trading from the same location, so the winner is not whoever advertises the lowest single number — it is whoever is both fast and consistent in the parts they control. That is precisely why this guide keeps steering you toward p99 and jitter rather than a marketing “1 ms,” and why a fast, well-configured VPS plus a well-written bot beats either one alone.

The four network phases: DNS, TCP, TLS, TTFB

When your bot makes its first request to a Polymarket endpoint, four things happen in sequence before you get a single byte of data back. A bare “1 ms ping” skips all of them. Here is what each one is and what a healthy value looks like from a well-located VPS.

DNS lookup. Translating a hostname like clob.polymarket.com into an IP address. On a healthy VPS with a local caching resolver this is well under a millisecond after the first lookup. If you ever see DNS times spiking to several seconds, that is not the network — it is a broken resolver, and we devote a whole troubleshooting section to it below because it is one of the most common and most damaging misconfigurations.

TCP connect. Opening the connection to the server. This is the cleanest proxy for raw network distance, because it is essentially one network round trip with nothing else mixed in. From a Dublin VPS sitting at the local internet exchange, TCP connect to Polymarket’s edge is roughly 0.5–1.5 ms. If you see 10 ms or more and it is rock-steady, your traffic is likely being routed to a distant edge — a routing detour worth investigating with the mtr method below.

TLS handshake. Negotiating encryption. This takes one to two additional round trips, so it scales with your network distance — typically 8–16 ms from a well-placed VPS. Crucially, this cost is paid once per connection. A bot that reuses a connection pays it a single time; a bot that opens a fresh connection per request pays it on every order.

TTFB (Time To First Byte). The time from sending your request to receiving the first byte of the response. This includes one network round trip plus Polymarket’s own server processing time. For the order book it lands around 60–70 ms cold, most of which is the server side and is identical for every provider.

The single most important concept here — and the one most latency tests miss — is cold versus warm.

Cold versus warm: the distinction that changes everything

cold request pays the full sequence: DNS + TCP + TLS + TTFB. A warm request reuses an already-open, already-encrypted connection, so it pays only the round trip plus server time. The difference is large: a cold request to the order book might total 85 ms, while the same request on a warm pooled connection is around 23 ms.

Cold versus warm request breakdown: DNS, TCP, TLS and TTFB total about 85 ms cold, while a warm pooled request is 23 ms

Why does this matter? Because a real trading bot keeps its connections open. It holds a persistent connection pool to the CLOB and a long-lived WebSocket for market data. So the warm number is the one that reflects your actual trading latency — and the cold number, which many simple tools report as “your latency,” dramatically overstates it. Any benchmark that does not separate the two is misleading you. We will come back to this when we explain why a bash one-liner falls short.

Reading the numbers: p50, p95, p99, max, and jitter

Run a latency test fifty times and you get fifty different numbers. Reporting the average of them is one of the most common mistakes in trading infrastructure, because the average hides exactly the events that cost you money. Percentiles tell the real story.

  • p50 (median). Half your requests are faster than this, half slower. This is your typical latency.
  • p95. Ninety-five percent of requests finish within this time; the slowest 5% are worse. This is where you start seeing the tail.
  • p99. Ninety-nine percent finish within this time. This is the tail that bites during real trading — one slow order in a hundred.
  • max. The single worst request in the run. Useful for spotting catastrophic outliers (a multi-second max almost always means a DNS or routing problem, not normal variance).
  • jitter (standard deviation). How spread out the numbers are. This is as important as the median for trading. Two VPSs can both show a 23 ms median, but one with 2 ms of jitter is predictable and one with 30 ms of jitter is not. A market maker can quote tighter when the latency is steady, because the tail risk is lower.
Fifty sorted latency samples with p50, p95 and p99 markers, comparing a tight-tail profile against a dangerous loose-tail profile

Here is the practical rule: a fast median with a tight tail beats a slightly faster median with a loose tail. A profile of p50 = 23 ms, p99 = 35 ms, jitter = 4 ms is excellent. A profile of p50 = 20 ms, p99 = 300 ms, jitter = 40 ms is dangerous, even though its median looks better — because one order in a hundred is stalling for a third of a second, and during a fast market that is precisely when you needed it to be quick.

When you compare two providers, compare them on p50, p99, and jitter together, and make sure both runs happened at the same time of day — market activity inflates tails, so a quiet-hour run will always look better than a busy-hour run regardless of the hardware. This is the single biggest source of misleading provider comparisons.

The endpoints a real Polymarket bot actually touches

A real Polymarket trading bot does not talk to one server. It touches several, and a complete latency test measures all of them — because a delay in any one of them can hurt you. Here are the endpoints that matter and why, with the correct, current hostnames (a surprising number of guides test the wrong URLs or hit cacheable paths that never reach the origin, producing flattering but meaningless numbers).

Map of the seven endpoints a Polymarket bot touches, with the CLOB REST API and WebSocket market channel marked trade-critical

CLOB — the order book and order submission (trade-critical). https://clob.polymarket.com. This is where you read the order book and where you post and cancel orders. It is the single most important REST path for execution. Test it against a live token’s order book (/book?token_id=...) so the request reaches the origin matching engine rather than being served from a CDN edge cache.

WebSocket market channel — the real-time feed (trade-critical). wss://ws-subscriptions-clob.polymarket.com/ws/market. This is how your bot sees the book move in real time. For most strategies this is the single most important path of all, because it determines how current your view of the market is. The right way to measure it is a PING/PONG round-trip on a live connection — not just the time to open the socket.

Relayer — gasless order submission. https://relayer-v2.polymarket.com. Polymarket’s proxy-wallet flow submits orders through a relayer. If your bot uses the gasless path, this endpoint is part of your order submission latency.

Gamma API — market metadata. https://gamma-api.polymarket.com. Market definitions, events, and metadata. Not on the hot path, but your bot reads it.

Data API — history and positions. https://data-api.polymarket.com. Portfolio, positions, and historical data. Not latency-critical, but worth checking for reachability.

Polygon RPC — settlement chain. The public https://polygon-rpc.com or a paid provider (Infura, Alchemy). Your on-chain settlement and allowance checks go here. Important: the public RPC is frequently rate-limited and unreliable — in our own benchmarking it produced multi-second connection spikes and intermittent errors. If your strategy depends on chain calls, use a paid RPC endpoint. (If you are migrating bots, our Polymarket V2 migration guide covers the contract and allowance changes.)

Binance — reference market data. https://api.binance.com. Many strategies use an external reference price. It is not Polymarket-critical, but it tells you how your VPS routes to a major exchange. Note that Binance geo-routes, so from Europe you may hit a distant region.

A test that only checks the CLOB misses the relayer delay, the chain flakiness, and — most importantly — the WebSocket feed. The Python probe below measures every one of these in a single run.

Four ways to test Polymarket VPS latency (and which to trust)

There are four practical methods. Each measures something different, and they are complementary — but they are not equal, and one of them is the right basis for any real decision. Here is an honest assessment of each.

Comparison of four latency test methods — ping/mtr, curl, FAK order test and the Python probe recommended for decisions

Method 1 — ping and mtr (the routing test)

What it measures: the raw network path and per-hop latency to an endpoint.

ping gives you a round-trip number. mtr is far more useful: it shows every router (hop) between your VPS and the destination, with latency and packet loss at each. This is the only method that reveals why your network latency is what it is.

mtr -rwzbc 30 clob.polymarket.com

This is invaluable for one specific job: confirming your traffic reaches the local Polymarket edge rather than detouring through a distant city. If you run this from a Dublin VPS and see a hop in London or a third-party transit carrier before you reach the destination, you have found a routing inefficiency that is costing you milliseconds — and it is the provider’s to fix, not something you can change in your code. A well-peered VPS will show a short path that hands off directly at the local internet exchange.

Limitation: it measures the network only, not your application or the order round-trip, and ICMP is often de-prioritized by routers (so an intermediate hop showing high latency or loss is frequently a measurement artifact — trust the destination hop). Use it for: diagnosing routing and distance.

Method 2 — curl / bash one-liner (the quick health check)

What it measures: the cold-connection phase breakdown to an endpoint, using curl’s built-in timing.

curl -w "DNS: %{time_namelookup}s  TCP: %{time_connect}s  TLS: %{time_appconnect}s  TTFB: %{time_starttransfer}s\n" -o /dev/null -s https://clob.polymarket.com/

A bash script wrapping this in a loop is genuinely useful as a fast, zero-dependency sanity check — “is this box reaching everything, roughly how far away is it?” It runs anywhere curl exists, which is everywhere.

But it has three limitations that make it the wrong basis for a real decision, and it is important to understand them:

  1. It is cold-only. Every curl invocation is a fresh process with a new connection, so it always pays the full DNS + TCP + TLS handshake. It cannot measure the warm round-trip that a real pooled bot actually experiences — so it systematically overstates your trading latency by the handshake cost.
  2. It cannot do WebSockets. curl does not speak the WebSocket protocol, so it can measure the time to open the socket but not the PING/PONG message latency — and the feed is the most important path for most bots.
  3. It hides DNS stalls. Most loop scripts warm up DNS before measuring, so the OS resolver cache hides exactly the intermittent multi-second stalls you most need to catch.

Use it for: quick health checks and the broader-stack reachability glance. Do not make a provider decision on its numbers alone.

Method 3 — authenticated fill-and-kill order test (the ground truth)

What it measures: true end-to-end latency — network plus application plus execution — by placing a real (or testnet) order and timing it to confirmation.

This is the only method that captures the complete picture including the matching engine, because it places an actual FAK (fill-and-kill) order and measures from submission to fill. It is the ground truth your live trading will experience.

Limitations: it requires authenticated API access and API keys, it involves real money or a testnet, and it is slower to run. Use it for: final validation before going live with significant capital. Our guide on setting up a Polymarket bot on a VPS covers the API setup you need for this.

Method 4 — the Python probe (the right tool for decisions)

What it measures: everything that matters, properly — the cold phase breakdown and the warm pooled round-trip, a real WebSocket PING/PONG round-trip, large-sample statistics with p50 through p99 and jitter, and DNS measured fresh so resolver stalls surface.

This is the method we recommend for any real latency assessment, and it is the one we use ourselves. Here is why Python is the best way to test Polymarket VPS latency:

  • It can hold a persistent connection and measure the warm round-trip — the number a real bot actually sees — which bash cannot.
  • It can open a real WebSocket, subscribe to the market channel, and time PING/PONG round-trips — capturing the single most important path for live trading.
  • It collects hundreds of samples over a sustained run and computes p50/p90/p95/p99, max, and jitter, so the tail statistics are trustworthy rather than resting on one or two samples.
  • It measures DNS fresh each round, so a broken resolver’s multi-second stalls show up instead of being hidden by a cache.
  • It probes the whole stack — CLOB, WebSocket, relayer, Gamma, Data API, Polygon RPC, and Binance — in a single run, with POST support for the chain RPC.

It needs Python (pre-installed on every Linux VPS) and one small package for the WebSocket test. That is the only “cost,” and in exchange you get numbers you can actually base a decision on. The full script is below.

The complete Python latency benchmark script

Save the following as pm_latency_v3.py on the VPS you want to test. It is self-contained and measures the cold breakdown, the warm pooled round-trip, the WebSocket PING/PONG, and the full statistics for every endpoint a real Polymarket bot uses.

#!/usr/bin/env python3

import argparse
import json
import math
import os
import socket
import ssl
import statistics
import sys
import time
from datetime import datetime, timezone
from urllib.parse import urlsplit
import urllib.request

USER_AGENT = "pm-latency-probe/3.0"

# ----------------------------------------------------------------------------
# Endpoint configuration
# ----------------------------------------------------------------------------

# Polygon RPC: use Infura if a key is supplied (matches the bash tool), else the
# public RPC. eth_blockNumber is a cheap POST that reaches the chain node.
INFURA_KEY = os.environ.get("INFURA_KEY", "").strip()
if INFURA_KEY:
    POLYGON_URL = f"https://polygon-mainnet.infura.io/v3/{INFURA_KEY}"
    POLYGON_NAME = "polygon_rpc_infura"
else:
    POLYGON_URL = "https://polygon-rpc.com/"
    POLYGON_NAME = "polygon_rpc_public"
POLYGON_BODY = '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}'

# group: pm = Polymarket | chn = settlement chain | ref = reference market
# critical = trade hot path (highlighted in the decision summary)
# `path` for clob_book is completed at runtime with a live token id.
REST_ENDPOINTS = [
    {"name": "clob_root",     "url": "https://clob.polymarket.com/",     "method": "GET",  "body": None,         "critical": True,  "group": "pm"},
    {"name": "clob_book",     "url": "https://clob.polymarket.com/book", "method": "GET",  "body": None,         "critical": True,  "group": "pm"},
    {"name": "clob_markets",  "url": "https://clob.polymarket.com/markets", "method": "GET", "body": None,       "critical": True,  "group": "pm"},
    {"name": "relayer_nonce", "url": "https://relayer-v2.polymarket.com/nonce?address=0x0000000000000000000000000000000000000000&type=SAFE", "method": "GET", "body": None, "critical": True, "group": "pm"},
    {"name": "gamma_events",  "url": "https://gamma-api.polymarket.com/events", "method": "GET", "body": None,   "critical": False, "group": "pm"},
    {"name": "data_trades",   "url": "https://data-api.polymarket.com/trades",  "method": "GET", "body": None,   "critical": False, "group": "pm"},
    {"name": POLYGON_NAME,    "url": POLYGON_URL,                         "method": "POST", "body": POLYGON_BODY, "critical": False, "group": "chn"},
    {"name": "binance_ping",  "url": "https://api.binance.com/api/v3/ping", "method": "GET", "body": None,       "critical": False, "group": "ref"},
]

WS_MARKET_URL = "wss://ws-subscriptions-clob.polymarket.com/ws/market"
SAMPLING_MARKETS_URL = "https://clob.polymarket.com/sampling-markets"
MARKETS_URL = "https://clob.polymarket.com/markets"


# ----------------------------------------------------------------------------
# Stats helpers
# ----------------------------------------------------------------------------

def percentile(values, p):
    """Linear-interpolated percentile. p in [0,1]."""
    if not values:
        return None
    s = sorted(values)
    if len(s) == 1:
        return s[0]
    k = (len(s) - 1) * p
    f = math.floor(k)
    c = math.ceil(k)
    if f == c:
        return s[int(k)]
    return s[f] * (c - k) + s[c] * (k - f)


def summarize(values, errors):
    n_ok = len(values)
    total = n_ok + errors
    if not values:
        return {"samples": total, "ok": 0, "errors": errors,
                "error_rate": round(errors / total, 4) if total else None,
                "min_ms": None, "mean_ms": None, "p50_ms": None, "p90_ms": None,
                "p95_ms": None, "p99_ms": None, "max_ms": None, "stdev_ms": None}
    r = lambda x: round(x, 3) if x is not None else None
    return {
        "samples": total,
        "ok": n_ok,
        "errors": errors,
        "error_rate": round(errors / total, 4) if total else 0.0,
        "min_ms":  r(min(values)),
        "mean_ms": r(statistics.fmean(values)),
        "p50_ms":  r(percentile(values, 0.50)),
        "p90_ms":  r(percentile(values, 0.90)),
        "p95_ms":  r(percentile(values, 0.95)),
        "p99_ms":  r(percentile(values, 0.99)),
        "max_ms":  r(max(values)),
        "stdev_ms": r(statistics.pstdev(values)) if n_ok > 1 else 0.0,  # jitter
    }


# ----------------------------------------------------------------------------
# REST: cold connection, phase-by-phase breakdown (GET or POST)
# ----------------------------------------------------------------------------

def probe_rest_cold(url, timeout, method="GET", body=None):
    """
    One cold request with no connection reuse. Returns phase timings (ms) plus
    resolved IP and HTTP status. DNS is measured fresh (no cache warming) so
    resolver stalls surface. Supports a JSON POST body (for the Polygon RPC).
    """
    parts = urlsplit(url)
    host = parts.hostname
    port = parts.port or (443 if parts.scheme == "https" else 80)
    path = parts.path or "/"
    if parts.query:
        path += "?" + parts.query
    method = (method or "GET").upper()

    t0 = time.perf_counter()
    # --- DNS ---
    infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
    t_dns = time.perf_counter()
    family, socktype, proto, _canon, sockaddr = infos[0]
    ip = sockaddr[0]

    raw = socket.socket(family, socktype, proto)
    raw.settimeout(timeout)
    sock = raw
    try:
        # --- TCP ---
        raw.connect(sockaddr)
        t_tcp = time.perf_counter()

        # --- TLS ---
        if parts.scheme == "https":
            ctx = ssl.create_default_context()
            sock = ctx.wrap_socket(raw, server_hostname=host)
        t_tls = time.perf_counter()

        # --- request + TTFB (first byte) ---
        body_bytes = body.encode() if body else b""
        if method == "POST" or body:
            head = (
                f"{method} {path} HTTP/1.1\r\n"
                f"Host: {host}\r\n"
                f"User-Agent: {USER_AGENT}\r\n"
                f"Accept: */*\r\n"
                f"Content-Type: application/json\r\n"
                f"Content-Length: {len(body_bytes)}\r\n"
                f"Connection: close\r\n\r\n"
            ).encode() + body_bytes
        else:
            head = (
                f"{method} {path} HTTP/1.1\r\n"
                f"Host: {host}\r\n"
                f"User-Agent: {USER_AGENT}\r\n"
                f"Accept: */*\r\n"
                f"Connection: close\r\n\r\n"
            ).encode()
        sock.sendall(head)
        first = sock.recv(4096)            # blocks until first packet arrives
        t_ttfb = time.perf_counter()

        try:
            status = int(first.split(b" ", 2)[1])
        except Exception:
            status = None

        while True:                        # drain for a comparable total
            chunk = sock.recv(65536)
            if not chunk:
                break
        t_total = time.perf_counter()
    finally:
        try:
            sock.close()
        except Exception:
            pass

    ms = lambda a, b: (b - a) * 1000.0
    return {
        "dns_ms":   ms(t0, t_dns),
        "tcp_ms":   ms(t_dns, t_tcp),
        "tls_ms":   ms(t_tcp, t_tls) if parts.scheme == "https" else 0.0,
        "ttfb_ms":  ms(t_tls, t_ttfb),     # ~1 network RTT + server processing
        "total_ms": ms(t0, t_total),
        "ip": ip,
        "status": status,
    }


# ----------------------------------------------------------------------------
# REST: warm / keep-alive request latency (connection reused) — GET or POST
# ----------------------------------------------------------------------------

class WarmClient:
    """
    Holds one persistent connection and measures the round-trip of a single
    small request on it -- what a bot with connection pooling actually
    experiences per request. Reconnects transparently if the server drops it.
    """
    def __init__(self, url, timeout, method="GET", body=None):
        import http.client
        self._http = http.client
        parts = urlsplit(url)
        self.scheme = parts.scheme
        self.host = parts.hostname
        self.port = parts.port or (443 if parts.scheme == "https" else 80)
        self.path = (parts.path or "/") + (("?" + parts.query) if parts.query else "")
        self.timeout = timeout
        self.method = (method or "GET").upper()
        self.body = body
        self.conn = None

    def _connect(self):
        if self.scheme == "https":
            self.conn = self._http.HTTPSConnection(
                self.host, self.port, timeout=self.timeout,
                context=ssl.create_default_context())
        else:
            self.conn = self._http.HTTPConnection(
                self.host, self.port, timeout=self.timeout)
        self.conn.connect()

    def _headers(self):
        h = {"User-Agent": USER_AGENT, "Accept": "*/*", "Connection": "keep-alive"}
        if self.body:
            h["Content-Type"] = "application/json"
        return h

    def request_ms(self):
        if self.conn is None:
            self._connect()
        t0 = time.perf_counter()
        try:
            self.conn.request(self.method, self.path, body=self.body, headers=self._headers())
            resp = self.conn.getresponse()
            resp.read()                    # must fully read to reuse the socket
            return (time.perf_counter() - t0) * 1000.0
        except Exception:
            try:
                self.conn.close()
            except Exception:
                pass
            self.conn = None
            self._connect()
            t1 = time.perf_counter()
            self.conn.request(self.method, self.path, body=self.body, headers=self._headers())
            resp = self.conn.getresponse()
            resp.read()
            return (time.perf_counter() - t1) * 1000.0

    def close(self):
        if self.conn:
            try:
                self.conn.close()
            except Exception:
                pass


# ----------------------------------------------------------------------------
# WebSocket: connect, first message, PING/PONG round-trip
# ----------------------------------------------------------------------------

def _find_token_id(obj):
    """Recursively pull the first token_id out of a CLOB markets JSON blob."""
    if isinstance(obj, dict):
        for k, v in obj.items():
            if k == "token_id" and isinstance(v, (str, int)) and str(v).isdigit():
                return str(v)
            r = _find_token_id(v)
            if r:
                return r
    elif isinstance(obj, list):
        for item in obj:
            r = _find_token_id(item)
            if r:
                return r
    return None


def fetch_live_token_id(timeout):
    """Grab one active token id so /book hits the origin and WS subscribe is valid."""
    for url in (SAMPLING_MARKETS_URL, MARKETS_URL):
        try:
            req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
            with urllib.request.urlopen(req, timeout=timeout) as r:
                data = json.loads(r.read().decode())
            tid = _find_token_id(data)
            if tid:
                return tid
        except Exception:
            continue
    return None


def run_ws_probe(url, token_id, ping_count, ping_interval, timeout):
    """
    Returns (connect_ms, first_msg_ms, [ping_rtt_ms, ...], errors, note).
    PING/PONG round-trip is the headline real-time metric: network RTT + backend
    turnaround on a warm socket -- how fast updates effectively reach your bot.
    """
    try:
        import websocket  # websocket-client
    except ImportError:
        return None, None, [], 0, "websocket-client not installed (pip install websocket-client) -> WS skipped"

    connect_ms = None
    first_msg_ms = None
    rtts = []
    errors = 0
    note = ""

    try:
        t0 = time.perf_counter()
        ws = websocket.create_connection(
            url, timeout=timeout,
            sslopt={"cert_reqs": ssl.CERT_REQUIRED},
            header=[f"User-Agent: {USER_AGENT}"])
        connect_ms = (time.perf_counter() - t0) * 1000.0  # DNS+TCP+TLS+WS upgrade
    except Exception as e:
        return None, None, [], 1, f"WS connect failed: {e!r}"

    try:
        sub = {"type": "market"}
        if token_id:
            sub["assets_ids"] = [token_id]
        ws.send(json.dumps(sub))

        ws.settimeout(timeout)
        t_sub = time.perf_counter()
        try:
            _ = ws.recv()
            first_msg_ms = (time.perf_counter() - t_sub) * 1000.0
        except Exception:
            note = "no first message after subscribe (market may be quiet); "

        for _ in range(ping_count):
            try:
                t_ping = time.perf_counter()
                ws.send("PING")
                got_pong = False
                deadline = t_ping + timeout
                while time.perf_counter() < deadline:
                    msg = ws.recv()
                    if isinstance(msg, bytes):
                        msg = msg.decode(errors="ignore")
                    if msg and msg.strip().upper() == "PONG":
                        rtts.append((time.perf_counter() - t_ping) * 1000.0)
                        got_pong = True
                        break
                if not got_pong:
                    errors += 1
            except Exception:
                errors += 1
            time.sleep(ping_interval)
    finally:
        try:
            ws.close()
        except Exception:
            pass

    return connect_ms, first_msg_ms, rtts, errors, note


# ----------------------------------------------------------------------------
# VPS info (public IP + geolocation) — from the bash tool
# ----------------------------------------------------------------------------

def get_vps_info(timeout):
    info = {"public_ip": None, "city": None, "region": None, "country": None, "org": None}
    try:
        req = urllib.request.Request("https://ipinfo.io/json", headers={"User-Agent": USER_AGENT})
        with urllib.request.urlopen(req, timeout=timeout) as r:
            data = json.loads(r.read().decode())
        info.update({k: data.get(k) for k in ("ip", "city", "region", "country", "org")})
        info["public_ip"] = data.get("ip")
    except Exception:
        pass
    return info


# ----------------------------------------------------------------------------
# Pretty printing
# ----------------------------------------------------------------------------

def print_table(title, rows, columns):
    print(f"\n{title}")
    print("-" * len(title))
    widths = {c: len(c) for c in columns}
    for row in rows:
        for c in columns:
            widths[c] = max(widths[c], len(str(row.get(c, ""))))
    print("  ".join(c.ljust(widths[c]) for c in columns))
    print("  ".join("-" * widths[c] for c in columns))
    for row in rows:
        print("  ".join(str(row.get(c, "")).ljust(widths[c]) for c in columns))


# ----------------------------------------------------------------------------
# Main
# ----------------------------------------------------------------------------

def main():
    ap = argparse.ArgumentParser(description="Polymarket VPS latency probe v3 (merged)")
    ap.add_argument("--duration", type=int, default=600, help="total run time in seconds (default 600)")
    ap.add_argument("--interval", type=float, default=5.0, help="seconds between REST sampling rounds (default 5)")
    ap.add_argument("--ws-interval", type=float, default=5.0, help="seconds between WS PINGs, keep <10 (default 5)")
    ap.add_argument("--timeout", type=float, default=5.0, help="per-operation timeout in seconds (default 5)")
    ap.add_argument("--quick", action="store_true", help="short ~20s sanity run")
    ap.add_argument("--no-ws", action="store_true", help="skip the WebSocket test")
    ap.add_argument("--no-geo", action="store_true", help="skip the public-IP / geo lookup")
    ap.add_argument("--csv", metavar="FILE", help="write every raw sample to this CSV")
    ap.add_argument("--json-out", metavar="FILE", help="write the summary JSON to this file")
    args = ap.parse_args()

    if args.quick:
        args.duration = 20
        args.interval = 2.0
        args.ws_interval = 2.0

    started = datetime.now(timezone.utc)
    print("=" * 60)
    print("  Polymarket VPS Latency Probe v3 (merged)")
    print("=" * 60)
    print(f"host={socket.gethostname()}  start={started.isoformat()}")
    print(f"duration={args.duration}s  interval={args.interval}s  ws_interval={args.ws_interval}s  timeout={args.timeout}s")

    vps = {"public_ip": None, "city": None, "region": None, "country": None, "org": None}
    if not args.no_geo:
        vps = get_vps_info(min(args.timeout, 5.0))
        loc = ", ".join(x for x in (vps.get("city"), vps.get("region"), vps.get("country")) if x) or "unknown"
        print(f"public_ip={vps.get('public_ip') or 'unknown'}  location={loc}  org={vps.get('org') or 'unknown'}")

    # Resolve a live token id so the CLOB /book request reaches the origin.
    token_id = fetch_live_token_id(args.timeout)
    print(f"live token id for /book + WS subscribe: {token_id or 'NOT FOUND (book/WS will use a fallback)'}")
    if INFURA_KEY:
        print("polygon RPC: using Infura (INFURA_KEY set)")

    endpoints = [dict(e) for e in REST_ENDPOINTS]
    for e in endpoints:
        if e["name"] == "clob_book" and token_id:
            e["url"] = f"https://clob.polymarket.com/book?token_id={token_id}"

    # CSV setup
    csv_f = None
    if args.csv:
        csv_f = open(args.csv, "w")
        csv_f.write("ts,endpoint,kind,metric,value_ms,status\n")

    def csv_log(name, kind, metric, value, status=""):
        if csv_f and value is not None:
            csv_f.write(f"{time.time():.3f},{name},{kind},{metric},{value:.3f},{status}\n")

    cold = {e["name"]: {k: [] for k in ("dns_ms", "tcp_ms", "tls_ms", "ttfb_ms", "total_ms")} for e in endpoints}
    cold_err = {e["name"]: 0 for e in endpoints}
    warm = {e["name"]: [] for e in endpoints}
    warm_err = {e["name"]: 0 for e in endpoints}
    ips = {}
    statuses = {}

    warm_clients = {e["name"]: WarmClient(e["url"], args.timeout, e["method"], e["body"]) for e in endpoints}

    end_time = time.time() + args.duration
    rounds = 0
    print("\nSampling REST endpoints (Polymarket + chain + reference)...")
    while time.time() < end_time:
        rounds += 1
        for e in endpoints:
            name = e["name"]
            try:
                r = probe_rest_cold(e["url"], args.timeout, e["method"], e["body"])
                for k in ("dns_ms", "tcp_ms", "tls_ms", "ttfb_ms", "total_ms"):
                    cold[name][k].append(r[k])
                    csv_log(name, "cold", k, r[k], r["status"])
                ips[name] = r["ip"]
                statuses[name] = r["status"]
            except Exception:
                cold_err[name] += 1
            try:
                w = warm_clients[name].request_ms()
                warm[name].append(w)
                csv_log(name, "warm", "rtt_ms", w)
            except Exception:
                warm_err[name] += 1
        if rounds % 10 == 0:
            print(f"  ...{rounds} rounds, {int(end_time - time.time())}s left")
        time.sleep(args.interval)

    for c in warm_clients.values():
        c.close()

    # --- WebSocket probe ---
    ws_result = None
    if not args.no_ws:
        print("\nProbing WebSocket market channel (PING/PONG round-trip)...")
        ping_count = max(1, int(args.duration / args.ws_interval))
        ping_count = min(ping_count, 120)
        connect_ms, first_msg_ms, rtts, ws_err, note = run_ws_probe(
            WS_MARKET_URL, token_id, ping_count, args.ws_interval, args.timeout)
        for v in rtts:
            csv_log("ws_market", "ws", "ping_rtt_ms", v)
        if connect_ms is not None:
            csv_log("ws_market", "ws", "connect_ms", connect_ms)
        if first_msg_ms is not None:
            csv_log("ws_market", "ws", "first_msg_ms", first_msg_ms)
        ws_result = {
            "url": WS_MARKET_URL,
            "connect_ms": round(connect_ms, 3) if connect_ms is not None else None,
            "first_msg_ms": round(first_msg_ms, 3) if first_msg_ms is not None else None,
            "ping_pong_rtt": summarize(rtts, ws_err),
            "note": note.strip(),
        }
        if note:
            print(f"  note: {note.strip()}")

    if csv_f:
        csv_f.close()

    # --- Build summary ---
    summary = {
        "meta": {
            "host": socket.gethostname(),
            "started_utc": started.isoformat(),
            "finished_utc": datetime.now(timezone.utc).isoformat(),
            "duration_s": args.duration,
            "rest_rounds": rounds,
            "token_id": token_id,
            "vps": vps,
            "polygon_provider": "infura" if INFURA_KEY else "public",
        },
        "rest": {},
        "websocket": ws_result,
    }
    for e in endpoints:
        name = e["name"]
        summary["rest"][name] = {
            "url": e["url"],
            "method": e["method"],
            "group": e["group"],
            "resolved_ip": ips.get(name),
            "http_status": statuses.get(name),
            "trade_critical": e["critical"],
            "cold": {k: summarize(cold[name][k], cold_err[name]) for k in
                     ("dns_ms", "tcp_ms", "tls_ms", "ttfb_ms", "total_ms")},
            "warm_rtt": summarize(warm[name], warm_err[name]),
        }

    # --- Human-readable tables ---
    print("\n" + "=" * 70)
    print("SUMMARY  (lower is better; watch p99 and stdev for consistency)")
    print("=" * 70)

    rest_rows = []
    for e in endpoints:
        name = e["name"]
        s = summary["rest"][name]
        warm_s = s["warm_rtt"]
        tcp_s = s["cold"]["tcp_ms"]
        ttfb_s = s["cold"]["ttfb_ms"]
        dns_s = s["cold"]["dns_ms"]
        rest_rows.append({
            "endpoint": ("* " if e["critical"] else "  ") + name,
            "grp": e["group"],
            "ip": s["resolved_ip"] or "-",
            "st": s["http_status"] if s["http_status"] is not None else "-",
            "dns_p50": dns_s["p50_ms"], "dns_max": dns_s["max_ms"],
            "tcp_p50": tcp_s["p50_ms"],
            "ttfb_p50": ttfb_s["p50_ms"], "ttfb_p99": ttfb_s["p99_ms"],
            "warm_p50": warm_s["p50_ms"], "warm_p99": warm_s["p99_ms"],
            "warm_jit": warm_s["stdev_ms"],
            "err": warm_s["errors"] + tcp_s["errors"],
        })
    print_table(
        "REST  (* = trade-critical)  [all ms; grp pm=Polymarket chn=chain ref=reference]",
        rest_rows,
        ["endpoint", "grp", "ip", "st", "dns_p50", "dns_max",
         "tcp_p50", "ttfb_p50", "ttfb_p99", "warm_p50", "warm_p99", "warm_jit", "err"],
    )
    print("  tcp_p50  = network RTT proxy (raw distance/route to the edge)")
    print("  warm_*   = per-request round-trip on a pooled connection (what a live bot sees)")
    print("  warm_jit = stdev (jitter); high jitter = unpredictable fills")
    print("  dns_max  = watch this: multi-second values mean a broken resolver, not the network")

    if ws_result and ws_result["ping_pong_rtt"]["ok"]:
        pp = ws_result["ping_pong_rtt"]
        print("\nWEBSOCKET market channel  [ms]  <- THE real-time metric")
        print("-" * 50)
        print(f"  connect handshake : {ws_result['connect_ms']}")
        print(f"  first message     : {ws_result['first_msg_ms']}")
        print(f"  PING/PONG p50     : {pp['p50_ms']}")
        print(f"  PING/PONG p95     : {pp['p95_ms']}")
        print(f"  PING/PONG p99     : {pp['p99_ms']}")
        print(f"  PING/PONG max     : {pp['max_ms']}")
        print(f"  PING/PONG jitter  : {pp['stdev_ms']}  (stdev)")
        print(f"  samples / errors  : {pp['ok']} / {pp['errors']}")
    elif ws_result:
        print(f"\nWEBSOCKET: no successful PING/PONG. note: {ws_result['note']}")

    # --- Decision metrics: the few numbers that actually drive the choice ---
    def g(name, section, metric, sub="warm_rtt"):
        s = summary["rest"].get(name)
        if not s:
            return None
        if sub == "warm_rtt":
            return s["warm_rtt"].get(metric)
        return s["cold"][section].get(metric)

    print("\n" + "=" * 70)
    print("DECISION METRICS  (compare these across hosts)")
    print("=" * 70)
    book_p50 = g("clob_book", None, "p50_ms")
    book_p99 = g("clob_book", None, "p99_ms")
    relayer_ttfb = g("relayer_nonce", "ttfb_ms", "p50_ms", sub="cold")
    polygon_ttfb = g(POLYGON_NAME, "ttfb_ms", "p50_ms", sub="cold")
    ws_p50 = ws_result["ping_pong_rtt"]["p50_ms"] if (ws_result and ws_result["ping_pong_rtt"]["ok"]) else None
    ws_p99 = ws_result["ping_pong_rtt"]["p99_ms"] if (ws_result and ws_result["ping_pong_rtt"]["ok"]) else None
    print(f"  Order path  (CLOB /book warm)  p50={book_p50} ms   p99={book_p99} ms   <- post/cancel")
    print(f"  Live feed   (WS PING/PONG)     p50={ws_p50} ms   p99={ws_p99} ms   <- sees the book")
    print(f"  Relayer     (gasless submit)   ttfb_p50={relayer_ttfb} ms")
    print(f"  Polygon RPC (settlement)       ttfb_p50={polygon_ttfb} ms")

    # DNS health flag across critical endpoints
    worst_dns = 0.0
    for e in endpoints:
        m = summary["rest"][e["name"]]["cold"]["dns_ms"]["max_ms"]
        if m and m > worst_dns:
            worst_dns = m
    if worst_dns >= 1000:
        print(f"\n  ⚠ DNS WARNING: worst cold lookup was {worst_dns:.0f} ms. That's a resolver")
        print("    timeout, not the network. Install a local caching resolver (unbound/dnsmasq)")
        print("    before trusting latency numbers or shipping this box.")
    else:
        print(f"\n  DNS health: OK (worst cold lookup {worst_dns:.1f} ms, no multi-second stalls)")

    print("\nHow to choose a VPS:")
    print("  1. Compare 'Order path' warm p50 and the WS PING/PONG p50 across hosts (primary).")
    print("  2. Prefer the host with the lower p99 / jitter even if p50 ties (tails cost fills).")
    print("  3. tcp_p50 to clob shows raw network distance; a transit detour shows up here.")
    print("  4. A multi-second dns_max is YOUR resolver to fix; it travels with the image.")

    # --- JSON output ---
    out = json.dumps(summary, indent=2)
    if args.json_out:
        with open(args.json_out, "w") as f:
            f.write(out)
        print(f"\nFull JSON summary written to {args.json_out}")
    else:
        print("\nFull JSON summary:")
        print(out)
    if args.csv:
        print(f"Raw per-sample CSV written to {args.csv}")


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        print("\nInterrupted.", file=sys.stderr)
        sys.exit(130)

How to run it

First, SSH into the VPS you want to test. Always test from the VPS itself, never your home machine, because home internet adds 50 to 300 ms that has nothing to do with the VPS.

The script needs one library for the WebSocket test. On a fresh Ubuntu VPS, a plain pip install often fails, either because minimal images ship without pip, or because newer Ubuntu and Debian releases block system-wide pip installs (you will see error: externally-managed-environment). We hit this ourselves, so here are two install paths that actually work.

Option A, the simplest: install the library through apt. No pip, no virtual environment:

sudo apt update
sudo apt install -y python3-websocket
python3 pm_latency_v3.py --duration 600 --csv dublin.csv --json-out dublin.json

Option B, the standard Python way: a virtual environment. Use this if you prefer pip or plan to add other packages:

sudo apt update
sudo apt install -y python3-pip python3-venv
python3 -m venv ~/pm-latency
source ~/pm-latency/bin/activate
pip install websocket-client
python3 pm_latency_v3.py --duration 600 --csv dublin.csv --json-out dublin.json

If you come back in a later SSH session, activate the environment again first with source ~/pm-latency/bin/activate.

On Windows Server, pip install websocket-client works as-is from PowerShell, since the standard Python installer is not externally managed.

And if you do not want to install anything at all, run the script with --no-ws. You lose the WebSocket feed measurement but every REST test still runs.

Useful flags:

  • --quick: a roughly 20-second sanity run while you are setting up.
  • --duration 600: the full run length in seconds; 600 (ten minutes) gives trustworthy tail statistics.
  • --no-ws: skip the WebSocket test (no library needed).
  • --csv FILE / --json-out FILE: save raw samples and the summary for later comparison.
  • INFURA_KEY=xxxx python3 pm_latency_v3.py: use a paid Infura endpoint for the Polygon RPC instead of the flaky public one.

For a fair provider comparison, run the same command on each VPS at the same time of day, within a few minutes of each other, so market activity is held constant.

How to read the output

The script prints a per-endpoint table, a WebSocket section, and a Decision Metrics block that surfaces the handful of numbers that actually drive a verdict:

  • Order path (CLOB /book warm) p50 and p99 — how fast you post and cancel orders.
  • Live feed (WebSocket PING/PONG) p50 and p99 — how fast your bot sees the book move.
  • Relayer TTFB — the gasless submission path.
  • Polygon RPC TTFB — the settlement chain.

It also prints a DNS health flag: if the worst cold lookup crossed one second, it warns you that your resolver is broken and needs a local cache — because that, not the network, is the cause. (More on that in troubleshooting.)

Compare across providers on the order-path and feed numbers, weight the tail and jitter, and remember the lower number only means something if both runs were at the same hour.

TradoxVPS Dublin latency test result, fully analyzed

Here is a real, full ten-minute run from a TradoxVPS Dublin instance against live Polymarket infrastructure, using the exact script above. The run captured 92 REST sampling rounds and 120 WebSocket PING/PONG round-trips, with zero errors. We then ran the identical benchmark on a competitor’s Dublin box (QuantVPS) in the same five-minute window, so the comparison holds market activity constant — the single most important control for a fair test.

TradoxVPS Dublin — trade-critical paths

Pathp50p95p99Jitter (stdev)
WebSocket feed (PING/PONG)14.0 ms29.7 ms62.8 ms16.7 ms
Order book — order path (warm)23.0 ms31.0 ms67.4 ms10.2 ms
CLOB markets (warm)21.7 ms25.4 ms27.0 ms1.9 ms
Relayer (warm)26.8 ms43.2 ms56.2 ms11.6 ms

Network phases (cold): DNS 0.3 ms median (0.6 ms max — clean, no stalls), TCP connect 1.5 msTLS handshake 15 ms, confirming the box sits right at the Dublin internet exchange with the order book edge roughly one network hop away.

Same-window comparison: TradoxVPS Dublin vs QuantVPS

MetricTradoxVPS DublinQuantVPSRead
TCP connect1.5 ms~1.0 msBoth at the Dublin floor
DNS (worst)0.6 ms~12 ms (with errors)TradoxVPS cleaner
Order path p5023.0 ms22.8 msDead heat
Order path p9967.4 ms56.8 msWithin noise
Feed p5014.0 ms16.3 msTradoxVPS faster
Feed p9962.8 ms105.6 msTradoxVPS tighter

Analysis

Three things stand out, and we report them straight — including where the competitor matches us.

The order path is a genuine tie. TradoxVPS and QuantVPS post and cancel orders at effectively identical median speed (23.0 vs 22.8 ms). On the order-path tail the two trade places within normal run-to-run noise. Anyone claiming a large order-path advantage between two well-located Dublin providers is reading single-run variance, not a real gap — the honest finding is that well-peered Dublin boxes cluster at the same physical floor. This is exactly why we tell traders to ignore marketing and test for themselves.

TradoxVPS wins the live feed. On the WebSocket path — the one that determines how current your view of the market is — TradoxVPS is faster on the median (14.0 vs 16.3 ms) and meaningfully tighter on the tail (p99 62.8 vs 105.6 ms). For real-time strategies that react to book movement, that feed advantage matters more than a fraction of a millisecond on the order path.

Same-window WebSocket feed latency comparison: TradoxVPS Dublin 14.0 ms p50 and 62.8 ms p99 versus QuantVPS 16.3 and 105.6 ms

TradoxVPS has cleaner DNS. The competitor’s box ran the default resolver path and showed ~12 ms DNS with intermittent lookup errors; the TradoxVPS image runs a local caching resolver and returned sub-millisecond DNS with zero errors. This is not cosmetic — DNS stalls are a leading cause of “my bot froze” incidents, which we explain next.

The takeaway: on the same infrastructure tier, at the same moment, a properly configured Dublin VPS matches the best competitor on order execution and beats it on the live feed and on DNS reliability. For the broader picture of why Dublin is the right home for Polymarket, see our dedicated analysis of the best VPS location for Polymarket trading.

Troubleshooting: the DNS stall that looks like a frozen bot

This deserves its own section because it is common, damaging, and almost always misdiagnosed as a network or VPS problem when it is neither.

If your Python probe shows DNS medians around 10 ms but a p99 or max of 5,000 ms or 10,000 ms, you have a broken resolver — not a slow network. Those specific values are the giveaway: 5 seconds and 10 seconds are the default timeout-and-retry intervals of the Linux resolver. When a DNS query or its reply goes missing, the resolver sits and waits the full timeout, then retries. The result is a bot that appears to “freeze” for five or ten seconds — usually on startup or when it reconnects after a blip — which traders almost always blame on the VPS.

The fix is entirely in your hands and takes minutes: run a local caching resolver (such as unbound or dnsmasq) pointed at reliable upstreams like 1.1.1.1 and 8.8.8.8, with serve-stale enabled so a dropped upstream packet can never cost you five seconds. Repeat lookups are then served from cache in microseconds. After applying this, re-run the probe and the multi-second DNS maxima should disappear entirely. (This is the configuration the TradoxVPS image ships with, which is why our DNS numbers above are clean.)

A second common issue: if your probe shows a steady ~10 ms or higher TCP connect time when you expect sub-millisecond, run the mtr command from Method 1. A consistent extra few milliseconds usually means a routing detour — your “local” traffic is travelling to a distant edge or through a transit carrier. That is the provider’s network to fix; no OS setting changes a routing path.

Finally, if your warm numbers are good but your cold numbers are bad, that is fine for a real bot — make sure your bot uses a persistent connection pool and a long-lived WebSocket so it lives on the warm path. A bot that opens a fresh connection per request needlessly pays the handshake every time.

How to compare VPS providers fairly

Pulling the method together, here is the honest way to choose a Polymarket VPS on latency:

  1. Test from each VPS itself, never from home internet.
  2. Run the Python probe (Method 4) for a full ten minutes, not a quick ping.
  3. Run each provider at the same time of day, within minutes of each other, so market activity is constant.
  4. Compare on order-path p50, order-path p99, feed p50, feed p99, and jitter — together, not in isolation.
  5. Confirm routing with mtr — make sure each box reaches the local Polymarket edge without a detour.
  6. Check DNS health — a multi-second max means a resolver problem to fix before you judge anything else.
  7. Validate with a testnet FAK order (Method 3) before committing real capital.

What not to compare: a provider’s advertised “1 ms” ping (it is the round trip after the connection is already open, not your order latency), core count (single-threaded bots use one to two cores — see single-core performance), or an SLA uptime percentage in isolation. For a wider checklist, our guide on how to check VPS performance goes beyond latency to CPU and disk.

Why “1 ms to the exchange” marketing is theater

Every VPS provider advertises “1 ms to the exchange.” Here is what that number really is, and why it is close to meaningless for trading.

When you ping a co-located server, the round trip might genuinely be around 1 ms. But that is only the round-trip time after your connection is already open, for an ICMP echo, with no DNS, no TCP setup, no TLS negotiation, and no request processing. A real first API call is 12–32 ms once you add those phases; a warm pooled request settles around 20 ms; and your live feed round-trip is in the teens. The “1 ms” figure is technically true and practically irrelevant — it conflates a ping with an order.

Serious traders do not care about ping. They care about the warm order-path round-trip and the WebSocket feed latency, measured over hundreds of samples with the tail and jitter reported. That is the number that protects your edge — and it is exactly what the method in this guide produces. For more on the difference between raw network speed and trading-relevant latency, see network speed vs latency and how server location impacts execution speed.

Pre-launch latency checklist

Before you run real capital through a Polymarket bot on any VPS:

  • Run the Python probe from the VPS itself for a full ten minutes
  • Confirm order-path p50 in the low-20s ms and a tight p99 (not multiples of the median)
  • Confirm WebSocket feed p50 in the teens with controlled jitter
  • Verify DNS max is in the low milliseconds — no multi-second stalls
  • Run mtr and confirm a short path to the local Polymarket edge (no distant-city detour)
  • Re-run at a different time of day to see how the tail behaves under market activity
  • Switch the Polygon RPC to a paid endpoint if your strategy makes chain calls
  • Ensure your bot uses connection pooling and a persistent WebSocket so it lives on the warm path
  • Validate end-to-end with a testnet fill-and-kill order before going live

If the order-path or feed tail is wildly inconsistent across runs, investigate noisy-neighbor contention (check CPU steal time) or routing before trusting the box with live size.

Try TradoxVPS for Polymarket

TradoxVPS is built for traders who measure their latency rather than take it on faith. Our Dublin location sits at the local internet exchange next to Polymarket’s order book infrastructure, every plan runs on Ryzen 9950X hardware with DDR5 memory, and our standard image ships with the caching-resolver and network tuning described in this guide — which is why the Dublin numbers above are clean and tight. (Trading futures or Kalshi instead? Our Chicago location and Kalshi VPS are built for those, and you can compare us directly in our TradoxVPS vs QuantVPS breakdown.)

Don’t take our word for it — deploy the script from this guide on a TradoxVPS box and read the numbers yourself. See plans and pricing or get started. Your edge is speed; make sure you are measuring it correctly.

TradoxVPS provides low-latency VPS hosting for prediction-market and futures traders. We do not provide financial or trading advice. Latency varies with network conditions, market activity, and configuration; run your own tests before committing capital.

Frequently Asked Questions

How do I test the latency of my Polymarket VPS?

Run a sustained Python benchmark from the VPS itself that measures the warm round-trip on the order book path and a WebSocket PING/PONG on the live feed, over several hundred samples, reporting p50, p99, and jitter. A one-off ping or a cold curl does not reflect what a real bot experiences. The complete script and instructions are in this guide.

What is a good latency for a Polymarket bot?

From a Dublin VPS, expect a warm order-path median in the low-20s of milliseconds and a WebSocket feed median in the teens, both with a tight tail (p99 not far above the median) and low jitter. The physical floor is set by the Dublin-to-order-book distance plus Polymarket’s own server time, so all well-located providers cluster near these numbers — the differentiator is consistency, not raw median.

What is the difference between p50 and p95 latency?

p50 is the median — half your requests are faster, half slower — and represents your typical latency. p95 is the latency at which 95% of requests complete, exposing the slower tail. For trading, the tail (p95 and especially p99) matters more than the average because a single slow order during a fast market is what causes slippage.

Why does a ping show 1 ms but my real latency is higher?

A ping measures only the round trip after the connection is already open, for an ICMP echo. A real API request also pays DNS, TCP, and TLS setup on the first call, plus Polymarket’s server processing time. Your warm trading latency is in the tens of milliseconds even when ping is 1 ms — which is why ping is not a useful trading metric.

Should I test from my home computer or the VPS?

Always test from the VPS, because that is where your bot runs. Home internet adds 50–300 ms of ISP and routing overhead that has nothing to do with the VPS. Testing from home tells you about your home connection, not your trading infrastructure.

My DNS lookups occasionally take several seconds — is my VPS broken?

No — that is a resolver misconfiguration, not the network or the VPS hardware, and it travels with your machine image regardless of provider. The 5- and 10-second values are the Linux resolver’s default timeout-and-retry intervals. Install a local caching resolver (unbound or dnsmasq) pointed at reliable upstreams, and the stalls disappear.

Which endpoints should a Polymarket latency test include?

At minimum the CLOB order book (clob.polymarket.com) and the WebSocket market channel (ws-subscriptions-clob.polymarket.com). A complete test also covers the relayer, the Gamma and Data APIs, the Polygon RPC for settlement, and a reference market like Binance. The Python script in this guide tests all of them in one run.

How often should I re-test my VPS latency?

Establish a baseline when you set up the VPS, then re-test monthly or before any major change (new hardware, new ISP, code changes). Re-test immediately if you notice slippage degradation, and always re-run at the same time of day as your baseline so the comparison is fair.

Share this article:
Facebook
X
LinkedIn

TradoxVPS Engineering Team

Infrastructure specialists focused on low-latency trading VPS and CME-proximal hosting.
Published:
Discover how TradoxVPS can power your trading with speed, stability, and 24/7 uptime to stay ahead in the markets.