DEV Community

Juan Esteban Roig Perez
Juan Esteban Roig Perez

Posted on Edited on Originally published at juanesroig.com

1,000 users, 1 API call: the fan-out pattern that saved my rate limit

If every visitor's browser polls your API, your usage grows with your audience. Mine doesn't: whether one person or a thousand open the app, the upstream API gets a single call.

The app is this live airspace visualizer, a 3D globe that shows live air traffic, with new aircraft positions every 15 seconds. If you wonder how the aircraft keep flying smoothly between updates, I explained it in this other article.

The data comes from a third-party API with a rate limit per account, not per user. If each browser called it directly, a hundred open tabs would mean a hundred calls every 15 seconds, and I'd hit the limit long before the app got any traffic. Besides, the API key can't live in the frontend.

One poller, many clients

The solution is a small proxy between the browsers and the API. It has a single poller that asks the API for new data every 15 seconds and sends the result to every connected browser over Server-Sent Events (SSE). The number of users only changes how many connections the proxy keeps open, not how many requests reach the API.

Each browser opens a connection to /api/states. The proxy sets the SSE headers, keeps the response open and hands it to the poller. When the browser closes the connection, the poller forgets about it.

  app.get('/api/states', (req, res) => {
    res.setHeader('Content-Type', 'text/event-stream')
    res.setHeader('Cache-Control', 'no-cache')
    res.flushHeaders()

    const client_id = poller.register(res)
    // The browser closed the tab or lost the connection
    req.on('close', () => poller.unregister(client_id))
  })
Enter fullscreen mode Exit fullscreen mode

On every tick, the poller makes one request and writes the result to every connection it has. An SSE message is just text, so writing to an open response is enough. If the request fails, the clients get an error event instead and the poller tries again on the next tick. There's also a lock so two requests don't overlap when the API is slow, and a timeout so one that never answers can't hold that lock forever.

  private broadcast(event: string, data: unknown) {
    // An SSE message: event name, data and a blank line
    for (const res of this.clients.values()) {
      res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
    }
  }

  private async dispatcher() {
    // The previous request is still running: skip this tick
    if (this.is_fetching) return
    this.is_fetching = true
    try {
      // One request, no matter how many clients there are
      const data = await this.get_data_with_timeout()
      this.latest_data = data
      this.latest_data_at = Date.now()
      this.broadcast('success', data)
    } catch (error) {
      this.broadcast('error', { message: this.error_message(error) })
    } finally {
      this.is_fetching = false
    }
  }
Enter fullscreen mode Exit fullscreen mode

The poller only runs while someone is watching. The first client to connect starts it. Since the latest result is cached, anyone who connects later gets it straight away instead of waiting up to 15 seconds for the next tick. If the cache is more than a minute old, it's thrown away rather than served stale.

  register(res: express.Response) {
    const client_id = randomUUID()
    this.clients.set(client_id, res)

    // Older than a minute (or nothing yet): discard it
    const age = Date.now() - (this.latest_data_at ?? 0)
    if (age > MAX_DATA_AGE_MS) this.latest_data = null
    // The new client doesn't wait for the next tick
    if (this.latest_data !== null) this.write_sse(res, 'success', this.latest_data)

    // First client: start polling
    if (this.interval_id === null) {
      void this.dispatcher()
      this.interval_id = setInterval(() => void this.dispatcher(), this.time_ms)
    }
    return client_id
  }
Enter fullscreen mode Exit fullscreen mode

When the last client disconnects, the interval is cleared and the proxy stops calling the API until someone comes back.

  unregister(client_id: string) {
    this.clients.delete(client_id)
    // Last one out turns off the lights
    if (this.clients.size === 0 && this.interval_id !== null) {
      clearInterval(this.interval_id)
      this.interval_id = null
    }
  }
Enter fullscreen mode Exit fullscreen mode

Why SSE and not WebSockets

WebSockets would work too, but they solve a problem I don't have. Here the data only goes one way, from the server to the browser, and that's exactly what SSE is for. It works over plain HTTP, so it goes through proxies and load balancers without extra setup, and the browser reconnects on its own if the connection drops. On the client it's just a few lines:

  const event_source = new EventSource(api_url('states'))
  event_source.addEventListener('success', (event) => {
    layer.update_aircrafts(JSON.parse(event.data))
  })
Enter fullscreen mode Exit fullscreen mode

With WebSockets I'd have to handle the protocol upgrade, the reconnection and a message format myself, for a channel where the browser never sends anything.

Disconnecting when no one is watching

There's one case left. If someone opens the app and leaves it in a background tab, the SSE connection stays open. If they're the only user, the poller keeps calling the API every 15 seconds for nobody.

To avoid it, the frontend listens to the visibilitychange event. When the tab is hidden it starts a one-minute timer, and when the timer runs out the stream is paused. If the user comes back before that, the timer is cancelled and nothing happens.

  const INACTIVE_DISCONNECT_MS = 60_000

  const handle_visibility_change = () => {

const handle_visibility_change = () => {
  clearTimeout(timeout)
  if (document.hidden) {
    // Hidden tab: pause the stream after a minute
    timeout = setTimeout(() => set_stream_paused(true), INACTIVE_DISCONNECT_MS)
  } else {
    set_stream_paused(false)
  }
}
document.addEventListener('visibilitychange', handle_visibility_change)
Enter fullscreen mode Exit fullscreen mode


typescript

Pausing the stream closes the EventSource. To the proxy that's just another client leaving, so if it was the last one, polling stops. When the user comes back, a new connection opens and gets the cached data if it's still fresh, or starts the poller again if it isn't. I also add a "Paused" prefix to the page title, so the tab bar shows why the data isn't moving.

useEffect(() => {
  if (stream_paused) return
  const event_source = new EventSource(api_url('states'))
  // ...
  // Closing it unregisters this client in the proxy
  return () => event_source.close()
}, [stream_paused])
Enter fullscreen mode Exit fullscreen mode

Conclusion

This pattern works for any project that needs regular updates from an API you don't control: prices, weather, scores, the status of a deploy. If the data is the same for everyone, there's no reason for each user to ask for it. One poller, a cache and a list of open connections are enough, and the cost stays the same with one user or a thousand.

It has its limits: it only works when everyone sees the same data, and the connections live in the memory of one process, so if you run several instances, each one has its own poller. For a project like this one, it's a good trade-off.

You can see it live at airspace.juanesroig.com. The code is on GitHub: the frontend in airspace-visualization and the proxy in opensky-proxy.

Top comments (0)