Skip to content
ExponaBLOG
KAFKA & WEBSOCKETS · DAY 064 / 100

Ordering and delivery over WebSockets: a practical guide

TCP is ordered. Your system might not be.

Expona Admin8 min read

TCP guarantees that bytes arrive in order within a connection, but your distributed system almost certainly doesn't. When you're streaming real-time updates over WebSockets to thousands of clients, you'll face out-of-order events from multiple services, missed messages during disconnections, and stale data overwriting fresh state. In this post, we'll tackle ordering and delivery over WebSockets—the gap between what the transport layer promises and what your business logic actually needs.

Multiple producers — Events from different services can arrive out of order

Imagine you're building a ride-hailing app. A rider's location updates come from a geolocation service, price estimates from a pricing engine, and driver availability from a matching service. All three might emit events about the same ride within milliseconds of each other. Your WebSocket server receives them from three different Kafka partitions, three different gRPC clients, or three different databases—and the order in which they land in your event queue is not guaranteed to match the order they were produced.

Here's a concrete scenario:

typescript

// Service A (Pricing): estimates fare at 12:00:00.100
const priceEvent = {
  rideId: "ride-123",
  fare: 15.50,
  timestamp: 1700000000100
};

// Service B (Location): rider moves at 12:00:00.105
const locationEvent = {
  rideId: "ride-123",
  lat: 40.7128,
  lng: -74.0060,
  timestamp: 1700000000105
};

// Your WebSocket server receives location first, price second
// Client state: knows location but thinks fare is still outdated

Without handling this properly, your client might render a location update, then seconds later revert the price because an older pricing event arrived late from a retry queue. The user sees the fare jump down unexpectedly, loses trust in your UI.

The root cause: multiple independent services produce events asynchronously. Each service has its own clock, retry logic, and network path. Kafka partitions guarantee ordering within a partition, not across partitions. Your WebSocket aggregation layer must be aware that events intended for the same entity can collide out of order.

Sequence numbers — Attach a sequence per entity so clients can reorder or drop stale updates

The most practical fix: assign a logical sequence number to each update for a given entity. This isn't a global counter—it's scoped to the entity (the ride, the product listing, the trade). Each producer increments its own sequence whenever it emits a new state for that entity.

On the server side, embed the sequence in every message:

typescript

interface EntityUpdate {
  entityId: string;
  entityType: "ride" | "product" | "trade";
  sequence: number;  // Incremented per entity, per producer
  producerId: string; // Which service created this
  payload: Record<string, unknown>;
  timestamp: number;
}

// Pricing service increments its own sequence for ride-123
const priceUpdate: EntityUpdate = {
  entityId: "ride-123",
  entityType: "ride",
  sequence: 3,        // Pricing has sent 3 updates for this ride
  producerId: "pricing-svc",
  payload: { fare: 15.50, currency: "USD" },
  timestamp: Date.now()
};

// Location service, independently, also on sequence 3
const locationUpdate: EntityUpdate = {
  entityId: "ride-123",
  entityType: "ride",
  sequence: 3,
  producerId: "location-svc",
  payload: { lat: 40.7128, lng: -74.0060 },
  timestamp: Date.now()
};

On the client, track the last sequence you've applied for each producer. If a message arrives out of order, buffer it or drop it:

typescript

class RideStateManager {
  private lastSequence = new Map<string, number>(); // producerId → sequence

  applyUpdate(update: EntityUpdate) {
    const key = `${update.entityId}:${update.producerId}`;
    const lastSeq = this.lastSequence.get(key) ?? -1;

    if (update.sequence <= lastSeq) {
      // Stale update, drop it
      console.warn(`Dropping stale update for ${key}, seq ${update.sequence}`);
      return;
    }

    if (update.sequence > lastSeq + 1) {
      // Gap detected, we missed an update
      console.warn(`Gap detected for ${key}: expected ${lastSeq + 1}, got ${update.sequence}`);
      // Option 1: buffer and wait for missing update
      // Option 2: request full resync
    }

    // Apply the update
    this.state[update.entityId] = { ...this.state[update.entityId], ...update.payload };
    this.lastSequence.set(key, update.sequence);
  }
}

This approach works well for independent state tracks—location, pricing, and availability can each use their own sequences without conflict. The client merges them into a unified ride object, but each producer's updates stay consistent.

Last-write-wins — For locations and prices, only the newest value matters

Sequence numbers work great when you need strict ordering, but for many real-time fields, only the most recent value matters. Location, temperature, stock price, availability count—these are naturally "last-write-wins" attributes.

In those cases, timestamp-based comparison is simpler and more resilient than sequence numbers:

typescript

interface TimestampedUpdate {
  entityId: string;
  field: string;
  value: unknown;
  timestamp: number; // Server-time or logical clock
  version: string;   // Optional: ties to a specific state snapshot
}

