DEV Community

Taocarts跨境
Taocarts跨境

Posted on AI-assisted

Building a Real-Time Parcel Tracking Feed with WebSockets

-->

Building a Real-Time Parcel Tracking Feed with WebSockets

When you run a cross-border reverse-commerce platform, the single most repeated question from buyers is not "how much" — it is "where is my parcel". On Taocarts, a system that mirrors products from Chinese marketplaces (Taobao, 1688) into overseas storefronts and consolidates purchasing, that question shows up hundreds of thousands of times a day. Our first version answered it badly.

The polling mistake

The early dashboard fetched the latest tracking status from our backend on a fixed interval — every 30 seconds per open tab. It worked in the demo and fell over in reality. With a few thousand concurrent buyers watching their parcels, that was a constant stream of identical read queries hammering the same endpoints. Worse, most polls returned "no change", so we were burning database connections to say nothing.

The second problem was freshness. A parcel can move three statuses in the time between two polls. Buyers refreshed manually, saw stale data, and opened support chats asking the same thing our server already knew. The chat volume was purely an artifact of bad UX, not real issues.

Why WebSockets, not SSE or long-poll

We considered Server-Sent Events. SSE is simpler and rides on HTTP, which is appealing. But our tracking updates come from multiple sources — carrier webhooks, manual scans at the warehouse, and batch imports — and we wanted bidirectional room to let clients subscribe to specific parcel IDs rather than a global stream. WebSockets gave us a clean subscription model: open one connection, send a subscribe frame with the parcel IDs you care about, and receive only the deltas.

The architecture

The transport layer is a small Node service behind the main Laravel API. Responsibilities are deliberately narrow:

  • Authenticate the socket using the same session token as the REST API.
  • Accept subscribe / unsubscribe frames keyed by parcel ID.
  • Hold a lightweight in-memory map of parcelId -> set of sockets.
  • Subscribe to a Redis pub/sub channel where all tracking updates are published.

The source of truth for tracking lives in the Laravel service. Whenever a status changes — a carrier webhook lands, a warehouse scan happens, a batch import finishes — Laravel publishes a small event to Redis: tracking:updated with the parcel ID and the new node. The Node socket service is just another subscriber. It looks up which sockets care about that parcel ID and pushes the delta. Nothing about tracking state is stored in the socket service itself; it is a pure fan-out.

Keeping state out of the socket layer was the key decision. If the socket service restarts, it loses nothing but open connections, and clients simply reconnect and re-subscribe. The real data is always rebuilt from the API on reconnect.

Handling the messy parts

Connection drops are normal, not errors. We use a heartbeat every 25 seconds; a socket that misses two heartbeats is closed and the client reconnects with exponential backoff. On reconnect, the client sends its current subscribed IDs again, so it never misses a node that changed while it was away — it just replays the latest state from the API.

Backpressure matters. If a carrier dumps ten thousand updates in a burst, we do not push them one-by-one synchronously. We batch per socket on a short tick (say 200ms) and coalesce: if a parcel changed twice in that window, the client gets the final node, not both. Buyers want "where it is now", not a high-frequency play-by-play.

We also learned to cap subscriptions per connection. A single tab subscribing to ten parcels is reasonable; a buggy client opening ten tabs and subscribing to a hundred each is not. We enforce a per-connection limit and a global connection ceiling, rejecting politely beyond that.

What changed

Support tickets about "where is my parcel" dropped sharply because the answer now arrives before the buyer thinks to ask. Server load actually went down despite more live connections, because we replaced a storm of polling reads with a push model that only fires on real change.

The lesson that stuck with the team: real-time is not about pushing everything faster, it is about pushing only what changed, to only the people who asked. WebSockets gave us the subscription primitive to do exactly that without bloating the core service.

Top comments (0)