Polling Pattern: Cleanly Implementing Interval-Based Data Refresh in Alpine.js
AI generated
x-data
Alpine
Alpine.js / Real-Time
Polling Pattern: Interval-Based Data Refresh
Cleanly managing setInterval alongside the Alpine lifecycle, without memory leaks and without unnecessary server load

Not every component needs Server-Sent Events or WebSocket, a simple but cleanly implemented polling loop is often entirely sufficient. The problem is rarely the basic idea behind setInterval, it is the details around it: an interval that keeps running after the component gets removed, a server that keeps getting hit every second while it is already down, or a background tab that happily keeps polling even though nobody is looking. This article shows how to tie a polling interval cleanly to Alpine's init() and destroy() lifecycle, how exponential backoff kicks in on repeated failures, and how polling can be paused through the Page Visibility API as soon as the tab is no longer visible.

10 min read setInterval Page Visibility API

1. When polling is the right choice despite SSE and WebSocket

Server-Sent Events and WebSocket solve the freshness problem more elegantly, but they also bring more infrastructure overhead: a permanently open connection per client, dedicated server-side support, and in many setups extra configuration on reverse proxies or load balancers. For data that changes rarely, or where a delay of a few seconds is entirely acceptable, such as a dashboard with hourly refreshed reports, that extra effort is often simply not justified.

Polling also remains the more pragmatic choice when the existing infrastructure, for example a classic PHP-FPM setup without support for long-running connections, makes operating many concurrently open streams difficult. A cleanly implemented polling loop with a reasonable interval, backoff, and a visibility pause hits a good balance between simplicity and acceptable server load in such cases.

2. Cleanly managing setInterval with init() and destroy()

The classic source of errors with polling in Alpine is a setInterval that gets started in init() but never stopped again. As soon as the component gets removed from the DOM, for instance because an x-if hides it, the interval timer keeps happily running in the background, keeping the component, which is no longer actually needed, alive through the closure context, and continuing to fire requests against an endpoint whose result nobody sees anymore.

The clean solution is to consistently store the timer ID returned by setInterval on this, and clean it up again with clearInterval in the component's destroy() hook. Alpine calls destroy() automatically once the associated element gets removed, so this cleanup step reliably kicks in without needing to be triggered manually elsewhere in the code.


Alpine.data('orderStatusPoller', (orderId) => ({
  status: null,
  intervalId: null,

  init() {
    this.fetchStatus();
    this.intervalId = setInterval(() => this.fetchStatus(), 5000);
  },

  async fetchStatus() {
    const response = await fetch(`/api/orders/${orderId}/status`);
    this.status = await response.json();
  },

  destroy() {
    clearInterval(this.intervalId);
  },
}));

3. A robust base implementation of a polling loop

Beyond simply starting and stopping, one small addition pays off: checking whether the previous request has already finished before starting the next one. Without that check, a slow server or a briefly blocked endpoint can cause a new request to start while the previous one is still in flight, letting overlapping requests pile up whose responses arrive in an unpredictable order.

A simple inFlight flag reliably solves this problem: as long as a request is still pending, the next interval tick gets skipped instead of starting another parallel request. That keeps the actual number of concurrent requests constant at a maximum of one per component, regardless of server response time.


Alpine.data('orderStatusPoller', (orderId) => ({
  status: null,
  intervalId: null,
  inFlight: false,

  init() {
    this.fetchStatus();
    this.intervalId = setInterval(() => this.fetchStatus(), 5000);
  },

  async fetchStatus() {
    if (this.inFlight) {
      return; // previous request still running, skip this tick
    }
    this.inFlight = true;

    try {
      const response = await fetch(`/api/orders/${orderId}/status`);
      this.status = await response.json();
    } finally {
      this.inFlight = false;
    }
  },

  destroy() {
    clearInterval(this.intervalId);
  },
}));

4. Exponential backoff on repeated failures

