To capture partial form submissions on a custom form, save the contact fields (name, email, phone) each time the visitor leaves one, send them with navigator.sendBeacon under a session ID the browser creates, and upsert them on your server. A job then marks sessions that have gone quiet as "left early" and alerts you once. When the visitor does press Send, the finished form carries the same session ID, so the early save and the lead become one record. Below is working code for both halves, tested end to end, and the four places a quick version goes wrong: consent, storage, duplicates, and getting the request out as the tab closes.
Key takeaways
- Save contact fields when the visitor leaves each one. Never save on every keystroke, never free text, and never passwords or card numbers.
- Let the browser create the session ID with
crypto.randomUUID(), so a request sent as the tab closes never needs a reply. - Send early saves with
sendBeacon(orfetchwithkeepalive: true) and flush onvisibilitychange, not onunloadorbeforeunload. - Send the session ID with the finished form and claim quiet sessions in one SQL statement, or you will alert on people who already sent it.
- For visitors in the EU, UK and Switzerland, ask first. The code below saves nothing until the visitor ticks a box.
This page is the build. For why people leave, what to fix in the form before you recover anyone, and the law in more depth, read our form abandonment guide.
What you are building
Six small parts. Leaving one out is where the quiet bugs come from.
- A browser script that saves contact fields as the visitor leaves each one.
- An endpoint that checks each request and upserts one row per visit.
- A table with one row per session.
- A hook in your real submit handler that marks the session as sent.
- A sweeper that runs every few minutes, marks quiet sessions as left early and alerts you once.
- A deletion rule, so left-early leads don't live forever.
The browser half
Mark the form with data-partial-form and add an unticked consent checkbox. The checkbox has no name, so it is never sent with the form.
<form data-partial-form="contact" action="/api/lead" method="post">
<input name="name" autocomplete="name">
<input name="email" type="email" autocomplete="email" required>
<input name="phone" type="tel" autocomplete="tel">
<textarea name="message"></textarea>
<label>
<input type="checkbox" data-partial-consent>
Save my name and contact details as I type, so you can reply even if I don't finish.
Anything else is sent only when I press Send.
</label>
<button>Send</button>
</form>
<script src="/partial-capture.js" defer></script>
Then the script. It saves only the three contact fields, only once there is an email or phone number to reply to, and only after the box is ticked. In a form without the checkbox, it waits for your consent banner to set window.partialConsent = true.
// partial-capture.js: saves name, email and phone as the visitor leaves each field,
// before they press Send. Nothing else, and nothing until they agree.
(() => {
const ENDPOINT = '/api/partial';
const CONTACT = ['name', 'email', 'phone']; // the name attributes of your contact inputs
for (const form of document.querySelectorAll('form[data-partial-form]')) {
const formId = form.dataset.partialForm;
const consentBox = form.querySelector('[data-partial-consent]');
let sessionId = null;
let lastSent = '';
// With a checkbox in the form, it decides. Without one, your banner sets window.partialConsent.
const allowed = () => (consentBox ? consentBox.checked : window.partialConsent === true);
const send = (payload) => {
const body = JSON.stringify(payload);
// A string body goes out as text/plain: no CORS preflight, and it survives the tab closing.
if (!navigator.sendBeacon || !navigator.sendBeacon(ENDPOINT, body)) {
fetch(ENDPOINT, { method: 'POST', body, keepalive: true }).catch(() => {});
}
};
const save = () => {
if (!allowed()) return;
const fields = {};
for (const name of CONTACT) {
const el = form.elements.namedItem(name);
const value = el && typeof el.value === 'string' ? el.value.trim() : '';
if (value) fields[name] = value.slice(0, 200);
}
if (!fields.email && !fields.phone) return; // nothing you could reply to yet
if (!sessionId) {
sessionId = crypto.randomUUID();
// The finished form carries the same id, so the early save and the lead become one.
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'partial_session';
hidden.value = sessionId;
form.append(hidden);
}
const snapshot = JSON.stringify(fields);
if (snapshot === lastSent) return; // nothing changed since the last save
lastSent = snapshot;
send({ sessionId, formId, fields });
};
form.addEventListener('focusout', save); // the visitor left a field
form.addEventListener('change', save); // autofill, pickers, checkboxes
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') save(); // tab switched, hidden or closing
});
// Unticking the box withdraws consent: stop saving and delete what was saved.
consentBox?.addEventListener('change', () => {
if (consentBox.checked || !sessionId) return;
send({ sessionId, formId, withdraw: true });
lastSent = '';
});
}
})();
Why each part is there:
focusoutbubbles andblurdoes not (MDN), so one listener on the form covers every field.changecatches values that arrive without the visitor moving through the field, such as autofill and date pickers. The snapshot check stops the two listeners from sending the same values twice.- The session ID is created on the first save, after consent, and written into a hidden field named
partial_session. The finished form now carries it. crypto.randomUUID()is only available in secure contexts (MDN), so serve the form over HTTPS; localhost counts as secure while you build.
The server half
The table
One row per visit. The SQL on this page runs on SQLite (3.35 or later, for RETURNING) and on Postgres; on Postgres, write the placeholders as $1, $2 and so on.
CREATE TABLE IF NOT EXISTS partial_leads (
session_id TEXT PRIMARY KEY,
form_id TEXT NOT NULL,
name TEXT,
email TEXT,
phone TEXT,
status TEXT NOT NULL DEFAULT 'in_progress', -- in_progress, left_early, submitted
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS partial_leads_sweep ON partial_leads (status, updated_at);
CREATE INDEX IF NOT EXISTS partial_leads_email ON partial_leads (email);
The endpoint
It takes a standard Request and returns a Response, so it drops into a Next.js route handler, a Cloudflare Worker, Hono, Bun or Deno. db stands for whatever database client you use, wrapped so it has run and all.
// partial.js: the endpoint. db is a thin wrapper around your client with run(sql, params)
// and all(sql, params).
const SITE_ORIGIN = 'https://www.example.com'; // your site, exactly as the browser sends it
const FORMS = new Set(['contact', 'quote']); // the data-partial-form values you use
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const clean = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);
export async function handlePartial(request, db) {
if (request.method !== 'POST') return new Response(null, { status: 405 });
if (request.headers.get('origin') !== SITE_ORIGIN) return new Response(null, { status: 403 });
const text = await request.text();
if (text.length > 2000) return new Response(null, { status: 413 });
let data;
try { data = JSON.parse(text); } catch { return new Response(null, { status: 400 }); }
const { sessionId, formId, fields = {}, withdraw } = data ?? {};
if (!UUID.test(String(sessionId)) || !FORMS.has(formId)) return new Response(null, { status: 400 });
if (withdraw === true) {
await db.run(`DELETE FROM partial_leads WHERE session_id = ? AND status = 'in_progress'`, [sessionId]);
return new Response(null, { status: 204 });
}
// Only the fields you chose, whatever the browser sent.
const name = clean(fields.name, 100);
const phone = clean(fields.phone, 40);
let email = clean(fields.email, 254);
if (email && !EMAIL.test(email)) email = null;
if (!email && !phone) return new Response(null, { status: 204 });
const now = Date.now();
await db.run(
`INSERT INTO partial_leads (session_id, form_id, name, email, phone, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'in_progress', ?, ?)
ON CONFLICT (session_id) DO UPDATE SET
name = COALESCE(excluded.name, partial_leads.name),
email = COALESCE(excluded.email, partial_leads.email),
phone = COALESCE(excluded.phone, partial_leads.phone),
updated_at = excluded.updated_at
WHERE partial_leads.status = 'in_progress'`,
[sessionId, formId, name, email, phone, now, now]
);
return new Response(null, { status: 204 });
}
// In a Next.js route handler (app/api/partial/route.js):
// export const POST = (request) => handlePartial(request, db);
It refuses other origins, bodies over 2,000 characters, unknown forms and malformed session IDs, and it reads only name, email and phone whatever the browser sends. Anyone can post to a public endpoint, so put your platform's rate limiting in front of it too.
Mark the session as sent
Add this to the same file and call it from the handler that receives the finished form.
// Call this from the handler that receives the finished form, after your own checks pass:
// const sessionId = formData.get('partial_session');
// if (sessionId) await markSubmitted(db, sessionId);
export async function markSubmitted(db, sessionId) {
if (!UUID.test(String(sessionId))) return;
await db.run(
`UPDATE partial_leads SET status = 'submitted', updated_at = ? WHERE session_id = ?`,
[Date.now(), sessionId]
);
}
The sweeper
Run it every five minutes. It claims quiet sessions, skips people who sent the form anyway, alerts you, and deletes old rows.
// sweep.js: run every 5 minutes (cron, a scheduled Worker, your host's cron jobs).
// notify(lead) sends the alert: an email, a Slack message, a row in your CRM.
const MINUTE = 60 * 1000;
const DAY = 24 * 60 * MINUTE;
export async function sweep(db, notify, { quietMs = 30 * MINUTE, keepDays = 30 } = {}) {
const now = Date.now();
// Claim quiet sessions in one statement, so two overlapping runs never alert twice.
const quiet = await db.all(
`UPDATE partial_leads SET status = 'left_early'
WHERE status = 'in_progress' AND updated_at < ?
RETURNING session_id, form_id, name, email, phone, updated_at`,
[now - quietMs]
);
for (const lead of quiet) {
// The same person sent the form in another tab or visit today: no alert.
const sent = lead.email
? await db.all(
`SELECT 1 FROM partial_leads WHERE email = ? AND status = 'submitted' AND updated_at > ? LIMIT 1`,
[lead.email, now - DAY]
)
: [];
if (sent.length) continue;
try {
await notify(lead);
} catch {
// Hand it back, so the next run tries again.
await db.run(`UPDATE partial_leads SET status = 'in_progress' WHERE session_id = ?`, [lead.session_id]);
}
}
// Delete what you no longer need: left-early leads after keepDays, merged copies after two days.
await db.run(
`DELETE FROM partial_leads
WHERE (status = 'left_early' AND updated_at < ?) OR (status = 'submitted' AND updated_at < ?)`,
[now - keepDays * DAY, now - 2 * DAY]
);
}
We ran this code end to end on 30 October 2026 (Chromium, Node 22, SQLite): nothing was sent before the box was ticked, the message field never left the browser, one row per visit, the row marked as submitted when the form was sent, one alert per quiet session, a failed alert retried on the next run, and old rows deleted.
Pitfall 1: consent
Saving what someone typed before they press Send is the part privacy law cares about, and the rules differ by region.
- EU. Article 5(3) of the ePrivacy Directive allows storing or reading information on a visitor's device only with consent or for the narrow purposes it lists. The EDPB's Guidelines 2/2023 (version 2.0, October 2024) say JavaScript that instructs the browser to send information is covered (paragraph 33), and that information the user types in is covered too, because it sits on the device before it is collected (paragraph 62).
- UK. Regulation 6 of PECR is the same rule: tell people, and get prior consent unless an exception applies. The ICO's guidance on storage and access technologies lists five exceptions (communication, strictly necessary, statistical purposes, appearance, emergency assistance). None of them is written for saving a form the visitor hasn't sent.
- Switzerland. Article 45c of the Telecommunications Act requires you to tell visitors what you do and why, and that they can refuse and how, as the FDPIC's guidelines on cookies and similar technologies explain (section 2.1). A clear sentence next to an unticked box does both.
- United States. No federal rule requires consent first. California's Invasion of Privacy Act (Penal Code section 631(a)) has been used against tools that record what visitors type, and in Javier v. Assurance IQ (9th Cir., May 2022, unpublished) the court read it as requiring consent before the recording, not after. That case was about a third-party tool recording a whole session; saving contact fields on your own server is narrower, but the timing point is the same.
In the code, allowed() is the gate: with the box unticked nothing leaves the browser, and pressing Send always works. Unticking the box later sends a withdraw that deletes the saved row. Say the same thing in your privacy policy, with how long you keep left-early leads. This page is not legal advice.
Pitfall 2: storage
- Allowlist on the server, not only in the browser. The endpoint reads three fields and ignores the rest, so a tampered request can't store a message or a password.
- Never save free text before Send. People draft and delete. Half a message is not something they chose to send you.
- Keep the session ID in the page. The script holds it in a variable and a hidden field and writes nothing to cookies or
localStorage, so a reload simply starts a new session. If you move it tosessionStorageto survive reloads, that is storage on the device too: write it only after consent. - Enforce your retention period in code. The sweeper deletes left-early rows after 30 days and merged copies after two. Pick your own numbers and state them in your privacy policy.
Pitfall 3: duplicates
- Early save, then Send. The hidden
partial_sessionfield lets your submit handler mark the row as submitted. The sweeper only touches rows still in progress. - A save that lands after Send. Leaving the page fires
visibilitychange, so a last save can arrive after the form was sent. The upsert'sWHERE partial_leads.status = 'in_progress'ignores it. - Two sweeper runs at once.
UPDATE ... RETURNINGclaims each quiet row once; the second run finds nothing left to claim. - The same person in another tab or on another day. That is a new session. Before alerting, the sweeper looks for a submitted row with the same email in the last 24 hours. If your real leads live in another table, check there instead.
- A form that posts straight to a hosted form service. Your server never sees the finished form, can't mark it as sent, and you will alert on people who did send the form. Route the form through your own handler first.
Pitfall 4: getting the request out as the tab closes
Most saves happen while the page is alive. The hard case is the last field: the visitor types a phone number and closes the tab. Two browser APIs let a request outlive the page.
| sendBeacon | fetch with keepalive | |
|---|---|---|
| Method | POST only | Any |
| Response | None: true only means queued | Yes |
| Body size | 64 KiB of queued data | 64 KiB |
| Browsers | All current browsers since 2018 | Chrome 66, Safari 13, Firefox 133 and later |
Sources: MDN on sendBeacon, MDN on keepalive and MDN's browser compatibility data, checked October 2026.
- Send a string, not a JSON Blob. A string body goes out as
text/plain;charset=UTF-8(we checked in Chromium), which needs no CORS preflight. Chrome refuses to beacon aBlobwhose type isn't on the CORS safelist, so a Blob typedapplication/jsonfails. The endpoint parses the text as JSON. - Flush on
visibilitychange. MDN calls it the most reliable moment to send.unloadandbeforeunloadoften don't fire on mobile,unloadalso breaks the back/forward cache, andpagehidehas the same reliability problem. - Keep the endpoint on your own origin. It avoids CORS entirely and keeps the origin check simple. Browsers send an
Originheader on same-origin POST requests (MDN), but under someReferrer-Policyvalues, such asno-referrer, it can arrive asnulland the check above refuses it. Test with your production headers. - Keep payloads tiny. The 64 KiB covers everything queued at once, and three contact fields need well under 1 KiB.
Test it before you ship
- Open the form with DevTools on the Network tab. Type an email and press Tab: nothing is sent while the box is unticked.
- Tick the box: one POST to
/api/partialwith the name and email. Type in the message field and leave it: nothing new is sent. - Type a phone number and navigate away without leaving the field. The row now has the phone.
- Run the sweeper with a short quiet period (
{ quietMs: 60000 }): the row turnsleft_earlyand you get one alert. Run it again: no second alert. - Start over, fill in the form and press Send. The row turns
submittedand the sweeper leaves it alone.
When to skip the build
The code above is the core. A real deployment also needs alerts that reliably reach you, rate limiting, and somewhere to read and answer leads. If you'd rather not run that, OllieLeads does this part for forms you build yourself: add <script src="https://ollieleads.com/sdk/v1.js" defer></script> and data-ollie-form="frm_xxxxxxxx" to the form, and it saves contact fields as the visitor leaves each one, sends one "left early" alert about 30 minutes after they go quiet, and with data-consent="required" on the script tag saves nothing until they agree. It is on every plan, including Free (500 leads in total, no card; pricing, checked October 2026). How it works is on the partial submissions page.
FAQ
Can I capture partial form submissions without a third-party tool?
Yes. A script in the page, an endpoint and a table on your server, a hook in your submit handler and a sweeper job are all it takes. The code on this page is all of it except the alert itself.
Should I use sendBeacon or fetch with keepalive?
Either one survives the tab closing, and both are limited to 64 KiB. sendBeacon works in every current browser and needs no reply, which suits fire-and-forget saves. fetch with keepalive gives you a response and any method, but Firefox only added it in version 133. The script here uses sendBeacon and falls back to fetch.
Why not save on every keystroke?
Keystrokes capture typos, half-written thoughts and things people deleted, which is more than they chose to give you, and recording every interaction is what the Javier wiretap case was about. Saving when a field is left gives you the finished value with far fewer requests.
How do I stop a visitor who submits from showing up twice?
Send the session ID with the finished form (the script adds it as a hidden field named partial_session) and mark that row as submitted in your submit handler. The sweeper only alerts on rows still in progress, and it skips anyone whose email arrived as a submitted lead in the last day.
Is it legal to save form data before someone submits?
It depends on where your visitors are. In the EU and UK, the cautious reading of the ePrivacy rules is that you need consent first. Switzerland requires a notice and a way to refuse. In the US, California's wiretap law has been used against tools that record typing without prior consent. The code here saves nothing until the visitor ticks a box. This page is not legal advice.
This page was made by SuperDuperSEO