Every time I needed a live UI element — a price ticker, a notification badge, a streak counter — I found myself writing the same block of code. Poll on an interval. Pause when the tab is hidden. Resume when it comes back. Pause when the element scrolls out of viewport. Handle network reconnect. Clean up when the element is removed.
It's about 40 lines. It works fine. But it's boilerplate, and I was tired of it.
So I extracted it, cleaned it up, made it configurable, and published it as stalejs. The whole thing is 1.3kb gzipped with zero dependencies.
The Problem It Solves
Say you have a price element that needs to refresh every 30 seconds. The naive version looks like this:
setInterval(async () => {
const data = await fetch('/api/price').then(r => r.json())
document.querySelector('#price').textContent = data.price
}, 30000)
This has problems. It keeps running when the tab is hidden — wasting requests. It doesn't pause when the element scrolls off screen. It doesn't recover if the network drops. And when the element is removed from the DOM, the interval keeps firing forever.
The stalejs Solution
With stalejs, the same behaviour is one call:
stale('#price', {
ttl: '30s',
refetch: () => fetch('/api/price').then(r => r.json()),
update: (el, data) => { el.textContent = data.price }
})
That's it. Under the hood, stalejs automatically handles all of the following:
- Tab visibility — pauses when
document.visibilityStateis hidden, resumes immediately on focus - Intersection observer — pauses when the element scrolls out of the viewport, resumes when it comes back
- Network reconnect — listens to
window.onlineand triggers a refetch when connection is restored - Automatic cleanup — uses a
MutationObserveron the element's parent to detect removal and tear everything down
How I Built It
The core was straightforward — a class that wraps setInterval and wires up the observers. The tricky part was making sure all observers were properly torn down on cleanup to avoid memory leaks.
I used a WeakMap to store instance state against DOM elements, which means the garbage collector can clean up automatically if the consumer forgets to call .destroy().
The TTL Parser
One small thing I'm proud of is the TTL parser. Instead of requiring milliseconds, you can pass human-readable strings:
ttl: '30s' // 30 seconds
ttl: '5m' // 5 minutes
ttl: '1h' // 1 hour
ttl: 2000 // raw ms still works
Publishing to NPM
This was my first library on NPM. A few things I learned the hard way:
- Always ship both ESM and CJS builds. I used Rollup to generate both.
- Include TypeScript types even if you write in plain JS — consumers expect them.
- Set
"sideEffects": falseinpackage.jsonso bundlers can tree-shake aggressively. - Semantic versioning matters from day one, not just when you have users.
Wrapping Up
stalejs is small, focused, and solves exactly one problem well. It works with any stack — vanilla JS, React, Vue, Svelte, HTMX — anything that has a DOM.
If you find yourself copying the same visibility/polling/cleanup pattern, give it a try. It's on GitHub at github.com/kptaan13/stalejs.
Stars and issues welcome.