Market holidayJapan will be closed on 2026-11-03 (Culture Day), 5 days from now.All announcements
Article

Duplicate Ticks After WebSocket Reconnect: Three Subscription State Traps

Duplicates after a reconnect are normal; deduplication is your job. We break down three subscription state traps — treating connected as subscribed, not clearing state, and conflating timeouts — then give a working state machine and Python implementation.

Reconnect logic is the most underestimated part of a streaming market data integration. Most teams implement "reconnect when it drops" carefully, then ignore state consistency after the reconnect — and downstream consumers start seeing duplicate ticks, metrics get double-counted, alerts fire twice, and the bug is hard to reproduce.

This article breaks down the three most common subscription state traps and gives you a reconnect state machine that holds up.

Why reconnects always produce duplicates

Start by accepting a premise: duplicates from reconnects are normal. Deduplication is the application's job.

The reason is WebSocket semantics. When the connection drops, the server may have already pushed frames you never received. After reconnecting and re-subscribing, the server resumes from the current moment. There is no "continue where we left off" mechanism between those two intervals, so the boundary necessarily overlaps.

The good news: TIQEX tick frames carry an at field (an RFC3339 timestamp), which gives deduplication a solid basis — use (symbol, at) as the idempotency key.

Trap one: treating "connected" as "subscribed"

This is the most common mistake. The code usually looks like this:

Python
async with websockets.connect(URL, additional_headers=HEADERS) as ws:
    await ws.send(json.dumps({"action": "subscribe", "market": "crypto", "symbols": SYMBOLS}))
    print("subscribed")      # wrong: this only means "sent", not "accepted"
    async for raw in ws:
        handle(raw)

The problem: send only guarantees the message reached your local send buffer. Whether the server accepted the subscription shows up in subsequent frames. TIQEX returns a type=subscribed acknowledgement carrying market and symbols; if the subscription fails, you get a type=error frame instead.

The correct approach is to wait for the acknowledgement before considering the subscription live. Otherwise, when a subscription is rejected — for instance MARKET_NOT_ENABLED or NO_SUBSCRIPTION — the code sits silently inside async for, neither erroring nor delivering data, which is extremely time-consuming to debug.

Trap two: not clearing local state on reconnect

After reconnecting, many implementations keep state accumulated from the previous connection: dedup caches, aggregation windows, alert debounce timers. All of that state is invalid across a reconnect boundary.

Concretely:

  • A stale dedup cache. If the cache holds "the last N entries", post-reconnect keys can collide with old ones, so fresh ticks get discarded as duplicates. That is worse than duplication, because it silently drops data.
  • An unclosed aggregation window. An incomplete minute bar from before the drop gets spliced with post-reconnect data, producing incorrect OHLC values.
  • Stale debounce timers. Alert timers spanning the reconnect fire spuriously or not at all.

The principle is simple: clear every piece of state tied to the connection lifecycle, keeping only durable business state. A dedup cache should be time-windowed — retaining only keys from the last five seconds — so it expires naturally after a reconnect with no extra cleanup logic.

Trap three: conflating heartbeat and subscription timeouts

Heartbeat timeout (how long without any frame before declaring the connection dead) and subscription timeout (how long to wait for an acknowledgement after sending subscribe) are two different things. Conflating them causes two failure modes:

  • Using a long subscription timeout as the heartbeat. Real disconnects take too long to detect, and data is silently lost in the meantime.
  • Using a short heartbeat timeout for subscription waiting. On quiet markets or outside trading hours there may be no ticks for a long stretch, so the client misreads this as a failed subscription and reconnects repeatedly — and reconnects consume quota.

Keep two separate timers: a short fixed timeout for subscription acknowledgement (say 10 seconds), and a longer idle timeout for the heartbeat (say 60 seconds). Prefer protocol-level ping/pong over inferring liveness from frame gaps.

A reconnect state machine that works

Expressed as states, the logic becomes clear:

StateEntered whenExit action
CONNECTINGOpening the connectionHandshake ok → SUBSCRIBING
SUBSCRIBINGsubscribe sentsubscribed received → LIVE; timeout or error → BACKOFF
LIVESubscription acknowledgedDisconnect or idle timeout → BACKOFF
BACKOFFWaiting to reconnectBackoff elapsed → CONNECTING (connection state cleared)

The key insight is that BACKOFF owns cleanup. All connection-bound state resets there, so a reconnect never resumes with expired state.

Python implementation

Python
import asyncio, json, os, time
import websockets

URL = "wss://api.tiqex.io/api/v1/market/stream"
API_KEY = os.environ["TIQEX_API_KEY"]
SYMBOLS = ["BTCUSDT", "ETHUSDT", "AAPL"]

