stalejs

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.

npm size license

→ Live Demo


The problem

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.

The solution

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.


Install

npm install stalejs
import { stale } from 'stalejs'        // ESM
const { stale } = require('stalejs')   // CJS

How it works

Every stale() call creates a binding that:

  1. Runs an initial fetch (unless eager: false)
  2. Starts a TTL interval — when it expires, refetches
  3. Pauses the clock when the tab is hidden
  4. Immediately refetches when the tab regains focus (if stale)
  5. Immediately refetches when the network comes back online
  6. Pauses when the element scrolls out of the viewport
  7. Resumes and refetches when it scrolls back in
  8. Auto-cleans up if the element is removed from the DOM

Call unsub() to manually tear everything down.


API

stale(target, options)

import { stale } from 'stalejs'

const unsub = stale(target, options)
unsub() // removes all listeners, observers, and intervals

target

string | 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.

TTL format

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,
})

Examples

Price ticker

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),
})

Notification badge

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
  },
})

Multiple elements via selector

// 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}` },
})

Framework usage

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>
}

vs. SWR / React Query

  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.


License

MIT © RK