Europe/Paris
Posts

Building a real-time departure board with TanStack Query: how it works, and why it was harder than expected

July 19, 2026 · 9 min de lecture
train-departures-idf is a PWA (a Progressive Web App — a website that can be installed like a native app and works offline) that displays, station-board style, the next departures from an Île-de-France Mobilités stop (here Saint-Gratien towards Bibliothèque François-Mitterrand, RER C line), with a fullscreen mode designed to run continuously on a kiosk or tablet. Stack: React 19 + TanStack Start (SSR, Server-Side Rendering — the HTML is generated on the server for the first load, instead of shipping an empty page that JavaScript fills in later) + TanStack Query for "real-time" + Tailwind. What I want to cover here for junior devs is both the how (how TanStack Query does real-time without websockets) and the why — because this project was mostly instructive through the bugs that had to be understood to make it work, not through the difficulty of the final code. First thing to clarify for a junior: there is no websocket and no Server-Sent Events in this project — those are the two common mechanisms that let a server push updates to the browser on its own, without being asked. The API being queried is PRIM (the official real-time open-data platform run by Île-de-France Mobilités, the region's transit authority), through its SIRI "Stop Monitoring" feed — SIRI being a standardized European protocol for exchanging public-transit real-time data, not something specific to this project. That feed is itself a classic REST API queried on demand — it pushes nothing. "Real-time" here is therefore regular polling, and that's perfectly fine: it matches exactly the nature of the data source. The configuration lives in two complementary places. First, the React Query client defaults, set once at the root of the app:
Tsx
// app/routes/__root.tsx
const [queryClient] = useState(
  () =>
    new QueryClient({
      defaultOptions: {
        queries: {
          staleTime: 1000 * 25,
          refetchInterval: 1000 * 30,
          retry: 3,
          retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
        },
      },
    })
);
Then the business hook that consumes these defaults (repeating them explicitly, which is redundant here but keeps the hook readable in isolation):
Typescript
// src/hooks/useDepartures.ts
export function useDepartures() {
  return useQuery({
    queryKey: ["departures", "saint-gratien-bnf"],
    queryFn: fetchDepartures,
    refetchInterval: 30000,
    refetchIntervalInBackground: true,
    staleTime: 25000,
    retry: 3,
    retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
  });
}
Two options deserve an explanation for a junior, because they're often confused with each other:
  • staleTime answers the question "for how long is the cached data still considered fresh?". As long as the data isn't stale, a new component that mounts and requests the same queryKey (the array TanStack Query uses to identify and cache a specific request — ["departures", "saint-gratien-bnf"] in the hook above) gets the cache immediately, without going back to the network.
  • refetchInterval answers a different question: "regardless of freshness, how often do I force a new fetch in the background?". This is what keeps the board running continuously without any user action.
The exponential-backoff retryDelay (1s, 2s, 4s, ... up to a 30s cap) is the last detail that matters: if the PRIM API responds badly once (network timeout, a one-off 5xx — an HTTP status code in the 500 range, meaning the error is on the server's side, not the client's), TanStack Query retries on its own without hammering the API at a fixed interval, which is what a hand-rolled setInterval fetch would do. The SIRI API returns a verbose, deeply nested structure (Siri.ServiceDelivery.StopMonitoringDelivery[0].MonitoredStopVisit[].MonitoredVehicleJourney.MonitoredCall...), with optional fields and raw status codes. The display component needs none of that: it needs a clean list of departures, with a status already computed. That's the role of parseSIRIResponse, sitting at the boundary between the external API and the rest of the app:
Typescript
// src/lib/api.ts
export interface Departure {
  id: string;
  aimedDepartureTime: string;
  expectedDepartureTime: string;
  destination: string;
  platform: string | null;
  status: "onTime" | "delayed" | "cancelled" | "early" | "missed";
  delay: number;
  line: string;
  isRealTime: boolean;
}

function parseSIRIResponse(data: SIRIResponse): Departure[] {
  const visits =
    data.Siri?.ServiceDelivery?.StopMonitoringDelivery?.[0]?.MonitoredStopVisit || [];

  return visits
    .map((visit, index): Departure => {
      const journey = visit.MonitoredVehicleJourney;
      const call = journey.MonitoredCall;

      const aimed = new Date(call.AimedDepartureTime);
      const expected = call.ExpectedDepartureTime
        ? new Date(call.ExpectedDepartureTime)
        : aimed;

      const delayMs = expected.getTime() - aimed.getTime();
      const delay = Math.round(delayMs / 60000);

      let status: Departure["status"] = "onTime";
      if (call.DepartureStatus === "cancelled") status = "cancelled";
      else if (call.DepartureStatus === "missed") status = "missed";
      else if (delay > 2) status = "delayed";
      else if (delay < -1) status = "early";

      return {
        id: `${journey.LineRef}-${index}-${call.AimedDepartureTime}`,
        aimedDepartureTime: call.AimedDepartureTime,
        expectedDepartureTime: call.ExpectedDepartureTime || call.AimedDepartureTime,
        destination: journey.DestinationName || "BnF",
        platform: call.DeparturePlatformName || call.ArrivalPlatformName || null,
        status,
        delay,
        line: journey.PublishedLineName || "C14",
        isRealTime: !!call.ExpectedDepartureTime,
      };
    })
    .filter((d) => /* ... ne garder que les départs vers la bonne destination */ true)
    .sort((a, b) => new Date(a.expectedDepartureTime).getTime() - new Date(b.expectedDepartureTime).getTime());
}
What's pedagogically interesting here: the status (onTime / delayed / early / missed / cancelled) doesn't exist as such in the API. It is derived by comparing the scheduled time (AimedDepartureTime) and the estimated time (ExpectedDepartureTime), using business thresholds (delay > 2 minutes = delayed, delay < -1 = early). This is a business rule, not a technical detail — and the fact that it lives in api.ts, at the boundary, rather than scattered across the display component, is what would make it possible to unit test without ever calling the real API (the project doesn't do this yet, see below). A classic trap for a junior: making the "in 4 min" display depend on a network re-fetch, which would produce a counter that jumps in 30-second steps instead of counting down second by second. The solution here fully decouples the two clocks:
Typescript
// src/hooks/useCountdown.ts
export function useCountdown(targetDate: string): number {
  const [minutes, setMinutes] = useState(() => {
    const now = new Date();
    const target = new Date(targetDate);
    return Math.max(0, Math.ceil((target.getTime() - now.getTime()) / 60000));
  });

  useEffect(() => {
    const interval = setInterval(() => {
      const now = new Date();
      const target = new Date(targetDate);
      const diff = target.getTime() - now.getTime();
      setMinutes(Math.max(0, Math.ceil(diff / 60000)));
    }, 1000);

    return () => clearInterval(interval);
  }, [targetDate]);

  return minutes;
}
This hook makes zero network calls: it just recalculates, every second, the difference between "now" (the browser's clock) and the already-known departure time (received during the last TanStack Query fetch). The result: the board gives a smooth, second-by-second impression of real-time, even though the network is only hit every 30 seconds. This is a generalizable principle: anything that can be derived locally from data already in memory shouldn't trigger a new network call. The project documents its own struggles in a dedicated RETOUR_EXPERIENCE.md — I find that approach rare enough to be worth highlighting, and I'll go over here the lessons most useful to a junior. A dependency that installs without error isn't proof it does what you expect. The very first blocker: npm run dev was failing because vinxi (the tool behind TanStack Start) actually pointed to a completely different npm package with the same name (an old Vite inspection plugin that had claimed that name years earlier). npm install succeeded just fine — it was just installing the wrong thing. The reflex to have in this situation: check that the expected binary actually exists in node_modules/.bin before digging further. An ecosystem that shifts underneath a "frozen" project. The real dependency problem ran deeper: two different generations of TanStack Start coexisted in package.json (the old @tanstack/start based on Vinxi, and the new @tanstack/react-start based on a Vite plugin), with conflicting shared internal sub-dependencies. Even keeping only the old package, its own dependencies using open version ranges (^1.120.20 — the caret means npm is free to install any newer version as long as the first number doesn't change) resolved, a year after its publication, to non-backward-compatible versions. The generalizable lesson: a package published several months ago isn't frozen in time if its dependencies use open ranges — reinstalling it today can literally break a project that worked yesterday. The fix was to explicitly pin, via overrides in package.json, the entire @tanstack/start* family to the version generation it was actually published and tested together with. The hydration bug — the sneakiest one, and the most interesting to understand. (Quick reminder: hydration is the step where React attaches its event handlers and interactivity to the static HTML the server already sent — without it, the page looks right but nothing on it actually responds.) Once the dependencies were sorted out, the server responded, even displaying a nice "Loading departures..." — and then nothing happened, without a single console error. Two compounding causes: __root.tsx rendered neither <html>/<head>/<body> nor TanStack Router's <Scripts /> component (so no <script> tag was ever injected into the HTML sent to the browser), and app/client.tsx called hydrate(router) without ever calling ReactDOM.hydrateRoot to actually mount React onto the existing DOM. In other words: the server produced a frozen render, and the JavaScript meant to "take over" on the browser side was never loaded. The lesson to absolutely remember on an SSR app: seeing correct HTML via curl proves nothing about hydration. The real test is to open an actual browser (or script Playwright, a browser-automation tool that loads a real page and lets you inspect what actually happens in it, the way a human would) and check that the request to the external API does indeed fire from the client — that's exactly how this bug was eventually spotted: zero network requests to the PRIM API were ever emitted, while the "Loading..." message made it look like everything was fine. A present configuration isn't a wired-up configuration. Both tailwind.config.js and postcss.config.js existed, but no CSS file with the @tailwind directives was imported anywhere in the project. So every Tailwind class in the JSX (text-board-accent, bg-board-bg...) was present in the code without generating any style at all. A screenshot of the page (rather than just checking that the text was present in the DOM) immediately showed a page that was almost entirely black, with no styling. The lesson: verify the full chain (source CSS file → import → build) rather than just the presence of a config file. The "bug" that wasn't one. One last twist: the app was technically running, but stayed stuck on "Loading..." indefinitely. This time it wasn't a code bug: the hardcoded SIRI identifier for the stop didn't match any existing stop (the API explicitly responded with an error saying "the MonitoringRef/LineRef pair doesn't exist"). After searching through PRIM's places API, the correct identifier was found. Once fixed, the app did display departures — but sometimes none at all, simply because the configured line doesn't actually serve that stop at certain times of day in reality. The lesson: before suspecting the code, validate business identifiers directly against the real API (a simple curl is enough), and don't confuse "the app shows nothing" with "the app is broken" — sometimes it's faithfully reflecting a genuine absence of data.
  • Pin versions from the very first commit (overrides, or a verified lockfile — the package-lock.json/pnpm-lock.yaml file that freezes the exact version of every dependency actually installed) on an ecosystem that's still moving fast, rather than leaving open ranges on recent packages.
  • Always validate hydration with a real browser (headless Playwright is enough) before considering an SSR page "working" — the HTML returned by curl isn't enough to prove it.
  • Commit a .gitignore before the first commit, not as an afterthought once you're wondering whether an API key almost ended up in the git history.
  • Test business identifiers against the real API before hardcoding them, rather than starting from a value that "looks plausible".
  • No automated tests so far on parseSIRIResponse, even though it's exactly the kind of pure function (JSON input → typed output, with business threshold rules) that lends itself very well to fast unit tests, with no network and no framework.
The code is on github.com/Rajekevin/train-departures-idf. For a junior, I'd recommend starting with src/lib/api.ts and src/hooks/useDepartures.ts (the heart of the "real-time" logic), then reading the project's RETOUR_EXPERIENCE.md in full — it details each bug with far more context than this summary, and it's probably the most useful document in the repo for improving at debugging an SSR + real-time stack.
On this page