SUBSCRIBE_TIMEOUT = 10      # wait for the subscribed acknowledgement
IDLE_TIMEOUT = 60           # heartbeat / idle timeout
DEDUP_WINDOW_SEC = 5        # dedup window

seen = {}                   # (symbol, at) -> first seen timestamp


def is_duplicate(tick: dict) -> bool:
    """Idempotency key is (symbol, at), guarded by a sliding window."""
    key = (tick.get("symbol"), tick.get("at"))
    now = time.time()
    # Expire old keys, so reconnects need no extra cleanup
    for k, ts in list(seen.items()):
        if now - ts > DEDUP_WINDOW_SEC:
            del seen[k]
    if key in seen:
        return True
    seen[key] = now
    return False


async def run_once():
    async with websockets.connect(
        URL, additional_headers={"X-API-Key": API_KEY}, open_timeout=10,
    ) as ws:
        await ws.send(json.dumps({
            "action": "subscribe", "market": "crypto", "symbols": SYMBOLS,
        }))

        # Phase 1: wait for the acknowledgement instead of assuming success
        deadline = time.monotonic() + SUBSCRIBE_TIMEOUT
        while True:
            raw = await asyncio.wait_for(ws.recv(), timeout=SUBSCRIBE_TIMEOUT)
            frame = json.loads(raw)
            data = frame.get("data") or {}
            if not frame.get("succ"):
                raise RuntimeError(f"subscribe failed code={data.get('code')} msg={frame.get('msg')}")
            if data.get("type") == "subscribed":
                print("subscription confirmed:", data.get("symbols"))
                break
            if time.monotonic() > deadline:
                raise TimeoutError("timed out waiting for the acknowledgement")

        # Phase 2: consume frames with dedup and an idle timeout
        while True:
            raw = await asyncio.wait_for(ws.recv(), timeout=IDLE_TIMEOUT)
            frame = json.loads(raw)
            data = frame.get("data") or {}
            kind = data.get("type")
            if kind == "tick":
                tick = data["tick"]
                if not is_duplicate(tick):
                    print(tick["symbol"], tick["last"], tick["at"])
            elif kind == "error":
                print("in-stream error:", data.get("code"), frame.get("msg"))


async def main():
    backoff = 1.0
    while True:
        try:
            await run_once()
            backoff = 1.0                    # reset backoff on a clean end
        except Exception as exc:             # noqa: BLE001
            print(f"connection lost: {exc}")
            seen.clear()                     # connection state cleared here
            await asyncio.sleep(backoff)
            backoff = min(backoff * 2, 60)   # exponential backoff, capped at 60s
            print(f"reconnecting in {backoff:.0f}s")


asyncio.run(main())

This addresses all three traps: it waits for the acknowledgement, dedups within a time window, and separates subscription timeout from idle timeout. Backoff is capped at 60 seconds so a server-side outage cannot drain quota through handshakes.

Verifying the fix

After the change, verify with observability rather than eyeballing logs:

  1. Ratio of unique keys. Track total frames received against unique (symbol, at) pairs. The ratio should approach 1. A significantly lower ratio means dedup is working but duplicate volume is high — check reconnect frequency.
  2. Reconnect count. A healthy day should show single digits. Frequent reconnects usually point at heartbeat settings or the network path.
  3. Reconcile downstream metrics. If the alerting system fires "same price, same timestamp" twice, dedup is not reaching that far downstream.

FAQ

Why must I wait for subscribed before processing data?

Because send only means the message left your client. Only the type=subscribed acknowledgement confirms that market and symbols took effect. Skipping this step turns subscription failures into silent outages.

Which field should the dedup key use?

Use (symbol, at). The at field is an RFC3339 timestamp, and a given symbol at a given instant is naturally unique — an ideal idempotency key.

Do reconnects consume quota?

Yes. The handshake counts toward daily quota and RPM; ticks pushed after the handshake are not metered. That is exactly why backoff matters — never use aggressive reconnects as a keep-alive.

What is a sensible idle timeout?

It depends on symbol activity. Quiet markets and out-of-hours periods can go a long time without ticks, so a short idle timeout misreads them as disconnects and reconnects repeatedly. Prefer protocol-level ping/pong for liveness and keep the idle timeout generous — 60 seconds, for example.

How should a rejected subscription be handled?

Classify by error code. MARKET_NOT_ENABLED and NO_SUBSCRIPTION are entitlement problems — reconnecting will not help, so alert and stop retrying. INVALID_SYMBOL and UNKNOWN_SYMBOL are parameter problems — fix the symbols and re-subscribe.

Next steps

Separate subscription acknowledgement, dedup window, and heartbeat timeout, and duplicate data largely disappears. The full subscription protocol and error codes are in the WebSocket streaming docs. For more on reconnection and heartbeats, see the reconnect guide and the WebSocket heartbeat entry. If you are still weighing streaming against polling, compare polling versus streaming.