MOVGRANTO

WebSocket as a signal: driver jobs without polling

The driver app polled for jobs every 30 seconds. Now a WebSocket only signals a change, data comes from the API, and polling stays as a fallback.

Published: 5 min read

Author: Łukasz Głowacz, Full-Stack Engineer · Czytaj po polsku

Comparison: polling every 30 seconds versus a WebSocket that sends a signal, after which the app fetches data from the API.
Contents

Until recently, the driver app in BusiKM asked the server for the job list every 30 seconds. A dispatcher would change a status or assign a new job, and the driver would see it up to half a minute late, even though the backend was already sending these changes over WebSocket to the web panel. On October 9 I connected the same channel in the mobile app. The most important decision wasn't about the WebSocket itself, but about what to do with the message that arrives through it.

The backend already sent events

Every job change in the backend (status, assignment, unassignment, creation and deletion) goes through a single module, broadcast.py. It sends an event through Django Channels and Redis to several groups: the whole company, the fleet map, and, if the job has a driver assigned, that driver's own group orders_driver_{id}. So a driver receives only events for their own jobs, not for the whole company.

One thing in this module matters for the client. Sending is a side effect of the write, not part of it:

for group in groups:
    try:
        async_to_sync(layer.group_send)(group, {"type": channel_type, "payload": payload})
    except Exception as exc:
        # A broadcast is a side effect. Redis being down must not fail POST /orders/.
        logger.warning("Order broadcast %s failed for group=%s: %s", channel_type, group, exc)

If Redis is down, the job write succeeds and the event simply isn't sent. That's a good decision for the backend, but it means the client can't assume it will get every event. This fact shaped the rest of the design on the app side.

The event as a signal, not data

A status change event carries the job number, the old and new status, the time of the change and a few other fields. It's tempting to put these values straight into the app's cache and not ask the server at all. I didn't do that. The event doesn't carry the full job, and the details screen shows much more, for example route stages and documents. Patching the cache with partial data produces a state that exists nowhere else: a new status next to old stages, or a job in the list that should have disappeared after the status change.

So the app reads only two things from the event: the type and the job identifier. It treats the rest as a "something changed" signal and fetches current data from the API, just like on a regular refresh.

Event flow: when a job is saved, the backend sends an event through Redis to the driver's group; the app reads the type and identifier, refreshes the lists, and refreshes the details only if they are in the cache. Polling every 120 seconds stays as a fallback. On the timeline, a change at 0 is visible immediately instead of at 1

/** Order event from a WS frame; `connected`, `pong` and malformed frames return null. */
export const parseOrderWsEvent = (raw: unknown): OrderWsEvent | null => {
  // ...JSON.parse with try/catch
  if (!isObject(data) || !isOrderEventType(data.type)) return null;
  const orderId = Number(data.id ?? data.order_id);
  if (!Number.isInteger(orderId) || orderId <= 0) return null;
  return { type: data.type, orderId };
};
 
/** Lists always; the detail only if the driver has opened it (it is in the cache). */
export const applyOrderWsEvent = (event: OrderWsEvent, deps: OrderWsDeps): void => {
  deps.invalidateLists();
  if (event.type !== "order_deleted" && deps.isDetailCached(event.orderId)) {
    void deps.refreshDetail(event.orderId).catch(() => undefined);
  }
};

I fetch job details only if they're already in the cache, meaning the driver has opened them. Fetching details of a job they never viewed would be a pointless request. On deletion there's nothing to fetch, so only the lists are refreshed. This logic lives in a separate function with no React and no WebSocket, so the tests exercise it on plain objects.

The cost is one extra request after each event. But it happens only when something actually changed, not every 30 seconds regardless of whether anything happened.

A counter instead of a new refresh mechanism

The job list already had its own hook with polling. I didn't want to add a second data-fetching path next to it, so the event does one thing: it bumps a counter in the store.

// orders-store: grows on every WS order event
listRevision: 0,
invalidateLists: () => set((state) => ({ listRevision: state.listRevision + 1 })),
 
// useOrders: the revision is a dependency of the effect that fetches and polls
useEffect(() => {
  void refresh();
  const id = setInterval(() => void refresh(), interval ?? DEFAULT_POLL_INTERVAL_MS);
  return () => clearInterval(id);
}, [refresh, options.pollIntervalMs, revision]);

Changing the counter re-runs the effect: the list is fetched immediately, and the polling countdown restarts from zero. There's no separate "refresh after event" function that could someday start doing something different from a regular refresh.

Polling stays, but every 120 seconds

Since the backend may not send an event, and a phone on the road loses coverage every now and then, I didn't turn polling off. I only changed its role. Before, it was the main source of changes and ran every 30 seconds. Now it's a safety net for a dropped connection and runs every 120 seconds.

After a drop, the WebSocket connection tries to reconnect three times, after 1, 2 and 4 seconds, the same way the chat and the work-time module do. A close with code 4001 (not authenticated) or 4003 (no company) ends the attempts immediately, because retrying won't change anything. Once the attempts run out, the app keeps working on polling alone, just seeing changes more slowly.

What's still to improve

After the connection is restored, the app doesn't refresh the lists right away. Events that arrived during the outage aren't replayed, so in the worst case a change shows up only at the next poll, up to 120 seconds later. The fix is simple: bump the same counter on reconnect, so the app immediately fetches the current state.

The second limitation concerns the token. The app passes it as a parameter in the connection URL, just like the web panel, because the browser WebSocket API doesn't let you set a custom header. It's a common solution, but a URL containing a token must not end up in server or proxy logs, so this has to be watched on the infrastructure side.

One rule from this change turned out the most useful: the event says that something changed, and state comes from a single source, the API. In the post about read-only mode I described how this same app reacts to a subscription lock, and here you can see that even a simple "live" feature in a mobile app comes down to the question of what to do when a message doesn't arrive. If you're building a similar channel in your own app, get in touch.

Łukasz Głowacz

About the author

Łukasz Głowacz

Full-Stack Engineer · MOVGRANTO

Full-Stack Engineer at MOVGRANTO. Builds systems from architecture to production: backend, web, mobile, payments and CI/CD. Author of BusiKM, a SaaS for managing van fleets.

LinkedInGitHub

BusiKM

Fleet management for 2.5-3.5 t vans: orders, live fleet map, driver working time and costs in one place. 14 days free.

Try BusiKM ↗

Building a similar system?

We join teams as full-stack engineers or build products from scratch. Let's talk about your project.

Book a call →

Newsletter

One real architecture decision from building a production SaaS, every week.