How to Track Button Clicks and Form Submissions with JavaScript
Learn how to track button clicks and form submissions with vanilla JavaScript: event delegation, sendBeacon, keepalive and SPA page views, with full code.
Imagine you ship an “Add to cart” button on Acme Shop, wire up a click handler, and the dashboard shows healthy click counts. The next sprint a designer swaps the button for a component that renders after page load. The count drops to zero and nobody notices for days. This is the typical failure when you track button clicks one listener at a time.
Client-side capture looks trivial, yet it hides a dozen edge cases. Clicks arrive from keyboards as well as mice. Forms submit through Enter, through scripts and through browser autofill. A page can unload before your request leaves, and a single-page app can change the URL without loading anything.
This article gives you one small, complete tracker in vanilla JavaScript that handles all of this. You will use event delegation, data attributes, navigator.sendBeacon() with a fetch() fallback, a safe form handler and single-page navigation. It builds on how web analytics works, where you saw why a request is the unit of tracking.
The server that receives these events is a separate topic. For now, assume a collector exists at POST https://analytics.acme-shop.example/v1/events, which you will build later.
The Event Contract You Are Producing
Before writing listeners, decide what each listener must output. Every event in this series uses one shape, with snake_case names and ISO 8601 UTC timestamps. The client fills these fields:
{
"event_id": "UUID v4, generated client-side for idempotency",
"event_name": "button_click",
"occurred_at": "2025-10-02T09:15:30.123Z",
"anonymous_id": "random id for the browser",
"user_id": null,
"session_id": "random id, 30 minute inactivity window",
"page_url": "https://acme-shop.example/products/42",
"referrer": "https://www.google.com/",
"user_agent": "raw string",
"properties": { "label": "add-to-cart", "tag": "button" }
}
The browser generates event_id. That choice matters because retries then become safe: the server can discard a repeated event_id. The retry logic belongs to async event tracking, so this article only generates the id.
Only six event names are allowed: page_view, button_click, form_submit, signup_completed, add_to_cart and purchase_completed. A fixed list stops typos like buttonClick from fragmenting your reports.
Why Event Delegation Beats Per-Element Listeners
The naive approach attaches a listener to every button. It looks fine in a demo and breaks in production.
// WRONG: breaks for buttons added after this runs
document.querySelectorAll('.add-to-cart').forEach((btn) => {
btn.addEventListener('click', () => {
track('add_to_cart', { product_id: btn.dataset.productId });
});
});
This code finds buttons once, at the moment it runs. Any button rendered later, such as a product in an infinite-scroll list or a modal, never gets a listener. Also, a CSS class like .add-to-cart is a styling hook, and a redesign renames it without warning.
Event delegation fixes both problems. Browsers bubble a click from the clicked element up through all its ancestors. Therefore one listener on document sees every click, including clicks on elements that do not exist yet. You then ask which tracked element contains the target.
// RIGHT: one listener, works for current and future elements
document.addEventListener('click', (event) => {
const el = event.target instanceof Element
? event.target.closest('[data-track]')
: null;
if (!el) return;
track(el.dataset.track, { label: el.dataset.trackLabel ?? null });
}, { capture: true });
The delta is the selector. Instead of a class that designers own, you use a data-track attribute that exists only for analytics. The markup becomes self-documenting:
<button data-track="add_to_cart" data-track-label="product-card" data-product-id="42">
Add to cart
</button>
Why capture: true
The { capture: true } option makes the listener run during the capture phase, before the event reaches the target. This matters because application code often calls event.stopPropagation(), which hides the click from any bubbling listener on document. In my experience building event pipelines, a team silently lost every click on their modal buttons because the modal library stopped propagation. A capture listener sees the event first and avoids that class of bug.
The trade-off is that you now record clicks that application code later cancels. A click on a button that calls preventDefault() still counts as a click, which is usually what you want. If you need to know whether the action succeeded, track the outcome separately, for example with signup_completed.
Closest, not target
Use closest(), never event.target alone. If a button contains an icon, the target is the svg or the text span, not the button. A handler that reads event.target.dataset.track then returns undefined whenever the user clicks the icon. closest() walks up the tree and finds the marked ancestor.
Edge Cases When You Track Button Clicks
A click is not always a mouse click. Knowing what the browser fires helps you read the data correctly.
- Keyboard activation: pressing Enter or Space on a focused button fires
click. In that caseevent.detailis 0, whereas a real pointer click reports 1 or higher. Record it as a property so you can separate the two. - Implicit form submission: pressing Enter inside a text field makes the browser fire a synthetic click on the form’s default submit button. Your report then shows a button click the user never made.
- Disabled buttons: browsers do not fire click on a disabled button, so you cannot count blocked attempts this way.
- Middle and right clicks: the
clickevent covers the primary button only. A middle click on a link firesauxclick, so “open in new tab” clicks are invisible unless you listen for it. - Double clicks: an impatient user fires two clicks in 200 milliseconds. Without protection, you record two add-to-cart events for one intent.
The implicit submission case caused me real trouble once. We counted button_click on a checkout button and form_submit on the surrounding form, and the funnel showed more button clicks than visitors who had reached the page. The cause was customers pressing Enter in the postcode field. The fix was to record keyboard: event.detail === 0 and treat each event type as a separate step, never as the same step counted twice.
Sending Events Without Losing Them
Capturing the click is half the work. Sending it is the other half, and this is where most data disappears. Consider a link that navigates to the next page. The browser cancels in-flight requests when the page unloads, so an ordinary fetch() started on click may never reach your server.
// WRONG: the request is often cancelled when the page navigates
link.addEventListener('click', () => {
fetch(ENDPOINT, { method: 'POST', body: JSON.stringify(event) });
});
Two APIs solve this. navigator.sendBeacon() hands the request to the browser, which sends it in the background even after the page is gone. The MDN reference for sendBeacon documents this behavior and its return value: true if the browser queued the data, false otherwise. The second option is fetch() with keepalive: true, described in the MDN page on the keepalive option. It lets the request outlive the page too, and unlike a beacon it allows custom headers.
Both mechanisms share a size limit. MDN states the total queued data is limited to 64 KiB, so a single click event is far below it. The limit becomes relevant only when you batch many events, which is why batching belongs with the reliability work in article 8.
function send(event) {
const body = JSON.stringify(event);
const blob = new Blob([body], { type: 'application/json' });
// Preferred: queued by the browser, survives page unload.
if (navigator.sendBeacon?.(ENDPOINT, blob)) return;
// Fallback: keepalive keeps the request alive after unload.
fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: true,
}).catch(() => {
// Analytics must never break the page. Drop the event silently.
});
}
One caveat applies. A beacon with a Content-Type of application/json triggers a CORS preflight request, because that type is not a “simple” one. Beacons also send credentials, so a wildcard Access-Control-Allow-Origin fails; check the current Fetch and MDN CORS documentation for the exact rules. Your collector must therefore answer OPTIONS with the right headers and echo back only the origins you trust. The Express collector covers the allow-list. Some trackers sidestep the preflight by sending the JSON as text/plain, which is simpler but makes server-side content-type checks weaker.
A Complete Tracker You Can Paste
The following file is the full browser tracker for the series. It is runnable as is: save it as tracker.js, load it with <script type="module" src="/tracker.js"></script> and point ENDPOINT at your collector. Until the collector exists, open the browser Network tab to watch the requests.
// tracker.js - Acme Shop browser tracker (vanilla ES2022)
const ENDPOINT = 'https://analytics.acme-shop.example/v1/events';
const SESSION_MS = 30 * 60 * 1000;
const ALLOWED = new Set([
'page_view', 'button_click', 'form_submit',
'signup_completed', 'add_to_cart', 'purchase_completed',
]);
function readStorage(key) {
try { return localStorage.getItem(key); } catch { return null; }
}
function writeStorage(key, value) {
try { localStorage.setItem(key, value); } catch { /* storage blocked */ }
}
function readCookie(name) {
const match = document.cookie.split('; ').find((c) => c.startsWith(name + '='));
return match ? match.slice(name.length + 1) : null;
}
function getAnonymousId() {
const value = readStorage('anonymous_id') ?? readCookie('anonymous_id') ?? crypto.randomUUID();
writeStorage('anonymous_id', value);
// Mirror to a first-party cookie so your web server logs can see the id.
document.cookie =
`anonymous_id=${value}; Max-Age=31536000; Path=/; Secure; SameSite=Lax`;
return value;
}
function getSessionId() {
const now = Date.now();
let state = null;
try { state = JSON.parse(readStorage('session_state')); } catch { /* ignore */ }
if (!state || now - state.last > SESSION_MS) {
state = { session_id: crypto.randomUUID(), last: now };
}
state.last = now;
writeStorage('session_state', JSON.stringify(state));
return state.session_id;
}
let pageReferrer = document.referrer || null;
let lastUrl = null;
export function track(eventName, properties = {}) {
if (!ALLOWED.has(eventName)) return;
send({
event_id: crypto.randomUUID(),
event_name: eventName,
occurred_at: new Date().toISOString(),
anonymous_id: getAnonymousId(),
user_id: window.acmeUserId ?? null,
session_id: getSessionId(),
page_url: location.href,
referrer: pageReferrer,
user_agent: navigator.userAgent,
properties,
});
}
function send(event) {
const body = JSON.stringify(event);
const blob = new Blob([body], { type: 'application/json' });
if (navigator.sendBeacon?.(ENDPOINT, blob)) return;
fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: true,
}).catch(() => {});
}
// Clicks: one delegated listener, capture phase.
document.addEventListener('click', (event) => {
const el = event.target instanceof Element
? event.target.closest('[data-track]')
: null;
if (!el || el.disabled) return;
track(el.dataset.track, {
label: el.dataset.trackLabel ?? null,
product_id: el.dataset.productId ?? null,
tag: el.tagName.toLowerCase(),
keyboard: event.detail === 0,
});
}, { capture: true });
// Forms: names only, never values.
document.addEventListener('submit', (event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement) || !form.dataset.trackForm) return;
if (form.dataset.trackBusy === 'true') return; // ignore double submits
form.dataset.trackBusy = 'true';
setTimeout(() => { delete form.dataset.trackBusy; }, 2000);
track('form_submit', {
form_name: form.dataset.trackForm,
field_names: [...form.elements].map((f) => f.name).filter(Boolean),
});
}, { capture: true });
// Single-page navigation.
function pageView() {
if (location.href === lastUrl) return;
if (lastUrl) pageReferrer = lastUrl; // the previous in-app page
lastUrl = location.href;
track('page_view', { title: document.title });
}
const originalPushState = history.pushState;
history.pushState = function (...args) {
const result = originalPushState.apply(this, args);
setTimeout(pageView, 0); // let the router update document.title first
return result;
};
window.addEventListener('popstate', () => setTimeout(pageView, 0));
pageView(); // the initial page load
The file has no dependencies. The tracker keeps anonymous_id in localStorage and mirrors it to a cookie on the shop’s own host, so the web server can log the same id, which the next article uses to join sources. Safari limits how long it keeps script-written storage, so treat the id as short-lived there. In my test run, the tracker emitted the expected page_view, click and form events, and a repeated pushState to the same URL produced one page view. It also demonstrates two defensive habits. Storage access is wrapped in try because browsers can throw a security error when a user blocks site data. And every failure path drops the event rather than showing an error, because analytics must never break checkout.
Tracking Form Submissions Correctly
Forms look simple, yet they produce the messiest data. The MDN page on the submit event lists the rules you need. The event fires when a user submits through a submit button or the Enter key, and it bubbles, so one delegated listener works.
What the submit event does not do
- Invalid forms never fire it. When HTML validation fails, the browser shows an error and no
submitevent occurs. Yourform_submitcount therefore means “passed client validation”, not “user tried”. Listen forinvalidseparately if you need friction data. - Calling form.submit() skips it. Programmatic
submit()bypasses both validation and the event. Frameworks that use it will silently evade your tracker.requestSubmit()fires the event correctly. - It fires before the server responds. A
form_submitevent means the attempt left the browser. It does not mean the signup worked.
That last point drives a design rule: separate attempt from outcome. Track form_submit in the browser when the user submits. Emit signup_completed only after the server confirms the account exists, ideally from the server itself. Mixing the two makes your conversion rate look better than it is, because failed attempts count as successes.
Mark each form you want counted with a name:
<form data-track-form="newsletter" action="/subscribe" method="post">
<input name="email" type="email" required>
<button>Subscribe</button>
</form>
Privacy: never read the values
The wrong way to track a form is to serialize it.
// WRONG: sends emails, passwords and card digits to analytics
track('form_submit', Object.fromEntries(new FormData(form)));
// RIGHT: field names only
track('form_submit', {
form_name: form.dataset.trackForm,
field_names: [...form.elements].map((f) => f.name).filter(Boolean),
});
The first version turns your analytics table into a store of personal data and, possibly, credentials. The second keeps the signal you need (which form, which fields) and nothing else. The same rule applies to button labels: capture a stable label you choose, not el.textContent, because button text can contain a user’s name (“Welcome back, Dana”).
Tracking Single-Page App Navigation
In a traditional site, every navigation loads a page and your script runs again, so a page_view fires naturally. A single-page app breaks that. The router changes the URL with history.pushState() and swaps content, and the browser never reloads. A tracker that only fires on load counts the first page and nothing else.
Browsers fire popstate for back and forward navigation, but pushState itself fires no event. Therefore the tracker above wraps pushState and calls pageView() after the router finishes. The setTimeout with a zero delay lets the framework update document.title first. Without it, every view reports the title of the previous page.
The code deliberately skips replaceState. Routers often use it to update query parameters, such as a filter on a product list, which should not count as a new page view. If your router uses replaceState for real navigations, wrap it too and compare URLs by pathname. The browser Navigation API may eventually replace this patching, but its support varies, so check the MDN compatibility table before depending on it.
Deduplication
Notice the lastUrl guard. Some routers call pushState twice for one navigation or push the same URL again. Without the guard, a single click on a menu link yields two page views and inflates traffic. The guard is cheap and removes a whole category of double counting.
Choosing a Tracking Approach
| Approach | Setup effort | Survives dynamic DOM | Main risk |
|---|---|---|---|
| Per-element listeners | Low | No | Silent breakage after redesigns |
| Delegated listener with data attributes | Low | Yes | Needs a markup convention |
| Tag manager with visual selectors | Low for marketers | Partly | CSS selectors break and add page weight |
| Framework-level hooks (route and component events) | Medium | Yes | Couples analytics to one framework |
| Server-side events | Higher | Not applicable | Misses UI interactions |
How Real Systems Do This
The delegated approach is the standard in production tools. PostHog’s autocapture records clicks and form submissions without per-element code, and Plausible’s tagged events let you mark elements with a CSS class instead of writing handlers. Check each product’s current documentation for the exact mechanics. Snowplow’s JavaScript tracker offers link click and form tracking plugins that attach listeners to forms and fields. Google Tag Manager’s click and form triggers work on the same principle, with a configuration UI on top.
Two lessons repeat across them. First, autocapture trades precision for coverage: you get every click without code, but event names depend on the DOM, and renames break your history. Second, the stable alternative is exactly what you built: explicit, hand-named events attached through data attributes. For a store with a few dozen meaningful actions, explicit names are worth the effort.
Server-side data fills the gaps this client code leaves. Requests that never reach JavaScript still show up in access logs, which the next article turns into an event source.
Decision Framework
- Is the action a revenue or account event, such as a purchase? Emit it from the server, and use the browser event only as a funnel signal.
- Does the element exist at page load? If it may appear later, use delegation.
- Can the page navigate away from the action? Use
sendBeaconwith akeepalivefetch fallback. - Does the form carry personal data? Capture form and field names only.
- Is the app a single-page app? Hook
pushStateandpopstate, and deduplicate by URL. - Do many teams add events? Standardize on
data-trackattributes and a fixed list of event names.
When NOT to Use This
- The event is critical to money or compliance. Client code can be blocked, edited or faked by any visitor. Record purchases, refunds and consent on the server.
- You only need aggregate page traffic. Server logs give you page views with no JavaScript at all, and an ad blocker cannot hide them.
- Marketing owns the tracking and changes it weekly. A tag manager or a hosted product with autocapture lets non-developers add events without deploys. Buy instead of build if engineering time is the scarce resource.
Common Mistakes
- Attaching listeners to buttons directly. Dynamically rendered elements lose tracking, and nobody notices until a report goes flat.
- Using an ordinary fetch on click. Navigation cancels the request, so link clicks vanish from the data.
- Serializing the whole form. You store passwords and emails in an analytics table and create a privacy incident.
- Counting submit-button clicks and form submits as one step. Enter-key submissions inflate the funnel with clicks nobody made.
- Ignoring SPA navigation. Only the landing page registers, so every session looks like one page long.
- Reading
textContentfor labels. Personalized button text leaks names into event properties.
Key Takeaways
- Use one delegated, capture-phase listener on
documentinstead of per-element handlers. - Mark elements with
data-trackattributes so analytics does not depend on CSS classes. - Send with
sendBeaconand fall back tofetchwithkeepalive: true, and stay below the 64 KiB queue limit. - Record
event.detail === 0to separate keyboard activations from pointer clicks. - Track form names and field names, never values, and track outcomes from the server.
- Wrap
history.pushStateand listen topopstatefor single-page apps, and guard against duplicate URLs. - Generate
event_idin the browser so the server can discard retries later.
FAQ
How do I track button clicks with JavaScript?
Add one click listener to the document, use event.target.closest() to find an element with a data-track attribute, and send an event with navigator.sendBeacon(). This approach works for buttons added after page load and does not depend on CSS classes.
How do I track form submissions without collecting personal data?
Listen for the submit event on the document and record only the form name and the field names. Never read field values. Emit the success event, such as signup_completed, from your server after the account is created.
Should I use sendBeacon or fetch for analytics events?
Use sendBeacon first, because the browser queues it and sends it even when the page unloads. Fall back to fetch with keepalive: true when a beacon is unavailable or when you need custom headers. Both share a 64 KiB queue limit.
How do I track page views in a single-page app?
Wrap history.pushState to emit a page_view after each route change, and listen for popstate to catch back and forward navigation. Deduplicate by URL so that a router that pushes twice does not double count.
Conclusion
Reliable client-side tracking comes down to three choices: delegate one listener, send with a method that survives unload, and never read what users typed. With those in place, the browser reports what happened, and your server decides what to trust.
Rule of thumb: track intent in the browser, track outcomes on the server, and never track what the user typed.
Last updated on 9 October 2026.
