About Skills Work Journey Blog Contact
Open Source · 5 min read · March 2026

Building stalejs: A Tiny Library That Keeps Your DOM Automatically Fresh

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.visibilityState is 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.online and triggers a refetch when connection is restored
  • Automatic cleanup — uses a MutationObserver on 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": false in package.json so 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.

Share

More Writing