Isotherm

What three clicks in 1000 milliseconds actually means

Isotherm calls it rage at three trusted clicks within 1000 ms that are each within 40 px of the newest one, resets after emitting, and draws each as a discrete marker.

A user clicks a button. Nothing happens. They click it again, harder, as if the extra force will help. Then twice more. Somewhere in that sequence they stopped believing the page and started punishing it, and the only artifact of that moment is a small pile of clicks in almost the same place.

That pile is what Isotherm calls a rage click. The whole detector is fifteen lines in sdk/src/collectors/clicks.ts, and every constant in it is a judgment call I had to defend to myself before shipping.

const RAGE_CLICKS = 3;
const RAGE_WINDOW = 1000;
const RAGE_RADIUS = 40;

At least three clicks in the last second, each within 40 pixels of the newest one. Nothing else. No velocity, no pressure, no machine learning.

Why three, why 1000, why 40

Three, not two. Double-click is a real gesture. It selects a word, it opens a row, it is the muscle memory of anyone who has used a file manager. If you fire on two clicks you will report rage on every table in every admin panel in the world, because people double-click table rows constantly and it works. Three is where accident ends. A user who clicks the same spot three times inside a second is not executing a gesture the platform defines; they are repeating themselves because the first attempt did not land.

1000 ms, not 400. The instinct is to make the window tight, on the theory that fast means angry. That is wrong in a specific way. Frustrated clicking is not metronomic. It has a pause in it: click, wait, did that do anything, click click. The gap between the first click and the second is the one where the user is still giving the page the benefit of the doubt, and it is the longest gap in the sequence. A 400 ms window catches the tail of the burst and misses the beginning of it, which means you need four or five clicks before three of them fall in the window, which means you have quietly raised your threshold without meaning to. One second is long enough to cover the hesitation and short enough that two unrelated clicks 900 ms apart in the same place are rare.

40 px, not "the same element". The obvious implementation is to count repeated clicks on the same Element reference or the same selector. I rejected that, and it is the most load-bearing rejection here. The single most common dead click in an admin panel is a <div> styled with cursor: pointer and no handler — and the second most common is a button whose label is a <span>, so alternating clicks land on button and on span and an element-identity check sees two separate targets. Worse: some frameworks re-render on click, so event.target on the second click is a different DOM node with the same appearance. Geometry does not care. A user hammering a 120 px button drifts by maybe 10 or 15 px between strikes; 40 px is generous enough to hold that drift and small enough that two adjacent toolbar buttons do not merge into one rage cluster.

The radius is measured from the newest click, and that has teeth

Here is the check itself:

const at = Date.now();
recent = recent.filter((r) => at - r.at < RAGE_WINDOW);
recent.push({ x: pageX, y: pageY, at });

const cluster = recent.filter((r) => Math.hypot(r.x - pageX, r.y - pageY) <= RAGE_RADIUS);
if (cluster.length >= RAGE_CLICKS) { … }

recent is a flat list, not a per-element map, and the cluster is rebuilt from scratch on every click: take everything still inside the window, keep the ones within 40 px of this click, count them. There is no centroid, no persistent cluster object, and — this is the part I got wrong in my own head for a while — no transitive chaining.

Work through it. Clicks at x=100, x=130, x=160, all inside one second. On the third click, recent holds all three. The filter measures each against x=160: 160 is 0 px away, 130 is 30 px away, 100 is 60 px away and gets dropped. cluster.length is 2. RAGE_CLICKS is 3. No rage event fires.

So the cluster does not walk. Each click is judged against the newest click only, and drift past 40 px silently discards the older end of the sequence. A user who slides 60 px across a wide button while hammering it gets no rage marker at all, and that is a miss — a real one, not a design choice I can dress up. The code comment above the constants says "inside RAGE_RADIUS px of each other", which describes a pairwise rule the code does not implement. That comment is mine and it is wrong.