If the server goes down for a moment, a rigid five-second interval means the component keeps hitting it at the same rate, even though every one of those requests fails. With many tabs open at once, that makes the situation worse for an already struggling server instead of giving it time to recover. Exponential backoff addresses this by extending the interval after every failure, up to a sensible upper bound.

It matters to reset the interval back to its original value immediately after the first request succeeds again, so the component does not get stuck permanently in a slower rhythm once the server starts responding normally again. Without that reset, a single temporary outage would degrade the freshness of the display indefinitely.


async fetchStatus() {
  if (this.inFlight) return;
  this.inFlight = true;

  try {
    const response = await fetch(`/api/orders/${this.orderId}/status`);
    if (!response.ok) throw new Error(`Status ${response.status}`);

    this.status = await response.json();
    this.failureCount = 0;
    this.scheduleNext(this.baseInterval);
  } catch (error) {
    this.failureCount++;
    const backoff = Math.min(this.baseInterval * 2 ** this.failureCount, 60000);
    this.scheduleNext(backoff);
  } finally {
    this.inFlight = false;
  }
},

scheduleNext(delay) {
  clearTimeout(this.timeoutId);
  this.timeoutId = setTimeout(() => this.fetchStatus(), delay);
}

5. Pausing polling when the tab is not visible

A tab open in the background that keeps polling at its normal rate generates server load for a display that nobody is currently looking at. The Page Visibility API, through document.hidden and the visibilitychange event, provides exactly the information needed to deliberately pause polling in that case, instead of letting it run blindly.

In practice that means distinguishing, inside the visibilitychange handler, between stopping the interval when the tab hides and immediately restarting it along with a direct refresh once it becomes visible again. That immediate refresh on return matters, so the user does not have to wait out the full interval to see current data again after being away for a while.


init() {
  this.startPolling();

  this.visibilityHandler = () => {
    if (document.hidden) {
      clearInterval(this.intervalId);
    } else {
      this.fetchStatus(); // immediate refresh on return
      this.startPolling();
    }
  };

  document.addEventListener('visibilitychange', this.visibilityHandler);
},

startPolling() {
  clearInterval(this.intervalId);
  this.intervalId = setInterval(() => this.fetchStatus(), this.baseInterval);
},

destroy() {
  clearInterval(this.intervalId);
  document.removeEventListener('visibilitychange', this.visibilityHandler);
}

6. Common causes of memory leaks in polling components

Besides the already mentioned missing clearInterval, there is a second, equally common mistake: a registered visibilitychange listener on document that never gets removed again. Since document persists beyond the lifetime of an individual component, such a listener keeps the entire component closure alive in memory permanently, even after the associated DOM element has long been removed.

The rule can be summarized simply: every addEventListener on an object that outlives the component, such as document or window, needs a matching removeEventListener with the same function reference in the destroy() hook. Passing an anonymous callback directly into addEventListener prevents it from being removed correctly later, since no reference to that exact function exists anymore.

7. Avoiding duplicate polling and overlapping requests

Besides the inFlight protection against overlapping requests within a single component shown above, it is worth looking at situations where the same component accidentally gets initialized more than once, for example through a broken Alpine.data registration that creates an additional interval on every hot reload during development, without stopping the old one.

A defensive approach cleans up any potentially existing old interval as a precaution at the start of init(), before starting a new one. That costs nothing in the normal case, but reliably prevents multiple parallel polling loops for the same component from silently accumulating.

8. Practical example: an order status widget with an adaptive interval

An order status widget after checkout only needs to poll the status until a final state such as 'shipped' or 'cancelled' is reached, after that further polling is pure waste. As soon as the component recognizes such a final state, it should stop the interval itself instead of continuing to run until the element gets removed from the DOM.

Combined with the visibility pause and the exponential backoff from the previous sections, this produces a widget that only actually polls when it makes sense: visible, with a pending status, and with a working server connection. That combination often reduces the server load of a single widget over its whole usage lifetime by more than half compared to a naive, permanently running interval.

9. Choosing the right polling interval

