Keep per-element observer state — timestamps, timers, last measured size, load status — in a WeakMap<Element, State> of typed objects rather than in data-* attributes: reads and writes are plain property access with no string parsing, no attribute mutations to trigger MutationObservers or selector invalidation, and entries disappear with their elements.

Problem / Scenario Context

A lazy-loading and impression-tracking script keeps its state on the elements themselves: data-loaded="true", data-seen-at="1718031234567", data-dwell="840", data-last-width="312". Every callback parses these strings, updates them and writes them back. Profiles show attribute writes in every intersection callback, each triggering style invalidation because the site's CSS has [data-loaded] selectors, and a separate analytics MutationObserver watching attributes fires on every one of them. The state is also visible to — and occasionally overwritten by — other scripts on the page.

Data attributes are for communicating state to CSS and other code, not for storing a script's private bookkeeping. The WeakMap Observer Registries topic introduces element-keyed maps; this page applies them to per-element state.

Mechanics Explanation

A data-* attribute write is a DOM mutation:

  • Strings only. Numbers and booleans are converted to strings on write and must be parsed on read.
  • Mutation records. Every write queues a record for any MutationObserver watching attributes on that subtree — including third-party ones — as covered in watching attribute changes with attributeFilter.
  • Style invalidation. If any selector references the attribute ([data-loaded], [data-state="visible"]), the element's styles are invalidated and recalculated before the next frame, and any style read in the meantime forces recalculation.
  • Shared namespace. Other scripts can read and overwrite it.

A WeakMap<Element, State> avoids all four: values are real JavaScript types, updates are property writes on a private object, nothing observes them, and the map holds entries only as long as the element is alive. The one thing the map cannot do is style elements — so the rule is to keep bookkeeping in the map and write to the DOM only the presentational state CSS actually needs, once, when it changes.

State in Data Attributes Versus a WeakMapTwo columns. State in data attributes is stored as strings, triggers MutationObserver records and style invalidation on every write, is visible to and writable by other scripts, and survives only as long as the element. State in a WeakMap is stored as typed values, triggers nothing on write, is private to the module, and is released when the element is collected.data-* attributesStrings; parse on every readEach write is a DOM mutationInvalidates styles with [data-*] selectorsShared, overwritable namespaceWeakMap of recordsTyped values; plain property accessWrites trigger nothingPrivate to the moduleReleased with the element

Comparison Table: Where Each Kind of State Belongs

State Needed by CSS? Store in Write to DOM
Loaded / revealed (for styling) yes WeakMap + one class once, when it changes
First-seen timestamp no WeakMap never
Dwell timer id / accumulated time no WeakMap never
Last measured size no WeakMap never
Current breakpoint of a component yes WeakMap + one attribute only on breakpoint change
Retry count for a failed load no WeakMap never

Minimal Reproducible Example

TypeScript
const io = new IntersectionObserver((entries) => {
  for (const e of entries) {
    const el = e.target as HTMLElement;
    if (e.isIntersecting) {
      el.dataset.seenAt ??= String(Date.now());
      el.dataset.visibleSince = String(performance.now());
    } else if (el.dataset.visibleSince) {
      const dwell = Number(el.dataset.dwell ?? 0) + performance.now() - Number(el.dataset.visibleSince);
      el.dataset.dwell = String(Math.round(dwell));          // attribute mutation per exit
      delete el.dataset.visibleSince;                        // and another
    }
  }
});

With an attribute MutationObserver elsewhere on the page and [data-dwell] in a stylesheet, each crossing produces several mutation records and style invalidations.

Production-Safe Solution

TypeScript
interface ItemState {
  seenAt?: number;            // first time it became visible (ms since origin)
  visibleSince?: number;      // start of the current visible interval
  dwell: number;              // accumulated visible ms
  loaded: boolean;
  lastWidth?: number;
}

const state = new WeakMap<Element, ItemState>();
const get = (el: Element): ItemState => {
  let s = state.get(el);
  if (!s) { s = { dwell: 0, loaded: false }; state.set(el, s); }
  return s;
};

const io = new IntersectionObserver((entries) => {
  for (const e of entries) {
    const s = get(e.target);
    if (e.isIntersecting) {
      s.seenAt ??= e.time;
      s.visibleSince = e.time;
      if (!s.loaded) {
        s.loaded = true;
        load(e.target as HTMLImageElement);
        e.target.classList.add('is-loaded');       // the one presentational write, once
      }
    } else if (s.visibleSince !== undefined) {
      s.dwell += e.time - s.visibleSince;
      s.visibleSince = undefined;
    }
  }
}, { rootMargin: '200px 0px' });

