A dwell map is not a mouse trail
Sampling the pointer on a 100 ms timer and counting grid cells turns a minute of mousemove from ~7,000 events into a few hundred integers — and produces a better signal than the trail would have.
The first version of Isotherm's movement collector was four lines long and it was the worst code in the repo:
document.addEventListener('mousemove', (e) => {
transport.push({ t: 'move', x: e.pageX, y: e.pageY, ts: Date.now() });
});
mousemove is dispatched at whatever rate the browser feels like, which on a 120 Hz display with a modern pointer means somewhere north of a hundred events per second while the mouse is actually moving. Each of those is roughly 40 bytes of JSON. A visitor who spends five minutes in an admin panel and moves the mouse for half of it generates on the order of 18,000 events, and the collect endpoint eats about 700 KB from one person. Multiply by the traffic on a page that a support team lives in all day. The database table would have dwarfed every other table in the schema, and no query would ever have read a single one of those rows back individually.
The obvious fix is to throttle the listener. It's the wrong fix, and understanding why is the whole point of this post.
Throttling is still event-driven
A throttle wrapped around the handler still means the browser's event rate is an input to your system. It fires, you check a timestamp, you drop it. You've reduced the payload but you have not decoupled anything: the sampling of the pointer is still whatever the event stream hands you. Move the mouse slowly and you get sparse readings. Move it fast and the throttle discards most of them, so a fast sweep across the page contributes about as much data as a slow one — except during the frames where the throttle window happens to open, which is a function of when the user started moving. The recorded positions are biased by pointer velocity in a way that is hard to reason about and impossible to fix downstream.
Worse, the thing you get out of it is a trail. And a trail is not what you want.
The claim behind every mouse heatmap is that the cursor tells you something about attention. That claim is only defensible for a stationary cursor. A cursor in transit is telling you about the geometry between two points, not about the content it passes over. Somebody dragging from the sidebar to the save button sweeps across a paragraph they never read. If you record the trail, that paragraph gets heat. If you record where the pointer rested, it doesn't.
So the collector doesn't listen to mousemove for data. It listens to it for state:
function onMove(event: MouseEvent): void {
if (!event.isTrusted) return;
pointerX = event.pageX;
pointerY = event.pageY;
moved = true;
inside = true;
}
That's the entire handler. It writes four variables. There is no allocation, no timestamp comparison, no queue. It can fire at 500 Hz and cost nothing that matters.
The actual reading happens on a timer, at moveInterval, which defaults to 100 ms:
function sample(): void {
if (!moved || !inside) return;
const gx = Math.floor(pointerX / config.gridSize);
const gy = Math.floor(pointerY / config.gridSize);
if (gx < 0 || gy < 0) return;
const key = `${gx},${gy}`;
cells.set(key, (cells.get(key) ?? 0) + 1);
}
Ten samples a second, regardless of what the browser is doing. Fast movement and slow movement now cost exactly the same, and a cursor that has stopped moving entirely still accrues one count per tick — which is the case we actually care about. The moved flag exists so we don't record a phantom hover at (0, 0) for a page nobody has touched. The gx < 0 || gy < 0 guard drops samples with a negative grid key; pageX and pageY can go negative, and a "-1,-3" key would sit in the map as a cell the renderer can't place. I have not chased down every browser situation that produces one. The guard is cheap and the alternative is a poisoned map, so it stays.
inside is the part worth reading carefully, because both listeners are registered in capture phase:
document.addEventListener('mousemove', onMove, { passive: true, capture: true });
document.addEventListener('mouseleave', onLeave, { passive: true, capture: true });
mouseleave doesn't bubble, which is exactly why capture is needed — a bubbling-phase listener on document would never see a mouseleave fired at a <button>. With capture: true it sees all of them. So inside is cleared not only when the pointer leaves the window, but every time it leaves any element on the page. The next mousemove sets it straight back to true, and mousemove is dispatched far more often than mouseleave, so in practice a moving pointer stays inside and a pointer that has genuinely left the document stops accruing counts. It works, but it works for a slightly grubbier reason than "the pointer left the window", and if you ever raise moveInterval to something long enough to fall between a mouseleave and the next mousemove, you will silently drop samples. passive: true is there for the obvious reason: neither handler ever calls preventDefault, and telling the browser so keeps them off the critical path of scrolling.
Then count, instead of store
cells is a Map<string, number>. The key is "gx,gy". That's quantization, and it's the second half of the fix: gridSize is 16 by default, so every position within a 16×16 pixel square is the same position.
Losing that precision costs nothing, because the renderer throws it away anyway. The dashboard draws a radial-gradient blob per cell with a radius of Math.max(18, gs * 2.2) — 35 px at the default grid. Sub-pixel accuracy inside a 35-pixel-wide blur is fiction. The only question is whether you pay to ship the fiction across the wire and store it. We don't.
What this buys, concretely: thirty seconds of a user reading a paragraph with the cursor parked next to it is 300 samples that land in maybe two or three cells. Those become two or three entries in the map, drained every flushInterval (5 s) as packed integer triples:
const packed: Array<[number, number, number]> = [];
for (const [key, count] of cells) {
const comma = key.indexOf(',');
packed.push([Number(key.slice(0, comma)), Number(key.slice(comma + 1)), count]);
}
cells.clear();
[74, 210, 300]. Twelve bytes of JSON for what naive recording would have shipped as roughly 3,600 events. The counts are mergeable: the server does
ON CONFLICT (...) DO UPDATE SET count = move_cells.count + excluded.count
which means a batch can land in any order relative to the others and the totals come out right. It also means the write is emphatically not idempotent. Replay a batch — a retry after a timeout that actually succeeded, a duplicated beacon on unload — and you don't get a no-op, you get inflated dwell counts. Nothing in the collector deduplicates, so this is a real failure mode and not a theoretical one; it just fails in the direction of "that cell looks warmer than it was" rather than "the table has doubled".
The upper bound is mostly reassuring. A visitor can only touch so many distinct cells: continuous movement for a full minute is 600 samples, and even in the pathological case where every one lands in a different cell, that's 600 triples. Real sessions collapse to a fraction of that, because real cursors don't tour the page uniformly — they park. The server does not take that on faith. EventIngestor slices every batch at MAX_MOVE_CELLS = 4000 and drops the rest on the floor, silently. That cap is not defensive theatre against a hostile client so much as it is a cap on unbounded writes, but the silence is a wart: a legitimate visitor on a 4K display sweeping a very long page for several minutes can, in principle, produce a batch that gets truncated, and nothing tells you it happened. If you tune gridSize down, you move toward that ceiling quadratically.
The render has its own scaling problem
Dwell counts are savagely long-tailed. One cell where somebody left the cursor while reading a support ticket can easily be a hundred times the median cell on the page. Feed that to a linear intensity scale and the map renders as one hot pixel on an empty sheet: count / max for the median cell is 0.01, which paints as nothing.
blob(s.ctx, gx * gs + gs / 2, y, r, Math.min(1, Math.sqrt(count / max)) * 0.5);
sqrt pulls the median cell up to 0.1 and the map becomes readable without lying about the ordering — the hot cell is still the hottest. I tried a log scale first, which compresses the tail so hard that the difference between "glanced at" and "stared at for a minute" disappears. sqrt was the compromise that survived looking at real pages.
The blobs are painted as translucent black into a scratch canvas, and only then is each pixel's alpha used as an index into the colour ramp:
const a = px[i + 3];
if (a === 0) continue;
const o = a * 4;
px[i] = RAMP[o];
px[i + 1] = RAMP[o + 1];
px[i + 2] = RAMP[o + 2];
px[i + 3] = Math.min(maxAlpha, a);
This two-pass thing is what makes overlapping cells compound. Paint them already-coloured and two adjacent warm blobs stay warm — you've drawn a scatter plot with big dots. Accumulate in the alpha channel first and density drives hue, which is what a heatmap is supposed to mean.
Where this is wrong
Dwell correlates with reading. It does not equal reading. Plenty of people read with the cursor abandoned in the corner where they last clicked, and that corner will glow while the paragraph they actually read stays cold.
Touch is worse, and worse in a way I didn't anticipate. There is no coarse-pointer guard anywhere in the SDK — index.ts wires the collector on if (config.move) and nothing else, and onMove only filters on event.isTrusted. Mobile browsers dispatch a trusted synthetic mousemove when you tap. That sets moved = true; inside = true, and since inside is only ever cleared by a mouseleave that a touch device has no reason to send, the 100 ms sampler then increments the tapped cell ten times a second for the rest of the page view. Rows land in move_cells keyed by device = 'mobile', the dashboard's Mobile breakpoint happily renders them under the "Mouse dwell" layer, and what you are looking at is not dwell. It's a monument to wherever the user's thumb last landed, weighted by how long they stayed on the page. That layer should be suppressed on coarse pointers and currently isn't. It's the next thing I'm fixing.
A trail-based recorder is genuinely better than this for one thing: debugging a specific user's confusion, where the path is the story. If that's your question, you want session replay, not a heatmap, and you should go install one.
And the 16 px grid is a real constraint, not a free parameter. On a 1440-wide layout that's 90 columns, which cannot distinguish two icon buttons sitting 12 px apart. data-grid exists so you can drop it, but halving the grid quadruples the worst-case cell count, and at some point you're back to shipping the thing you were trying not to ship — or hitting the 4,000-cell cap and losing data without being told.
For the actual question this tool exists to answer — which parts of this admin screen do people stop on, and which parts do they sweep past — sampling and counting is not a compression trick applied to the real data. It is the data. The trail was never the signal.
-
A dead click is about belief, not wiring
You cannot tell from the DOM whether an element has a handler, so Isotherm detects dead clicks by watching what the user believed and whether the page blinked.
-
Detecting a redesign without being told: DOM structure fingerprinting
The SDK hashes the DOM skeleton with runs of identical siblings folded to `tag*`, so a new column forks the heatmap version but a 25th table row does not.
-
How a heatmap is actually rendered (and the mistake everyone makes first)
Painting each point in its final colour gives you a scatter plot with big dots; density has to compound in the alpha channel before anything gets a hue.