Debugging stale orders on the Polymarket CLOB
Why CLOB orders go stale -- network drops, dust, near-resolution -- how to detect them, and how to cancel systematically.
What makes an order stale
A stale order is one sitting on the CLOB that you no longer want. The most common causes:
- Network interruption: your bot sent a GTC order, then crashed before tracking the order ID. On restart, the order is still live.
- Signal flip: you placed a bid expecting a drop, the market recovered, but the order was never cancelled.
- Near-resolution: the market moved to >95% on one outcome. Any resting order is now economically worthless but still technically valid.
- Dust: a partially-filled GTC order has a tiny remaining size below your minimum. Not worth holding, not automatically cancelled.
Fetching open orders
const CLOB_BASE = 'https://clob.polymarket.com'
async function getOpenOrders(apiKey, signer) {
const client = new ClobClient(CLOB_BASE, 137, signer, apiKey)
return client.getOpenOrders({ market: null }) // null = all markets
}The response is an array of order objects with id, price, size_remaining, asset_id, side, and created_at.
Detecting near-resolution stale orders
Cross-reference open orders against the current market price. If the market is above 0.95 or below 0.05, any resting order is suspect:
async function detectNearResolutionStale(openOrders, getMarketPrice) {
const stale = []
for (const order of openOrders) {
const price = await getMarketPrice(order.asset_id)
if (price >= 0.95 || price <= 0.05) {
stale.push(order.id)
}
}
return stale
}Detecting dust orders
After partial fills, the remaining size may fall below your minimum viable order size (typically 5 shares per Polymarket's minimum, but practically higher for fee efficiency):
const MIN_VIABLE_SHARES = 10
function detectDust(openOrders) {
return openOrders
.filter((o) => parseFloat(o.size_remaining) < MIN_VIABLE_SHARES)
.map((o) => o.id)
}Cancelling stale orders
async function cancelStaleOrders(client, orderIds) {
if (!orderIds.length) return
console.log(`Cancelling ${orderIds.length} stale orders: ${orderIds.join(', ')}`)
await client.cancelOrders(orderIds)
}The CLOB cancelOrders endpoint accepts an array of order IDs and processes them in bulk.
The startup cleanup pattern
On every bot startup, run a stale order cleanup before placing any new orders. This handles the common case where the previous bot run crashed with open orders:
async function startup(client) {
console.log('Checking for stale orders from previous session...')
const openOrders = await client.getOpenOrders({})
if (openOrders.length > 0) {
console.log(`Found ${openOrders.length} open orders, cancelling all`)
await client.cancelOrders(openOrders.map((o) => o.id))
await sleep(1000) // brief pause for cancels to propagate
}
console.log('Startup clean, proceeding')
}Heartbeat-based stale detection
For long-running bots, add a periodic sweep that cancels orders older than a threshold:
const MAX_ORDER_AGE_MS = 10 * 60 * 1000 // 10 minutes
async function sweepOldOrders(client) {
const openOrders = await client.getOpenOrders({})
const now = Date.now()
const old = openOrders.filter((o) => {
const age = now - new Date(o.created_at).getTime()
return age > MAX_ORDER_AGE_MS
})
if (old.length) {
console.log(`Sweeping ${old.length} old orders`)
await client.cancelOrders(old.map((o) => o.id))
}
}
// Run every 5 minutes
setInterval(() => sweepOldOrders(client).catch(console.error), 5 * 60 * 1000)Graceful shutdown
Register signal handlers to cancel all open orders when the bot process exits cleanly:
let client // initialized in main()
async function cleanup() {
try {
const orders = await client.getOpenOrders({})
if (orders.length) {
console.log(`Shutdown: cancelling ${orders.length} open orders`)
await client.cancelOrders(orders.map((o) => o.id))
}
} catch (e) {
console.error('Cleanup error:', e.message)
}
process.exit(0)
}
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)Order state reconciliation
If you track orders in memory, reconcile against the API periodically. The API is the source of truth -- an order you think is open might be cancelled by the CLOB (e.g. if it became invalid after oracle near-resolution).
async function reconcileOrders(client, localOrderIds) {
const remoteOrders = await client.getOpenOrders({})
const remoteIds = new Set(remoteOrders.map((o) => o.id))
const ghostOrders = localOrderIds.filter((id) => !remoteIds.has(id))
if (ghostOrders.length) {
console.log(`Ghost orders (locally tracked but not on CLOB): ${ghostOrders.join(', ')}`)
}
return remoteOrders
}Summary
- Stale orders are caused by crashes, signal flips, near-resolution markets, and dust from partial fills.
- Run a full cancel on startup before placing new orders.
- Sweep orders older than 10 minutes every 5 minutes.
- Register
SIGINT/SIGTERMhandlers to cancel before exit. - Reconcile local order state against the API, not the other way around.
Related bots
contrarian — GTC maker bids on underdog outcomes
GTC maker bids on 20-50¢ underdog outcomes across all Polymarket markets. Zero taker fees, 30-min scan loop, multi-layer risk stack. Inspired by @Car's +$22K strategy.
polydaily — Daily BTC pair arbitrage bot
Buy-both-sides Gabagool on daily BTC markets (threshold + Up/Down). Picks up cheap YES + NO, locks profit when pair stays below $0.95. Tracks up to 3 markets at once across a ±$1k strike range.