class StateCache {
  private fieldTimestamps = new Map<string, number>(); // "ride-123:price" → timestamp

  applyLww(update: TimestampedUpdate) {
    const key = `${update.entityId}:${update.field}`;
    const lastTimestamp = this.fieldTimestamps.get(key) ?? 0;

    if (update.timestamp < lastTimestamp) {
      // Older than what we have, ignore
      return;
    }

    // Update the field and record the timestamp
    this.cache[update.entityId] = this.cache[update.entityId] || {};
    this.cache[update.entityId][update.field] = update.value;
    this.fieldTimestamps.set(key, update.timestamp);
  }
}

Last-write-wins is beautifully simple and works well for independent, scalar fields. Beware: it breaks down for structured updates that depend on each other. If you send { lat, lng } as separate messages, LWW might give you the lat from 2024-01-15T10:00:00Z and lng from 2024-01-15T09:59:59Z—a location from the past.

For such cases, either group related fields into one update message, or fall back to per-entity versioning and validate consistency at the client.

Delivery is not guaranteed — Disconnected clients miss messages. Plan for resync

Here's the hard truth: WebSocket connections drop. Network hiccups, browser tab backgrounding, server restarts—it happens. During a disconnect, your client misses updates. When it reconnects, your WebSocket server has likely already streamed those events to other clients and discarded them.

You must design a resync mechanism:

typescript

// On the server: persist recent state to Redis or a database
class WebSocketServer {
  private redis: Redis;

  async broadcastUpdate(update: EntityUpdate) {
    // 1. Send to connected clients immediately
    this.io.emit("update", update);

    // 2. Store the latest value in Redis with expiration
    const key = `state:${update.entityType}:${update.entityId}`;
    await this.redis.set(
      key,
      JSON.stringify(update.payload),
      "EX",
      86400 // 24 hours
    );

    // 3. Also store in a time-series log for gap detection
    const logKey = `log:${update.entityType}:${update.entityId}`;
    await this.redis.lpush(logKey, JSON.stringify(update));
    await this.redis.ltrim(logKey, 0, 999); // Keep last 1000 updates
  }

  async handleReconnect(clientId: string, lastKnownSequence: number) {
    const rideId = this.getClientRide(clientId);
    const logKey = `log:ride:${rideId}`;

    // Fetch updates since last known sequence
    const updates = await this.redis.lrange(logKey, 0, -1);
    const filteredUpdates = updates
      .map(u => JSON.parse(u))
      .filter(u => u.sequence > lastKnownSequence);

    // Send backfill to client
    for (const update of filteredUpdates.reverse()) {
      this.io.to(clientId).emit("update", update);
    }
  }
}

On the client side, track your last applied sequence and send it on reconnection:

typescript

class WebSocketClient {
  private lastSequence = 0;
  private ws: WebSocket;

  connect() {
    this.ws = new WebSocket(`wss://api.example.com/ride/${this.rideId}`);
    this.ws.onopen = () => {
      // Tell server where we left off
      this.ws.send(JSON.stringify({
        type: "sync",
        lastSequence: this.lastSequence
      }));
    };

    this.ws.onmessage = (e) => {
      const update = JSON.parse(e.data);
      this.applyUpdate(update);
      this.lastSequence = Math.max(this.lastSequence, update.sequence);
    };
  }
}

This way, a client that disconnects for 30 seconds can rejoin and replay the missed updates, maintaining consistency.

Common mistakes

  • Ignoring clock skew: Server timestamps may differ across machines. Use a single canonical clock (NTP-synced, or a logical clock) for ordering decisions. Don't trust client timestamps.
  • Broadcasting without retention: Sending WebSocket messages to all clients is fast, but you have no history. Always back up critical updates to a database or cache so you can resync.
  • Mixing LWW and ordering: If you use last-write-wins for some fields and strict ordering for others, document it clearly. Inconsistency leads to bugs that only appear under network stress.
  • Assuming partition-level ordering is enough: You may carefully route updates to the same Kafka partition, but your WebSocket server still deserializes and processes them concurrently. Add explicit ordering logic at the application layer.
  • No gap detection: A silent gap in sequences is worse than a loud failure. Always detect missing updates and trigger a resync, don't just hope the next message will fix it.

Key takeaways

  • TCP ordering doesn't imply system ordering. Multiple independent producers will emit events out of order.
  • Use sequence numbers scoped per entity and per producer to detect and handle out-of-order updates.
  • For scalar, independent fields (location, price), last-write-wins with timestamps is simpler and often sufficient.
  • WebSocket disconnects are inevitable. Persist recent state and implement a resync protocol so reconnecting clients catch up without full reloads.
  • Combine sequences, timestamps, and gap detection to build a resilient real-time system that users trust.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles