Skip to content

19 Timing and storage

Roberto Ferranti edited this page Nov 10, 2026 · 3 revisions

Timing and storage

Three small APIs on the ful global that the components use themselves and that are exposed for your own code: rate-limiting a function, dropping the outcome of work that newer work has superseded, and putting something in browser storage with a version on it.

Debouncing and throttling

ful.Timing carries three functions.

const [search, abort] = ful.Timing.debounce(300, (needle) => runSearch(needle));

sleep(ms) resolves after the given milliseconds.

debounce(ms, fn, { immediate }) collapses a burst of calls into one. By default the call happens on the trailing edge, after the burst has stopped; immediate: true fires the leading edge instead, so the first call goes through and the rest of the burst is swallowed.

throttle(ms, fn, { leading, trailing }) lets a call through at most once per interval. Both edges are on by default, and either can be turned off: { leading: false } waits out the first interval before the first call, { trailing: false } drops the call a burst would otherwise leave pending at the end.

Both return [wrapped, abort]. Calling abort() cancels whatever is pending without disabling the function, so the wrapped function keeps working afterwards. That is what you want when a component is detached and re-attached rather than destroyed.

ful-select throttles its own search at 400ms, which is the built-in example of the shape.

Dropping superseded work

Asynchronous work overlaps: a list asks for page 2 and then for page 3 before page 2 answers, and whichever answer arrives last would win. ful.Claims makes the last request win instead. One Claims stands for one contended resource, and each piece of work takes a claim on it before it starts:

#loads = new ful.Claims();

async load(page) {
    const claim = this.#loads.take();       // supersedes every earlier claim
    const rows = await this.#fetch(page);
    if (claim.stale) {
        return;                             // a newer load started meanwhile: this answer owns nothing
    }
    this.#paint(rows);
}
call effect
take() starts a new generation, superseding every earlier claim, and answers the claim on it
hold() answers a claim on the current generation without superseding anything: work that joins what is running, and that a later take() must still detach
invalidate() supersedes every claim without taking one: a hide that ends every pending show
claim.stale true once a later take() or invalidate() superseded the claim

Ask stale after every await and before every effect: painting, storing state, dispatching an event, throwing towards a caller. Nothing is cancelled: a fetch in flight still resolves, its outcome is just dropped. Hold one Claims per resource rather than per component, so work that contends separately (a dropdown's searches and the labels of an assigned value, in ful-select) does not cancel each other.

Versioned browser storage

ful.LocalStorage and ful.SessionStorage wrap the platform's two stores with the same four calls, serializing through JSON.

ful.LocalStorage.save('draft', { subject: 'hello' });
ful.LocalStorage.load('draft');      // the object, or undefined
ful.LocalStorage.remove('draft');
ful.LocalStorage.pop('draft');       // load and remove in one call

Three details that matter in practice. A miss answers undefined, not null: storage is the one place in the library where that is the empty answer, since it is the platform's own. Reads never throw: where storage is unreachable at all, which happens with blocked cookies and in some embedded and private contexts, a read is a miss rather than a failure, and so is a stored value that no longer parses, which is dropped on the way out. Writes do throw, on a full quota, deliberately: silently losing what you asked to persist is worse than a rejected write you can see.

The methods are bound to nothing, so destructuring them keeps them working:

const { save, load } = ful.SessionStorage;

ful.VersionedLocalStorage and ful.VersionedSessionStorage add a revision beside the value.

ful.VersionedLocalStorage.save('countries', 'v7', entries);
ful.VersionedLocalStorage.load('countries', 'v7');   // the entries
ful.VersionedLocalStorage.load('countries', 'v8');   // undefined: evicted

A load under a revision that does not match what was stored evicts the entry and answers undefined. That is the whole mechanism behind ful-select's revision attribute: a remote vocabulary is cached across page loads, and changing the revision on the server invalidates every client's copy without anyone having to clear anything. A select can also leave the attribute valueless and take the revision from the registry, so the page states its build once rather than in every element: see ful-select.

The revision is an opaque string, so a deployment id, a content hash or a date all work equally well.

Clone this wiki locally