An interval that is too short generates unnecessary server load without any real freshness gain, an interval that is too long makes the display feel noticeably stale. A good starting point is asking how often the underlying value actually changes in practice, rather than the theoretically shortest possible refresh rate that would be technically feasible.

In many cases an adaptive interval that adjusts to context also pays off: short during an active checkout process where order status can change quickly, considerably longer for a dashboard with historical metrics. The table below summarizes the key aspects of a polling pattern compared to the alternatives from the earlier articles in this series.

Aspect Naive Polling Polling with Backoff and Visibility Recommendation
Behavior on server error Keeps asking at the same rate Interval grows up to an upper bound Always implement backoff
Behavior in a background tab Keeps polling unchanged Paused, refreshed on return Use the Page Visibility API
Overlapping requests Can pile up Prevented via an inFlight flag Build in inFlight protection
Cleanup on removal Often keeps running unnoticed clearInterval in the destroy() hook Always tie it to the lifecycle
Resource usage Constantly high, regardless of need Adapts to visibility and failure rate Prefer an adaptive interval

Mironsoft

Alpine.js interactivity for Hyvä frontends

A Hyvä frontend that needs more interactivity, but without React overhead?

We build interactive frontend components for Hyvä themes with Alpine.js, lightweight and without build-step complexity, from simple toggles to complex form flows.

Custom Components

Develop interactive Alpine.js components for specific shop requirements.

Performance Review

Review existing Alpine.js implementations for reactivity pitfalls and performance.

Team Training

Bring developers up to speed on Alpine.js patterns for Hyvä themes hands-on.

10. Summary

Polling Pattern with Alpine.js

Core idea

A setInterval timer is cleanly tied to the Alpine lifecycle and consistently cleaned up again in the destroy() hook.

Failure resilience

Exponential backoff extends the interval on repeated failures and prevents unnecessary load on an already struggling server.

Resource savings

The Page Visibility API pauses polling in a background tab and refreshes immediately on return.

Most common bug source

A missing clearInterval or a never-removed visibilitychange listener keeps the component permanently in memory.

11. FAQ: Polling Pattern with Alpine.js

1Why does my setInterval keep running even though the component was removed?
Because the timer ID was never stopped with clearInterval. It has to be cleaned up in the component's destroy() hook, which Alpine calls automatically once the associated DOM element is removed.
2How do I prevent overlapping requests against a slow server?
With a simple inFlight flag that gets set as soon as a request starts and skips the next interval tick as long as the previous request has not finished yet.
3What is exponential backoff in polling?
A pattern where the polling interval doubles or extends similarly after every failure, up to a fixed upper bound, so an already struggling server does not get hit with an unchanged, frequent rate of requests.
4Do I need to reset the interval after a successful request?
Yes, otherwise the component stays stuck permanently in the slower backoff rhythm even once the server is responding normally again. The failure counter should be reset to zero on every success.
5How do I pause polling when the browser tab is in the background?
Through the Page Visibility API using document.hidden and the visibilitychange event, which pauses polling when the tab hides and restarts it with an immediate refresh once it becomes visible again.
6Why does my application's memory usage grow over time?
Often because of a visibilitychange or other listener on document or window that never gets removed with removeEventListener, keeping the entire component closure permanently in memory.
7How do I choose the right polling interval?
Based on how often the underlying value actually changes in practice, not on the theoretically shortest possible refresh rate. An adaptive interval that adjusts to context is often the best solution.
8Should an order status widget keep polling forever?
No, once a final state such as 'shipped' or 'cancelled' is reached, the component should stop the interval itself, since further polling from that point on is pure waste.
9What happens if the same component accidentally gets initialized more than once?
Multiple parallel polling loops end up running for the same component. A defensive init() that cleans up any potentially existing old interval first prevents that reliably.
10Is polling always the worse choice compared to Server-Sent Events?
No, for rare changes and simple infrastructure, a cleanly implemented polling loop with backoff and a visibility pause often remains the more pragmatic and easier to operate solution.