Whether to fix it is a live question. The centroid version (keep a running mean, test each new click against it, allow the mean to move) would tolerate drift and would catch the 100/130/160 case. It also merges neighbouring controls more eagerly, because a centroid that drifts is a centroid that can drift onto the next button. For now I would rather miss the drifting rager than invent one at a coordinate nobody clicked. If you are seeing false negatives on large targets, raise RAGE_RADIUS before you reach for a centroid.

The reset is the whole point

emit('rage', cx, cy, event.clientX, event.clientY, el);
// Reset so one long hammering session reports one rage point, not one per click.
recent = [];

Without that recent = [], a user who clicks eleven times in the same 20 px produces nine rage events — one for the third click and one for every click after it, since the window never empties while they are still hammering. The heatmap would then show a rage score of nine at that pixel, and the number would mean "how long this person kept going", not "how many people got stuck here".

Those are different questions, and only one of them is actionable. Nine rage events at one coordinate could be nine different users who each gave up after three clicks, or one furious user who clicked eleven times. If you cannot tell those apart, the number is decoration. After the reset, one hammering session emits exactly one rage point, and the count on the map is a count of incidents. Six rage markers means six times someone got stuck. That is a number you can put in a ticket.

The cost is real and I will name it: intensity is now unrecoverable. If a user clicks 40 times, the rage event looks identical to a user who clicked 3 times and walked away. I do not think intensity is worth much — three clicks already tells you the interaction failed, and 40 tells you the same thing louder — but if you disagree, the fix is to keep the cluster and emit an occurrence count, not to remove the reset.

The emitted point is the mean of the cluster (cx, cy), not the click that triggered it. With a 40 px radius the two are never far apart, but the mean sits in the middle of the strikes rather than on whichever one happened to be last.

Why rage clicks are markers and heat is heat

Every normal click also goes into the heatmap, and heat is the right encoding for it: thousands of points, individually meaningless, where the shape of the distribution is the finding. Nobody cares about click #4,182.

Rage clicks are the opposite. There are six of them on the page. Each one is a specific person who got stuck at a specific control, and Isotherm stores each as a row in events with a selector, a tag name, and the offset inside the element's box — enough to say which button, not just which region.

So they render as discrete markers, not as a second heat layer. If you gaussian-blur six points you get a soft orange smudge, and a smudge answers no question you actually have. It does not tell you there were exactly six. It does not tell you two of them were on the "Export" button and four on a <div> that only looks like a tab. It converts six countable facts into one vague impression, and it does it in the name of visual consistency with a layer that is solving a completely different problem. Blur is a summarization technique. You summarize things you have too many of.

The tell is this: if a rage layer looks good on your demo page, your threshold is too low.

What a rage click does not tell you

Here is the assumption I keep having to correct, including in my own head: a rage click does not mean the element is broken.

It means the user expected a response and did not perceive one within their patience. That covers a broken handler, yes. It also covers a handler that works perfectly but takes 1.5 seconds with no spinner. It covers a button that is correctly disabled for a permission the user does not have, with no tooltip saying so. It covers a form that did validate, and did show the error, 900 px below the fold. In every one of those, the code is doing what it was told. The feedback is what failed.

That is why Isotherm treats rage and dead clicks as two separate signals rather than folding them into one "frustration" score. The dead-click detector watches the DOM, the URL and the scroll position for DEAD_TIMEOUT (800 ms) after a click and reports the click only if nothing changed. A click that is both rage and dead is a broken control. A click that is rage but not dead is a slow or invisible one — the page responded, the user just did not notice. Same marker on a naive tool, completely different bug.

Three known holes, since you will hit them. The drift case above: hammering that wanders more than 40 px from its newest point never reaches three, so the loudest users on the widest buttons can be the ones you never see. event.isTrusted is checked, so a script dispatching synthetic clicks in a loop cannot manufacture rage — but a user with a trackpad that double-fires on a physical click can, and there is no way to distinguish that from anger at the DOM level. And the 40 px radius is in page pixels, not element-relative units, so on a mobile layout where controls are 44 px tall and packed, two genuinely different buttons can share a cluster. The marker will land between them. Look at the selector on the row, not the pixel.

Related