Zero-dependency DOM content-freshness primitive. Keep any element automatically up to date — tab visibility, network reconnects, scroll intersection, and TTL expiry all handled out of the box.
This is what keeping one element fresh actually looks like:
let intervalId, isVisible = true, isOnline = navigator.onLine, 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)
}
intervalId = setInterval(fetchPrice, 30_000)
fetchPrice()
document.addEventListener('visibilitychange', () => {
isVisible = !document.hidden
if (!document.hidden && Date.now() - lastFetched > 30_000) fetchPrice()
})
window.addEventListener('online', () => { isOnline = true; fetchPrice() })
window.addEventListener('offline', () => { isOnline = false })
const io = new IntersectionObserver(([e]) => {
isVisible = e.isIntersecting
if (e.isIntersecting) fetchPrice()
})
io.observe(document.querySelector('#price'))
// cleanup you'll definitely forget
function destroy() {
clearInterval(intervalId)
io.disconnect()
// ...removeEventListener × 3
}
40+ lines. Leaks if you forget cleanup. Multiply by every live widget in your app.
import { stale } from 'stalejs'
const unsub = stale('#price', {
ttl: '30s',
refetch: () => fetch('/api/price').then(r => r.json()),
update: (el, data) => { el.textContent = data.price },
})
unsub() // full cleanup — one call
Everything else is automatic.
npm install stalejs
import { stale } from 'stalejs' // ESM
const { stale } = require('stalejs') // CJS
Every stale() call creates a binding that:
eager: false)Call unsub() to manually tear everything down.
stale(target, options)import { stale } from 'stalejs'
const unsub = stale(target, options)
unsub() // removes all listeners, observers, and intervals
targetstring | HTMLElement | NodeList | NodeListOf<HTMLElement>
A CSS selector, a direct element reference, or a NodeList. When a selector matches multiple elements each gets an independent binding.
options| Option | Type | Default | Description |
|---|---|---|---|
ttl |
string \| number |
— | Time before data is considered stale. See TTL format. |
refetch |
() => Promise<any> |
— | Async function that returns fresh data. |
update |
(el, data) => void |
— | Applies the fetched data to the element. |
onError |
(err: Error) => void |
undefined |
Called when refetch throws. Silent by default. |
eager |
boolean |
true |
Fetch immediately on init. |
visibilityPause |
boolean |
true |
Pause TTL when tab is hidden. |
focusRefetch |
boolean |
true |
Refetch on tab focus if data is stale. |
intersectionPause |
boolean |
true |
Pause TTL when element is out of viewport. |
reconnectRefetch |
boolean |
true |
Refetch immediately when network comes back online. |
| Value | Resolves to |
|---|---|
'500ms' |
500 ms |
'30s' |
30,000 ms |
'5m' |
300,000 ms |
'1h' |
3,600,000 ms |
2000 (number) |
2,000 ms |
stale.invalidate(target)Force an immediate refetch, regardless of TTL.
stale.invalidate('#price')
stale.invalidate(el)
stale.pause(target) / stale.resume(target)Manually pause or resume a binding.
stale.pause('#price') // stop polling
stale.resume('#price') // resume — refetches immediately if stale
stale.getStatus(target)Returns the current status of a binding. Useful for building loading states, debug overlays, or error indicators.
const status = stale.getStatus('#price')
// Returns null if no binding exists for the target
// Otherwise:
{
paused: boolean // is the binding paused?
fetching: boolean // is a refetch in flight?
lastFetched: number // timestamp of last successful fetch (0 = never)
age: number // ms since last fetch (Infinity if never fetched)
stale: boolean // is data currently stale?
error: Error | null // last refetch error, if any
}
Example — show an error badge when refetch fails:
stale('#price', {
ttl: '10s',
refetch: () => fetch('/api/price').then(r => r.json()),
update: (el, data) => { el.textContent = data.price },
onError: () => {
const status = stale.getStatus('#price')
document.querySelector('#price-error').hidden = !status?.error
},
})
stale.configure(defaults)Set global defaults for all future stale() calls.
stale.configure({
ttl: '60s',
visibilityPause: true,
reconnectRefetch: true,
})
import { stale } from 'stalejs'
stale('#btc-price', {
ttl: '10s',
refetch: () => fetch('/api/btc').then(r => r.json()),
update: (el, data) => { el.textContent = `$${data.usd.toLocaleString()}` },
onError: (err) => console.warn('fetch failed:', err),
})
stale('#notif-count', {
ttl: '1m',
refetch: () => fetch('/api/notifications/unread').then(r => r.json()),
update: (el, { count }) => {
el.textContent = count > 99 ? '99+' : String(count)
el.hidden = count === 0
},
})
// Each `.score-widget` gets its own independent binding
stale('.score-widget', {
ttl: '5s',
refetch: () => fetch('/api/score').then(r => r.json()),
update: (el, data) => { el.textContent = `${data.home} — ${data.away}` },
})
Vanilla JS
import { stale } from 'stalejs'
const unsub = stale('#price', {
ttl: '30s',
refetch: () => fetch('/api/price').then(r => r.json()),
update: (el, data) => { el.textContent = data.price },
})
window.addEventListener('unload', unsub)
Vue 3
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { stale } from 'stalejs'
const el = ref(null)
let unsub
onMounted(() => { unsub = stale(el.value, { ttl: '15s', refetch, update }) })
onUnmounted(() => unsub?.())
</script>
<template><span ref="el">Loading…</span></template>
Svelte
<script>
import { onMount } from 'svelte'
import { stale } from 'stalejs'
let el
onMount(() => {
return stale(el, { ttl: '15s', refetch, update }) // return = auto cleanup
})
</script>
<span bind:this={el}>Loading…</span>
React (for non-React-state DOM needs)
import { useEffect, useRef } from 'react'
import { stale } from 'stalejs'
function PriceTicker() {
const ref = useRef(null)
useEffect(() => {
return stale(ref.current, {
ttl: '10s',
refetch: () => fetch('/api/price').then(r => r.json()),
update: (el, data) => { el.textContent = data.price },
})
}, [])
return <span ref={ref}>Loading…</span>
}
| stalejs | SWR / React Query | |
|---|---|---|
| Framework | None — any DOM | React only |
| Virtual DOM dependency | No | Yes |
| Bundle size | 1.3 kb gz | ~13 kb+ |
| Works with | Any HTML element | React component state |
| SSR pages, HTMX, Web Components | ✅ | ❌ |
stalejs is not a replacement for SWR or React Query inside React apps. It’s the answer for everything else — server-rendered pages, vanilla dashboards, HTMX partials, Web Components, and Vue/Svelte apps that need DOM-level freshness control.
MIT © RK