Zero-dependency DOM freshness primitive

Keep your UI fresh.
Automatically.

No React. No virtual DOM. No framework lock-in. Drop it into any page and your elements stay in sync — tab visibility, network reconnects, scroll intersection, all handled.

< 3kb gzipped zero dependencies TypeScript ESM + CJS works everywhere
✗  Without stale — 40 lines
let intervalId = null
let isVisible = true
let isOnline = navigator.onLine
let lastFetched = null

function fetchPrice() {
  if (!isVisible || !isOnline) return
  fetch('/api/price')
    .then(r => r.json())
    .then(data => {
      document.querySelector('#price')
        .textContent = data.price
      lastFetched = Date.now()
    })
    .catch(console.error)
}

// Poll every 5 seconds
intervalId = setInterval(fetchPrice, 5000)
fetchPrice()

// Pause when tab is hidden
document.addEventListener('visibilitychange', () => {
  isVisible = !document.hidden
  if (!document.hidden) {
    // Was it stale while hidden?
    if (Date.now() - lastFetched > 5000) {
      fetchPrice()
    }
  }
})

// Refetch on network restore
window.addEventListener('online', () => {
  isOnline = true
  fetchPrice()
})
window.addEventListener('offline', () => {
  isOnline = false
})

// Pause when scrolled out
const io = new IntersectionObserver(([e]) => {
  isVisible = e.isIntersecting
  if (e.isIntersecting) fetchPrice()
})
io.observe(document.querySelector('#price'))

// Remember to clean up (you'll forget)
function destroy() {
  clearInterval(intervalId)
  io.disconnect()
  // ...remove event listeners
}
VS
✓  With stale — 6 lines
import { stale } from 'stale'

const unsub = stale('#price', {
  ttl: '5s',
  refetch: () =>
    fetch('/api/price').then(r => r.json()),
  update: (el, data) => {
    el.textContent = data.price
  },
})

// That's it. All of this is automatic:
// ✓ TTL expires       → refetch
// ✓ Tab hidden        → pause TTL clock
// ✓ Tab regains focus → refetch if stale
// ✓ Network offline   → pause
// ✓ Back online       → refetch immediately
// ✓ Scroll out        → pause TTL clock
// ✓ Scroll back in    → resume + refetch
// ✓ DOM removed       → full cleanup

unsub() // cleanup everything

Live Crypto Prices

Three cards running at different TTLs (4s / 6s / 8s) so you can watch them update independently. Each has a sparkline, freshness indicator, and draining TTL bar.

Bitcoin
BTC · 4s TTL
$67,420.00
fresh
0s ago
Ξ
Ethereum
ETH · 6s TTL
$3,241.00
fresh
0s ago
Solana
SOL · 8s TTL
$142.00
fresh
0s ago

Tab Visibility Pause

Switch to another tab and come back. The timeline records exactly when polling was active vs. paused — no wasted requests while you're away.

Champions Cup — Live
5s TTL · score updates randomly
Man City
1
Arsenal
0
polling active
0s ago
💡 Hide this tab then come back — the timeline below will show the pause gap
Polling Timeline
oldest 0 pauses recorded now →
Polling active
Tab hidden (paused)

Network Reconnect

Toggle the simulated network. When you go back online, stale() fires a refetch immediately — no waiting for the next TTL cycle.

● Network lost
Order #28471
Last updated just now
Placed
Processing
Shipped
Delivered
Network status
Network log
● Connected — polling active

Scroll Intersection Pause

These cards only poll when visible. Scroll them out of view and the counter drops — zero background work for off-screen elements.

0
Active polling instances out of 3 news cards
⏸ paused
Markets · 4s TTL
Loading…
Fetching…
⏸ paused
Tech · 5s TTL
Loading…
Fetching…
⏸ paused
Open Source · 6s TTL
Loading…
Fetching…