
How to Detect 'Rage Clicks' Without a Heavy Third-Party Analytics SDK
Stop bloatware from killing your performance and use a lightweight native heuristic to pinpoint exactly where your users are losing their patience.
Your heavyweight session-recording SDK is likely causing more rage clicks than it’s actually recording.
It’s a bitter irony: we install massive, third-party analytics scripts to "understand the user experience," but the performance hit from those very scripts—the main-thread blocking, the layout shifts, the bloated bundle size—is often what pushes users over the edge. You don't need a 50KB mystery-meat obfuscated library to tell you that a user is spamming a button that isn't working.
You can build a surgical, high-performance rage-click detector in about 40 lines of vanilla JavaScript.
The Anatomy of a Rage Click
What is a "rage click," anyway? It’s a heuristic. From a browser's perspective, it’s just a series of click events that happen in rapid succession within a confined physical area.
To build our own detector, we need to track three variables:
1. Frequency: How many clicks happened?
2. Temporal Proximity: How much time passed between the first and last click?
3. Spatial Proximity: Did they click the same spot, or are they just clicking different things quickly?
A Basic Implementation
Let’s start with a "naive" version. This tracks three clicks within a 500ms window. It’s simple, but it’s a solid foundation.
let clickCount = 0;
let lastClickTime = 0;
const RAGE_THRESHOLD = 3;
const TIME_LIMIT = 500; // ms
document.addEventListener('click', (event) => {
const now = Date.now();
if (now - lastClickTime < TIME_LIMIT) {
clickCount++;
} else {
clickCount = 1;
}
lastClickTime = now;
if (clickCount >= RAGE_THRESHOLD) {
console.warn('Rage click detected at:', event.target);
// Reset after trigger so we don't spam the logs
clickCount = 0;
}
});Why the "Naive" Version Fails
The code above has a glaring flaw: it doesn't care *where* the user clicks. If a user is rapidly clicking through a gallery of images or a list of "Delete" buttons, that’s not rage—that’s just efficiency.
A true rage click usually happens because the user expects something to happen and nothing does. They stay focused on one element. We need to measure the distance between clicks.
The "Smarter" Heuristic
We’ll use the Euclidean distance formula to ensure the clicks are clustered together. If the clicks are more than 30 pixels apart, we’ll assume they aren't "raging" at a single broken button.
const config = {
threshold: 4, // clicks
interval: 1000, // ms
radius: 30, // pixels
};
let clicks = [];
document.addEventListener('click', (e) => {
const now = Date.now();
const { clientX: x, clientY: y } = e;
// Filter out clicks that are too old
clicks = clicks.filter(click => now - click.time < config.interval);
clicks.push({ x, y, time: now });
if (clicks.length >= config.threshold) {
// Check if all clicks in the array are within the radius of the first click
const isClustered = clicks.every(click => {
const distance = Math.sqrt(
Math.pow(click.x - clicks[0].x, 2) +
Math.pow(click.y - clicks[0].y, 2)
);
return distance <= config.radius;
});
if (isClustered) {
handleRage(e, clicks.length);
clicks = []; // Clear the buffer
}
}
});
function handleRage(event, count) {
const target = event.target;
const elementDesc = target.tagName.toLowerCase() +
(target.id ? `#${target.id}` : '') +
(target.className ? `.${target.className.split(' ').join('.')}` : '');
console.log(`User is losing it on ${elementDesc}. ${count} clicks detected.`);
// Send to your lightweight logging endpoint
// trackEvent('rage_click', { element: elementDesc });
}Making it Production Ready
If you drop this into a high-traffic app, you’ll realize quickly that not all clicks are created equal. Here are a few "gotchas" I've run into:
1. Input Elements: People often triple-click to select text in an input or textarea. You might want to ignore those.
2. Buttons with legitimate fast clicks: Think of a "Quantity Increment" button on an e-commerce site.
3. The "Ghost" Click: On mobile, click events can behave differently. Sometimes pointerdown is a better metric if you want to be super responsive.
Here’s how we might ignore specific elements:
if (['input', 'textarea', 'select'].includes(target.tagName.toLowerCase())) {
return;
}
// Or check for a data attribute to opt-out specific UI components
if (target.closest('[data-ignore-rage]')) {
return;
}How to Ship Data Without Breaking the Bank
The whole point of this exercise was to avoid heavy SDKs. Don't ruin it by using a massive POST request that blocks the UI thread. Use navigator.sendBeacon() or a fetch with keepalive: true. These allow the browser to send the data asynchronously, even if the user navigates away or closes the tab.
function trackEvent(name, metadata) {
const url = '/api/analytics/events';
const body = JSON.stringify({
event: name,
timestamp: Date.now(),
url: window.location.href,
...metadata
});
if (navigator.sendBeacon) {
navigator.sendBeacon(url, body);
} else {
fetch(url, { method: 'POST', body, keepalive: true });
}
}The Verdict
You don't need a massive dashboard and a $200/month subscription to find your app's pain points. Often, the most valuable insights come from the simplest heuristics.
By implementing your own rage-click detection, you get:
* Zero impact on your Core Web Vitals.
* Total control over what defines "frustration" in your specific UI.
* Privacy compliance, because you aren't recording the user's entire screen and sending it to a third party.
Sometimes, the best code is the code you *don't* install from NPM. Give this a shot, look at the logs after a week, and I guarantee you'll find a button you thought was working perfectly—but actually wasn't.


