Operations · 7 min read

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:

  1. Network interruption: your bot sent a GTC order, then crashed before tracking the order ID. On restart, the order is still live.
  2. Signal flip: you placed a bid expecting a drop, the market recovered, but the order was never cancelled.
  3. Near-resolution: the market moved to >95% on one outcome. Any resting order is now economically worthless but still technically valid.
  4. 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/SIGTERM handlers to cancel before exit.
  • Reconcile local order state against the API, not the other way around.

Related bots