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
MutationObserverwatching 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.
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
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
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.
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
WeakMapitself 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.
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-loadedfade-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
Mapkeyed by element. Removed elements stay alive; use aWeakMap. - 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.
Related
- WeakMap vs Map for Observer Target Tracking — why the key must be weak
- Tracking Section Read Time with IntersectionObserver — dwell timing in practice
- Avoiding getComputedStyle in Hot Observer Paths — the style side of the same cost
↑ Back to WeakMap Observer Registries