Finding where people quit a multi-step form, without cookies or recording what they typed
A visitor opens our gift-deed form, answers a couple of questions, and closes the tab. Our analytics told us one thing: "Started form: Gåvobrev". It did not say which step they were on or whether they got stuck on an err
A visitor opens our gift-deed form, answers a couple of questions, and closes the tab. Our analytics told us one thing: "Started form: Gåvobrev". It did not say which step they were on or whether they got stuck on an error. We could see that people left, but not where they left.
The obvious fix is a session recorder or a product-analytics SDK. We did not want either. The forms collect names, personal identity numbers, and who gets what when someone dies. We do not want that data in a third-party replay tool, and we do not want a cookie banner on a page where people already hesitate.
So the rules were:
- No cookies, no user id, no stored IP.
- Never send what the person typed. Only where they were.
- One event per abandoned form, not a stream.
- Do not count a payment redirect as abandonment.
This is what we built, and the traps we hit on the way.
The beacon
The site already had a tiny page-view beacon. It exposes one global the form can call:
<script is:inline>
(function () {
function send(e, d, m) {
try {
var body = JSON.stringify({ e: e, p: location.pathname, r: document.referrer, d: d, m: m });
if (!(navigator.sendBeacon && navigator.sendBeacon('/api/t', new Blob([body], { type: 'text/plain' }))))
fetch('/api/t', { method: 'POST', body: body, keepalive: true, headers: { 'Content-Type': 'text/plain' } });
} catch (_) {}
}
// Automated browsers announce themselves; real people do not.
if (navigator.webdriver) { window.vtTrack = function () {}; return; }
window.vtTrack = send;
})();
</script>
Two details matter for an exit event.
navigator.sendBeacon is built for this. The browser queues the request and sends it even after the page is gone. A normal fetch started while the page is unloading is often cancelled. The fetch(..., { keepalive: true }) fallback covers browsers where sendBeacon is missing or returns false because its queue is full.
The body is a text/plain Blob. A sendBeacon with a JSON content type is not a "simple" request, and some browsers refuse it or send nothing. The server parses the text as JSON anyway.
When has someone "left"?
The first instinct is beforeunload or unload. Both are poor choices. unload does not fire reliably on mobile, where users usually swipe the app away or switch tabs and never "unload" anything. Pages with an unload listener can also lose the back/forward cache. beforeunload is meant for "you have unsaved changes" prompts, not analytics.
pagehide is the event to use. It fires when the page is hidden for navigation, tab close, or bfcache, including on mobile.
pagehide can fire more than once, for example when a user goes back to the page from the bfcache and then leaves again. We want one event per visit, so there is a flag:
let started = false; // passed step 1 validation
let touched = false; // typed or clicked anything
let paying = false; // pressed the pay button
let left = false; // already reported
root.addEventListener('input', () => (touched = true), { once: true });
addEventListener('pagehide', () => {
if (left || paying || !(touched || started)) return;
left = true;
track('leave', (whereNow() + (lastErrors && !onFinal ? ` | errors: ${lastErrors}` : '')).slice(0, 160));
});
Each guard prevents one kind of noise:
-
!(touched || started): someone who opens the form and leaves without touching it is a bounce, not a drop-off. The page-view stats already count them. -
paying: the pay button redirects to the payment provider, and that redirect firespagehidetoo. Without this flag, every buyer would show up as "left the form at the last step". Set the flag before you send thepay_clickevent and start the redirect, not in a callback after it. -
left: one report per page life.
Where were they?
whereNow() returns a short, human-readable position:
function whereNow(): string {
if (onFinal) return 'preview (saw price)';
const vis = visibleSteps();
const title = steps[current].querySelector('.wz-step__title')?.textContent?.trim().slice(0, 50) ?? '';
return `step ${vis.indexOf(steps[current]) + 1}/${vis.length + 1}: ${title}`;
}
The form has conditional steps. A step can be hidden depending on earlier answers. So "step 3" means "the third step this person saw", counted from visibleSteps() and not from the DOM order. If you count the raw step index, two people on the same screen can report different numbers.
We add the step title because "step 2/5" means nothing in a report a week later, while "step 2/5: Givare och mottagare" (giver and receiver) tells you exactly which screen it was.
preview (saw price) is a separate state, because "left after seeing the price" is a different problem from "left halfway through the questions".
Which fields failed, without the values
The most useful signal is whether the person got stuck on a validation error. Validation runs on the server when the user presses Next. The response lists issues with a field path:
const errs = showIssues(r.issues, stepEl);
if (errs.length) {
lastErrors = [...new Set(errs.map((e) => e.field!.replace(/\.\d+\./g, '.')))].join(', ').slice(0, 80);
return;
}
lastErrors = '';
Three things happen here:
- We keep only the field id (
givare_pnr), never the value. - Repeated groups have paths like
mottagare.0.pnrandmottagare.3.pnr. We strip the index, so the report saysmottagare.pnronce. This also means the report does not reveal how many recipients someone entered. -
lastErrorsis cleared on the next successful step. A leave event only says "errors: ..." if the person left while errors were on the screen.
A real event from testing:
{"e":"leave","d":"gavobrev","m":"step 2/5: Givare och mottagare | errors: givare_namn, givare_pnr"}
The endpoint does not trust the client
Anyone can POST to the beacon URL, so the server treats m as hostile text:
const meta = type === 'leave' && typeof b.m === 'string'
? b.m.replace(/[\u0000-\u001f<>]/g, '').slice(0, 160)
: undefined;
- Only the
leaveevent may carrym. On any other event it is dropped. - Control characters and
<>are removed. The text ends up in an HTML-formatted chat notification and in an admin report, so this blocks injection there (the notification code also escapes, but two layers cost nothing). - The length is capped on both sides, 160 characters.
The same endpoint drops known bot user agents and requests from data-center networks before it writes anything. A form drop-off report full of crawlers is worse than no report.
The trap in our own tests
The beacon does nothing when navigator.webdriver is true. This keeps headless crawlers out of the stats. It also means a Playwright test of the new event passes while sending nothing: the test filled the form, closed the page, and the server never saw a request.
The fix is to make the test browser look like a normal one for this check only:
await context.addInitScript(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => false });
});
Then the test waits for the /api/t request with a leave body and asserts on m. If your analytics has a bot guard, your end-to-end test needs to get past it on purpose, or it tests nothing.
What we store
One row per abandoned form: event type, path, document type, device class, browser language, coarse location from an in-memory IP lookup (the IP itself is not stored), and the meta string. We also updated the privacy policy to say this in one sentence: if you leave a form before paying, we record which step you were on and which fields had errors, never what you wrote.
A daily summary groups the rows by document + meta. That gives a short list such as "gåvobrev at step 2/5: Givare och mottagare | errors: givare_pnr". This is enough to see which screen loses people, and it never needs a recording of anyone.
Takeaways
- Use
pagehideandsendBeaconfor exit events. Do not useunload. - Report once per page life, and only for people who interacted.
- Mark the payment redirect so it does not count as abandonment.
- Count steps the way the user saw them, not the way the DOM has them.
- Send field ids, not values, and strip array indices.
- Sanitize the text on the server, even if your own client wrote it.
- If you filter
navigator.webdriver, your tests must override it.
I maintain Vittnet, a Swedish site with guides and step-by-step forms for wills, gift deeds and cohabitation agreements. Many people who start these forms first arrive at a guide like that one, on what a cohabitation agreement does and does not do when a partner dies.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.