export function dwellOf(el: Element): number {
  const s = state.get(el);
  if (!s) return 0;
  return s.dwell + (s.visibleSince !== undefined ? performance.now() - s.visibleSince : 0);
}

declare function load(img: HTMLImageElement): void;

Only one DOM write remains — adding is-loaded the first time an image loads, which CSS needs for its fade-in. Everything else is private, typed and invisible to other code. e.time is used for interval boundaries, so dwell measurements reflect when crossings actually happened, not when the callback ran.

DOM Work per 1,000 CrossingsA bar chart of side effects caused by storing observer state. Storing state in data attributes produced about three thousand attribute mutations, three thousand MutationObserver records for an attribute watcher on the page, and style invalidations for elements matched by data selectors. Storing state in a WeakMap with one class write on load produced about two hundred mutations, only for the first load of each image.1,000 intersection crossings, 200 imagesstate in data-*~3,000 attribute mutationsWeakMap + class on load~200 class changes

Debuggability Without Attributes

The one real advantage of data attributes is that you can see them in the Elements panel. You can keep that convenience without the cost:

  • Expose a debug accessor in development: window.__observerState = (el) => state.get(el). Select an element in DevTools, then call __observerState($0) in the console.
  • Mirror state to attributes only in debug builds, behind a flag, for visual inspection.
  • Use DevTools' "Store as global variable" on the WeakMap itself when paused in the module.

Tests can import dwellOf or a similar read-only accessor rather than inspecting the DOM, which also makes them less brittle.

One Presentational Write, Everything Else PrivateThree boxes. The observer callback updates a typed record in a WeakMap for every crossing, with no DOM writes. When an image loads for the first time, a single class is added so CSS can fade it in. A development-only accessor exposes the record for inspection in the console.WeakMap recordevery crossing, no DOM writesclassList.add onceonly what CSS needsDev accessorinspect via the console

Verification Steps

  • Count mutation records from an attribute watcher before and after; they should drop to the presentational changes only.
  • Record a trace while scrolling and confirm no Recalculate Style caused by observer callbacks.
  • Check that CSS still works for the presentational state (is-loaded fade-in).
  • Use the debug accessor to inspect state in development.
  • Remove elements and confirm state entries disappear with them (heap snapshot filtered by your state class, if named).

Common Mistakes to Avoid

  • Storing timers and timestamps in data-*. Every update becomes a DOM mutation.
  • Using a Map keyed by element. Removed elements stay alive; use a WeakMap.
  • Writing presentational state on every callback. Write it once, when it changes.
  • Hiding state tests behind DOM inspection. Export a read-only accessor instead.

FAQ

Are data attributes slow?

Reading them is fast. Writing them is a DOM mutation, which can queue MutationObserver records and invalidate styles if selectors reference them. In frequently firing observer callbacks those side effects add up.

Why not store state as expando properties on the element?

Properties like el.__seenAt avoid attribute mutations, but they pollute the element, can collide with other scripts, and are untyped. A module-private WeakMap gives the same performance with isolation and types.

Does a WeakMap keep elements alive?

No. Keys are held weakly, so an element that is otherwise unreachable is collected along with its WeakMap entry.

When should state go in an attribute?

When CSS or other code needs to react to it — a loaded class for a fade, a breakpoint attribute for styling. Write it only when it changes.

Can I iterate over a WeakMap to report all dwell times?

No, WeakMaps are not iterable. Keep a separate list of the elements you report on, or accumulate reports as intervals close, as shown above.

What about frameworks that already keep component state?

If each observed element belongs to a component instance, the component's own state is a natural home — as long as updating it does not trigger a re-render for bookkeeping that nothing renders. Refs in React, plain fields in Vue or Lit components, and module-level WeakMaps all avoid that.

How do I migrate existing code that reads data attributes?

Add the WeakMap alongside the attributes, switch readers to the map one by one, and remove the attribute writes last. Keep writing any attribute that CSS or another team's script genuinely depends on.

Is e.time comparable with performance.now()?

Yes. Both are DOMHighResTimeStamps relative to the document's time origin, so they can be subtracted directly.


↑ Back to WeakMap Observer Registries