What is a partial form lead, and why capture it before submit?
A partial form lead is the data a visitor typed into a lead form before leaving the page without clicking submit. The standard form pipeline only fires on submit, so the typed data is normally lost — but with input-level event listeners, the data can be captured and stored as the user types.
Capturing before submit matters because most lead forms see 60–80% abandonment rates (DigitalApplied, 2026), with the cross-industry average around 67.9%. The visitors who started but didn’t finish represent recoverable conversions: their email, name, and phone are often in the form by the time they leave. Capturing on input rather than submit turns those abandonments from total losses into a follow-up channel.
The legal floor: capture is generally permissible under GDPR Article 6(1)(f) (legitimate interest) and CCPA when the privacy notice discloses it. Sending marketing email to a captured-but-unsubmitted address is a separate question and usually requires explicit opt-in. The GDPR section below covers this in detail.
Which form events should you listen for?
The three relevant input events are input, change, and blur. They fire at different points and capture different states.
| Event | When it fires | Use for partial capture |
|---|---|---|
input |
Every keystroke (or paste) into a text field | Best — captures progressive state |
change |
When the user changes a value and the field loses focus (text) or on selection (dropdowns, radios) | Good for <select> and <input type="checkbox"> |
blur |
When focus leaves the field | Backup snapshot point |
submit |
When the form is submitted | Final state — not partial |
pagehide |
When the page unloads (close tab, navigate away) | Last-chance send |
The canonical setup uses input for live tracking and pagehide as the last-chance send. change is a fallback for non-text inputs that don’t fire input. blur is sometimes added for forms where users tab through fields quickly.
beforeunload is the older event for page-leaving but is unreliable on mobile browsers, where backgrounded tabs often skip it entirely. pagehide is the recommended replacement per the Page Visibility API specification and is supported across modern browsers including Safari, Chrome, Firefox, and mobile WebKit.
How do you build a minimal partial-lead capture script?
The minimal script attaches an input listener to every field in the form, captures the current form state, and sends it to a backend endpoint. Start with this HTML:
<form id="lead-form">
<input type="email" name="email" placeholder="Email" required>
<input type="text" name="name" placeholder="Name">
<input type="tel" name="phone" placeholder="Phone">
<button type="submit">Get the demo</button>
</form>
And this JavaScript:
const form = document.querySelector("#lead-form");
const endpoint = "/api/partial-lead";
function sendPartial() {
const data = Object.fromEntries(new FormData(form));
navigator.sendBeacon(endpoint, JSON.stringify(data));
}
form.querySelectorAll("input, textarea, select").forEach((field) => {
field.addEventListener("input", sendPartial);
});
window.addEventListener("pagehide", sendPartial);
This works — but it fires sendPartial() on every keystroke, which can mean hundreds of requests for a single user filling out a five-field form. The next section adds debouncing to throttle that down.
How do you debounce input events without losing the last keystroke?
A debounce wraps the send function so it only runs after the user stops typing for a configurable interval — typically 600–1000ms for partial-lead capture. The debounced version still fires immediately on pagehide so the last typed character isn’t lost.
Implementation:
function debounce(fn, ms = 800) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
const debouncedSend = debounce(sendPartial, 800);
form.querySelectorAll("input, textarea, select").forEach((field) => {
field.addEventListener("input", debouncedSend);
});
// On page exit, send immediately — don't wait for the debounce
window.addEventListener("pagehide", sendPartial);
With an 800ms debounce, a user typing their email at normal speed produces one captured snapshot when they pause to look at the next field, not 50 snapshots from every keystroke. Backend load drops by roughly an order of magnitude.
Tradeoff: A longer debounce (e.g. 2000ms) further reduces backend load but increases the chance that a user who types fast and immediately closes the tab is captured only by the
pagehidefallback, which can miss the last 1–2 fields in some browsers.
How do you reliably send data when the user leaves the page?
Use navigator.sendBeacon() — not fetch() — for the pagehide send. The two APIs have different reliability guarantees when the page is being torn down:
| API | Reliability on pagehide |
Notes |
|---|---|---|
navigator.sendBeacon() |
High — browser is required to send | Sync, fire-and-forget; cannot read the response |
fetch() with keepalive: true |
Medium | Works on Chrome/Edge; spotty on Safari and mobile |
fetch() without keepalive |
Low — often canceled mid-flight | Don’t use for last-moment sends |
XMLHttpRequest (sync) |
High but deprecated | Browsers actively warn against it |
The sendBeacon API is specifically designed for this case: the browser queues the request and guarantees it will be sent even after the page is gone. The tradeoff is that you cannot read the response, but for partial-lead capture you usually don’t need to — the backend just acknowledges and writes to storage.
function sendPartial() {
const data = Object.fromEntries(new FormData(form));
const payload = JSON.stringify(data);
// sendBeacon sends as Content-Type: text/plain by default
// If your backend requires application/json, use a Blob:
const blob = new Blob([payload], { type: "application/json" });
navigator.sendBeacon(endpoint, blob);
}
The Blob wrapper is important if your backend strictly parses Content-Type: application/json — sendBeacon’s default content type is text/plain which trips many JSON middleware setups.
How do you handle GDPR consent for partial capture?
Under the EU General Data Protection Regulation (GDPR), capturing partial form data can rely on legitimate interest as the lawful basis under Article 6(1)(f), provided the visitor has been clearly informed in the privacy notice. Sending marketing email to the captured address is governed by the ePrivacy Directive and generally requires prior explicit opt-in.
The practical implementation has two consent gates:
function sendPartial() {
const data = Object.fromEntries(new FormData(form));
// Check for marketing-consent checkbox state
const marketingConsent = form.querySelector("[name='marketing_opt_in']")?.checked;
data.marketing_consent = !!marketingConsent;
// Include a flag so the backend knows whether this lead can be emailed
navigator.sendBeacon(
endpoint,
new Blob([JSON.stringify(data)], { type: "application/json" }),
);
}
And the form should include the consent checkbox as a separate field, not bundled with the submit:
<label>
<input type="checkbox" name="marketing_opt_in" value="true">
Email me updates about new features (optional).
</label>
Backend rules:
- If
marketing_consentistrue, the lead can enter a recovery email sequence. - If
marketing_consentisfalseor absent, the partial data is stored for analytics and internal lead-recovery prompts only — no outbound email. - All capture is disclosed in the site’s privacy notice with a link near the form. Under the California Consumer Privacy Act (CCPA), the rules are less restrictive — disclosure in the privacy policy is generally sufficient — but the same architectural pattern works for both jurisdictions, so most implementations apply the GDPR-compliant version globally.
How do you send partial leads to a CRM in real time?
Two architecture options:
Option A: Direct from the browser to the CRM.
Works for CRMs with a public-write API (HubSpot Forms API, some Salesforce Web-to-Lead endpoints). Skip the backend roundtrip:
function sendPartialToHubSpot() {
const data = Object.fromEntries(new FormData(form));
const payload = {
fields: Object.entries(data).map(([name, value]) => ({ name, value })),
};
navigator.sendBeacon(
`https://api.hsforms.com/submissions/v3/integration/submit/${PORTAL_ID}/${FORM_GUID}`,
new Blob([JSON.stringify(payload)], { type: "application/json" }),
);
}
Downside: API keys or form GUIDs are exposed in client-side code. Acceptable for public form-submission endpoints; not acceptable for any endpoint requiring a secret token.
Option B: Through your own backend.
The browser sends the partial data to your endpoint; your server enriches, validates, and forwards to the CRM. This is the standard architecture for any non-trivial setup:
// Browser side
navigator.sendBeacon("/api/partial-lead", payload);
// Server side (Node.js / Express example)
app.post("/api/partial-lead", express.json(), async (req, res) => {
const { email, name, phone, marketing_consent } = req.body;
if (!email) return res.status(204).end();
await db.upsertPartialLead({ email, name, phone, marketing_consent });
await fetch("https://api.hubapi.com/crm/v3/objects/contacts", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUBSPOT_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
properties: { email, firstname: name, phone, lifecyclestage: "lead" },
}),
});
res.status(204).end();
});
Option B is the right default for almost all production setups. It keeps the CRM token server-side, lets you deduplicate against existing records, lets you enrich with IP / user-agent / UTM data, and lets you decide on the server whether the partial lead is qualified enough to push to the CRM at all.
What does the full production-ready script look like?
The minimal-but-complete vanilla-JS implementation, with debounce, sendBeacon on pagehide, GDPR consent handling, and field-level tracking:
(function () {
const form = document.querySelector("#lead-form");
if (!form) return;
const endpoint = "/api/partial-lead";
const DEBOUNCE_MS = 800;
// Debounce helper
function debounce(fn, ms) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
// Capture the current form state and send via sendBeacon
function snapshot() {
const data = Object.fromEntries(new FormData(form));
// Skip if the only field with content is empty / whitespace
const hasContent = Object.values(data).some((v) => String(v).trim().length > 0);
if (!hasContent) return;
// Add metadata
data.captured_at = new Date().toISOString();
data.page_url = location.href;
data.referrer = document.referrer || null;
data.marketing_consent =
!!form.querySelector("[name='marketing_opt_in']")?.checked;
const blob = new Blob([JSON.stringify(data)], {
type: "application/json",
});
navigator.sendBeacon(endpoint, blob);
}
const debouncedSnapshot = debounce(snapshot, DEBOUNCE_MS);
// Live capture on every field change
form.querySelectorAll("input, textarea, select").forEach((field) => {
field.addEventListener("input", debouncedSnapshot);
field.addEventListener("change", debouncedSnapshot);
});
// Last-chance send on page exit
window.addEventListener("pagehide", snapshot);
})();
Drop this into a <script> tag at the bottom of the page (or in a separate file loaded with defer). The IIFE wrapper keeps variables out of the global scope.
That’s the entire integration. No tag manager, no vendor script, no library. The backend endpoint at /api/partial-lead is whatever you build to store the data and optionally forward it to a CRM.
Sources
- MDN Web Docs — Navigator.sendBeacon(): https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon
- MDN Web Docs — Window: pagehide event: https://developer.mozilla.org/en-US/docs/Web/API/Window/pagehide_event
- MDN Web Docs — HTMLFormElement: submit event: https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/submit_event
- MDN Web Docs — FormData: https://developer.mozilla.org/en-US/docs/Web/API/FormData
- EU GDPR — Article 6 (Lawful basis for processing): https://gdpr-info.eu/art-6-gdpr/
- EU ePrivacy Directive — Article 13 (unsolicited communications): https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX%3A32002L0058