Lead Capture & Forms

How Do You Capture a Partial Form Lead in JavaScript?

Capture partial form leads with vanilla JavaScript: listen for input events, debounce the writes, send the snapshot with sendBeacon on pagehide.

Quick answer

Capture a partial form lead by attaching an input event listener to each form field, debouncing the captured data, and sending it to your backend with navigator.sendBeacon() on the pagehide event. This pattern records every field as the user types and reliably sends the last snapshot when the user leaves the page — no tag manager or vendor script required.

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 pagehide fallback, 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_consent is true, the lead can enter a recovery email sequence.
  • If marketing_consent is false or 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

  1. MDN Web Docs — Navigator.sendBeacon(): https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon
  2. MDN Web Docs — Window: pagehide event: https://developer.mozilla.org/en-US/docs/Web/API/Window/pagehide_event
  3. MDN Web Docs — HTMLFormElement: submit event: https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/submit_event
  4. MDN Web Docs — FormData: https://developer.mozilla.org/en-US/docs/Web/API/FormData
  5. EU GDPR — Article 6 (Lawful basis for processing): https://gdpr-info.eu/art-6-gdpr/
  6. EU ePrivacy Directive — Article 13 (unsolicited communications): https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX%3A32002L0058

Frequently asked questions

QWhy use pagehide instead of beforeunload?
beforeunload is unreliable on mobile browsers, where backgrounded tabs frequently skip the event entirely. pagehide is the modern replacement specified by the Page Visibility API and is supported across all major browsers including Safari, Chrome, Firefox, and mobile WebKit. The pagehide event also fires when the page enters the back/forward cache, which beforeunload typically doesn't, making it more reliable for capturing the final form state.
QWhy use sendBeacon instead of fetch?
sendBeacon is specifically designed for sending data when the page is being unloaded. The browser is required to queue and send the request even after the page is gone. Regular fetch() requests are commonly canceled mid-flight when the page unloads, losing the data. The keepalive: true option on fetch mitigates this but has spottier support across Safari and mobile browsers. sendBeacon is the safer default.
QWill this script work if the user has an ad blocker installed?
Yes, in most cases. Ad blockers primarily target known tracking domains and scripts (Meta Pixel, Google Analytics, etc.) — a same-origin POST from your own form to your own /api/partial-lead endpoint is rarely blocked. The exception is uBlock Origin's "strict" rules or some privacy-focused browsers (Brave on aggressive mode), which can block sendBeacon calls if they're identified as analytics. For these users, you lose the partial capture but not the eventual form submission.
QDo I need to capture the data on every keystroke, or just on field blur?
Both are valid. Capturing on every keystroke (with debounce) catches users who close the tab mid-field; capturing on field blur (every time focus leaves a field) is simpler but misses users who type partial data into a field and then close the tab. The input + debounce + pagehide combination in this article gives you both — live updates during typing and a final-state guarantee on exit.
QHow do I prevent capturing test data from my own developers?
Add a query-parameter or cookie check before binding the listeners: if (location.search.includes("partial=off") || document.cookie.includes("partial-off=1")) return;. Then set the cookie in your team's browsers via a one-time bookmarklet. This keeps your partial-lead database clean of QA noise without changing the deployed code.
QShould I capture password fields in partial-lead tracking?
No, never. Treat any <input type="password"> as off-limits for partial capture. The implementation should explicitly exclude password fields: form.querySelectorAll("input:not([type='password']), textarea, select"). Capturing typed passwords creates an immediate security and compliance liability — most data-protection authorities would consider it a breach by design. The same applies to credit-card-number fields, social security numbers, and any field marked as PCI-scoped.
QHow do I capture partial leads on a single-page application where the form is re-rendered?
Use a MutationObserver to watch for the form being added to the DOM, and run the binding code each time a new form instance appears. The pattern: observe document.body, check for added nodes that match your form selector, and call the binding function on each. This handles React, Vue, Angular, and other framework cases where the form node is replaced rather than mutated.

Find the qualified leads your forms are currently throwing away.

Install PartialLeads on one landing page, send traffic, and compare what your CRM captured against what PartialLeads recovered and qualified.