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.
Table of Contents
- 1. When polling is the right choice despite SSE and WebSocket
- 2. Cleanly managing setInterval with init() and destroy()
- 3. A robust base implementation of a polling loop
- 4. Exponential backoff on repeated failures
- 5. Pausing polling when the tab is not visible
- 6. Common causes of memory leaks in polling components
- 7. Avoiding duplicate polling and overlapping requests
- 8. Practical example: an order status widget with an adaptive interval
- 9. Choosing the right polling interval
- 10. Summary
- 11. FAQ
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.