How to test form data is kept after a failed submit
Fill every field, force the failure, then read what each control holds with node probe.mjs server. A handler that echoed one field returned the other five empty, including a file the server had already received in full. The browser's own back navigation restored five of the six, which is why one tester cannot reproduce what another reports.
Why check this
A submit that fails is the moment a form loses typed work. Four mechanisms produce that loss and they look identical to the person typing: the handler did not echo the value, the page never navigated, the browser refilled the fields from session history, or a saved draft put something back. Run it when a form gains a server-side rule, and after any change to the submit handler or cache headers.
The failure it prevents is a ticket nobody can reproduce. One tester reaches the error page by pressing back and sees a full form, another submits and sees an empty one. The route is what differed, and it belongs in the report.
The file field is the sharpest case: a person who re-uploads is redoing work the form already took and discarded.
Prerequisites
- Node 22 and a free port 8934. Every request goes to
127.0.0.1. - Chrome, plus
puppeteer-corebeside the fixture (npm i puppeteer-core). SetCHROMEif it sits elsewhere. --lang=en-USon the launch. Chrome's built-in validation text follows the UI locale and reads Ukrainian here; every message below comes from the page instead.- One capture from Chrome 152.0.7977.76 on 2026-09-12: restoration is browser behaviour, so another browser answers differently.
- A file input's
valuetakes only the empty string (the value IDL attribute); restoration from session history is bfcache.
server.mjs, the target: six kinds of field, one rule, one echoed field.
// server.mjs : one form, six kinds of field, and a handler that echoes one of them.
import { createServer } from 'node:http';
const PORT = 8934;
const accepted = [];
const CLIENT_GUARD = `
document.querySelector('#f').addEventListener('submit', (e) => {
const name = document.querySelector('#fullname');
if (name.value.length < 5) {
e.preventDefault();
document.querySelector('#err').textContent = 'Full name needs 5 characters.';
}
});`;
const DRAFT = `
const KEY = 'draft:signup';
const form = document.querySelector('#f');
const save = () => {
const d = {};
for (const el of form.elements) {
if (!el.name) continue;
if (el.type === 'checkbox' || el.type === 'radio') { if (el.checked) d[el.name] = el.value; }
else d[el.name] = el.value;
}
sessionStorage.setItem(KEY, JSON.stringify(d));
};
form.addEventListener('input', save);
form.addEventListener('change', save);
const saved = sessionStorage.getItem(KEY);
if (saved) {
const d = JSON.parse(saved);
for (const el of form.elements) {
if (!el.name || !(el.name in d)) continue;
if (el.type === 'checkbox' || el.type === 'radio') el.checked = el.value === d[el.name];
else if (el.type !== 'file') el.value = d[el.name];
}
}`;
const page = (opt, echo = {}, error = '') => `<!doctype html><html lang="en"><meta charset="utf-8">
<title>signup</title>
<p id="err">${error}</p>
<form id="f" method="post" action="/submit?${opt}" enctype="multipart/form-data">
<input id="fullname" name="fullname" type="text" value="${echo.fullname ?? ''}">
<select id="plan" name="plan">
<option value="free">Free</option><option value="pro">Pro</option><option value="team">Team</option>
</select>
<input id="terms" name="terms" type="checkbox" value="yes">
<input id="email" name="contact" type="radio" value="email">
<input id="phone" name="contact" type="radio" value="phone">
<input id="password" name="password" type="password">
<input id="avatar" name="avatar" type="file">
<button id="go" type="submit">Create account</button>
</form>
<script>
${opt.includes('guard=client') ? CLIENT_GUARD : ''}
${opt.includes('draft=on') ? DRAFT : ''}
</script>`;
function parseMultipart(buf, boundary) {
const out = {};
for (const part of buf.toString('binary').split(`--${boundary}`)) {
const i = part.indexOf('\r\n\r\n');
if (i === -1) continue;
const head = part.slice(0, i);
const name = /name="([^"]*)"/.exec(head);
if (!name) continue;
const file = /filename="([^"]*)"/.exec(head);
const body = part.slice(i + 4).replace(/\r\n$/, '');
out[name[1]] = file ? { filename: file[1], bytes: Buffer.byteLength(body, 'binary') } : body;
}
return out;
}
const headers = (opt) => ({
'content-type': 'text/html; charset=utf-8',
'cache-control': opt.includes('cache=store') ? 'max-age=60' : 'no-store',
});
createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1');
const opt = url.searchParams.toString();
if (req.method === 'POST' && url.pathname === '/submit') {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const boundary = /boundary=(.+)$/.exec(req.headers['content-type'] ?? '')?.[1] ?? '';
const f = parseMultipart(Buffer.concat(chunks), boundary);
console.log(`POST /submit received ${JSON.stringify(f)}`);
if ((f.fullname ?? '').length < 5) {
// The whole defect in one line: one field is echoed, the other five are not.
res.writeHead(422, headers(opt));
return res.end(page(opt, { fullname: f.fullname }, 'Full name needs 5 characters.'));
}
accepted.push(f);
res.writeHead(200, headers(opt));
return res.end(`<!doctype html><html lang="en"><meta charset="utf-8"><title>done</title><p id="ok">account ${accepted.length} created</p>`);
});
return;
}
if (url.pathname === '/other') {
res.writeHead(200, headers(opt));
return res.end('<!doctype html><html lang="en"><meta charset="utf-8"><title>other</title><p>another page</p>');
}
if (url.pathname === '/records') {
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ accepted }) + '\n');
}
res.writeHead(200, headers(opt));
res.end(page(opt));
}).listen(PORT, '127.0.0.1', () => console.log(`form target on 127.0.0.1:${PORT}`));
probe.mjs, the driver. Its upload is printf 'avatar bytes' > avatar.txt, 12 bytes.
// probe.mjs <case> : fills every field, then reports what each one holds afterwards.
// cases: server | client | back-no-store | back-store | reload-no-store | reload-store | file | draft
import { launch } from 'puppeteer-core';
import { fileURLToPath } from 'node:url';
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const BASE = 'http://127.0.0.1:8934';
const AVATAR = fileURLToPath(new URL('avatar.txt', import.meta.url));
const kase = process.argv[2] ?? 'server';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const TYPED = 'fullname="Ann" plan=team terms=checked contact=phone password="hunter2hunter2" avatar=avatar.txt';
async function fill(page) {
await page.type('#fullname', 'Ann');
await page.select('#plan', 'team');
await page.click('#terms');
await page.click('#phone');
await page.type('#password', 'hunter2hunter2');
await (await page.$('#avatar')).uploadFile(AVATAR);
console.log(`typed: ${TYPED}`);
}
const read = (page) => page.evaluate(() => [...document.querySelectorAll('#f [name]')].map((el) => ({
field: el.name,
type: el.type,
value: el.value,
checked: el.type === 'checkbox' || el.type === 'radio' ? String(el.checked) : '-',
files: el.type === 'file' ? String(el.files.length) : '-',
})));
function table(rows) {
const cols = ['field', 'type', 'value', 'checked', 'files'];
const w = cols.map((c) => Math.max(c.length, ...rows.map((r) => String(r[c]).length)));
const line = (cells) => cells.map((c, i) => String(c).padEnd(w[i])).join(' ').trimEnd();
console.log(line(cols));
for (const r of rows) console.log(line(cols.map((c) => r[c])));
}
const browser = await launch({ executablePath: CHROME, headless: true, args: ['--lang=en-US'] });
const page = await browser.newPage();
let docs = 0;
page.on('response', (r) => { if (r.request().resourceType() === 'document') docs += 1; });
console.log(`case ${kase}`);
if (kase === 'server') {
await page.goto(`${BASE}/`, { waitUntil: 'load' });
await fill(page);
const [res] = await Promise.all([page.waitForNavigation({ waitUntil: 'load' }), page.click('#go')]);
console.log(`submit -> HTTP ${res.status()} at ${new URL(page.url()).pathname}, error "${await page.$eval('#err', (e) => e.textContent)}"`);
table(await read(page));
} else if (kase === 'client') {
await page.goto(`${BASE}/?guard=client`, { waitUntil: 'load' });
await fill(page);
const before = docs;
await page.click('#go');
await sleep(500);
console.log(`submit -> ${docs - before} document response(s), still at ${new URL(page.url()).pathname}, error "${await page.$eval('#err', (e) => e.textContent)}"`);
table(await read(page));
} else if (kase.startsWith('back-') || kase.startsWith('reload-')) {
const cache = kase.endsWith('-store') && !kase.endsWith('no-store') ? 'store' : 'no-store';
await page.goto(`${BASE}/?cache=${cache}`, { waitUntil: 'load' });
await fill(page);
const before = docs;
if (kase.startsWith('back-')) {
await page.goto(`${BASE}/other?cache=${cache}`, { waitUntil: 'load' });
await page.goBack();
} else {
await page.reload();
}
await sleep(800);
console.log(`cache-control: ${cache}, ${docs - before} document response(s) during the action, back at ${new URL(page.url()).pathname}`);
table(await read(page));
} else if (kase === 'file') {
await page.goto(`${BASE}/`, { waitUntil: 'load' });
const r = await page.evaluate(() => {
const out = {};
const host = document.createElement('div');
host.innerHTML = '<input type="file" value="avatar.txt">';
const parsed = host.querySelector('input');
out['markup value="avatar.txt"'] = `value ${JSON.stringify(parsed.value)}, files ${parsed.files.length}`;
const el = document.querySelector('#avatar');
try { el.value = 'avatar.txt'; out['el.value = "avatar.txt"'] = 'no error'; }
catch (e) { out['el.value = "avatar.txt"'] = `${e.name}: ${e.message}`; }
try { el.value = ''; out["el.value = ''"] = `no error, files ${el.files.length}`; }
catch (e) { out["el.value = ''"] = `${e.name}: ${e.message}`; }
return out;
});
for (const [k, v] of Object.entries(r)) console.log(`${k} -> ${v}`);
} else if (kase === 'draft') {
await page.goto(`${BASE}/?draft=on`, { waitUntil: 'load' });
const empty = await page.evaluate(() => sessionStorage.getItem('draft:signup'));
console.log(`before typing, sessionStorage draft:signup = ${empty}`);
await fill(page);
console.log(`after typing, sessionStorage draft:signup = ${await page.evaluate(() => sessionStorage.getItem('draft:signup'))}`);
await Promise.all([page.waitForNavigation({ waitUntil: 'load' }), page.click('#go')]);
console.log(`failed submit -> back on the form, fields now:`);
table(await read(page));
await page.$eval('#fullname', (e) => { e.value = ''; });
await page.type('#fullname', 'Annabel');
await (await page.$('#avatar')).uploadFile(AVATAR);
await Promise.all([page.waitForNavigation({ waitUntil: 'load' }), page.click('#go')]);
console.log(`successful submit -> ${await page.$eval('#ok', (e) => e.textContent)}`);
console.log(`after success, sessionStorage draft:signup = ${await page.evaluate(() => sessionStorage.getItem('draft:signup'))}`);
await page.goto(`${BASE}/?draft=on`, { waitUntil: 'load' });
console.log(`a later visit to the empty form holds:`);
table(await read(page));
}
console.log(`chrome ${await browser.version()}`);
await browser.close();
Steps
- Step 1.
Start the target in its own shell.
node server.mjsform target on 127.0.0.1:8934Stop it later by the PID that owns port 8934, never by image name.
- Step 2.
Fill every field, submit, and read the form the error response rendered.
node probe.mjs servercase server typed: fullname="Ann" plan=team terms=checked contact=phone password="hunter2hunter2" avatar=avatar.txt submit -> HTTP 422 at /submit, error "Full name needs 5 characters." field type value checked files fullname text Ann - - plan select-one free - - terms checkbox yes false - contact radio email false - contact radio phone false - password password - - avatar file - 0 chrome Chrome/152.0.7977.76One field of six survived: the
selectfell back to its first option, the checkbox and both radios readfalse, the password and file inputs empty. - Step 3.
Read what the server held as it rendered that page.
grep 'POST /submit' server.log | head -1POST /submit received {"fullname":"Ann","plan":"team","terms":"yes","contact":"phone","password":"hunter2hunter2","avatar":{"filename":"avatar.txt","bytes":12}}Every value arrived, the upload included, and five were dropped on the way out.
- Step 4.
Ask the browser whether the server could have restored the file.
node probe.mjs filecase file markup value="avatar.txt" -> value "", files 0 el.value = "avatar.txt" -> InvalidStateError: Failed to set the 'value' property on 'HTMLInputElement': This input element accepts a filename, which may only be programmatically set to the empty string. el.value = '' -> no error, files 0 chrome Chrome/152.0.7977.76A
valueattribute parses to the empty string, and the assignment raisesInvalidStateError. No server-side echo refills that control. - Step 5.
Run the same form with a handler that calls
preventDefault()on failure.node probe.mjs clientcase client typed: fullname="Ann" plan=team terms=checked contact=phone password="hunter2hunter2" avatar=avatar.txt submit -> 0 document response(s), still at /, error "Full name needs 5 characters." field type value checked files fullname text Ann - - plan select-one team - - terms checkbox yes true - contact radio email false - contact radio phone true - password password hunter2hunter2 - - avatar file C:\fakepath\avatar.txt - 1 chrome Chrome/152.0.7977.76Zero document responses. Nothing navigated, so nothing was restored: all six fields hold what was typed.
- Step 6.
Navigate away from the filled form and press back, under
cache-control: no-store.node probe.mjs back-no-storecase back-no-store typed: fullname="Ann" plan=team terms=checked contact=phone password="hunter2hunter2" avatar=avatar.txt cache-control: no-store, 2 document response(s) during the action, back at / field type value checked files fullname text Ann - - plan select-one team - - terms checkbox yes true - contact radio email false - contact radio phone true - password password - - avatar file C:\fakepath\avatar.txt - 1 chrome Chrome/152.0.7977.76The document was fetched again and the values came back anyway, from session history rather than the markup. The password is the one field Chrome left empty.
- Step 7.
Repeat with the same page served as
cache-control: max-age=60.node probe.mjs back-storecase back-store typed: fullname="Ann" plan=team terms=checked contact=phone password="hunter2hunter2" avatar=avatar.txt cache-control: store, 1 document response(s) during the action, back at / field type value checked files fullname text Ann - - plan select-one team - - terms checkbox yes true - contact radio email false - contact radio phone true - password password hunter2hunter2 - - avatar file C:\fakepath\avatar.txt - 1 chrome Chrome/152.0.7977.76One document response instead of two, and the password came back too. A cache header decided whether a password sits in a field after the back button.
- Step 8.
Reload the filled form instead of navigating back.
node probe.mjs reload-no-storecase reload-no-store typed: fullname="Ann" plan=team terms=checked contact=phone password="hunter2hunter2" avatar=avatar.txt cache-control: no-store, 1 document response(s) during the action, back at / field type value checked files fullname text - - plan select-one free - - terms checkbox yes false - contact radio email false - contact radio phone false - password password - - avatar file - 0 chrome Chrome/152.0.7977.76Nothing was restored, and
node probe.mjs reload-storeprinted the same table. This is a protocol reload over the DevTools connection: the harness cannot press Chrome's own reload control, so the step says nothing about it. - Step 9.
Add a draft in
sessionStorageand follow it through a failure and a success.node probe.mjs draftcase draft before typing, sessionStorage draft:signup = null typed: fullname="Ann" plan=team terms=checked contact=phone password="hunter2hunter2" avatar=avatar.txt after typing, sessionStorage draft:signup = {"fullname":"Ann","plan":"team","terms":"yes","contact":"phone","password":"hunter2hunter2","avatar":"C:\\fakepath\\avatar.txt"} failed submit -> back on the form, fields now: field type value checked files fullname text Ann - - plan select-one team - - terms checkbox yes true - contact radio email false - contact radio phone true - password password hunter2hunter2 - - avatar file - 0 successful submit -> account 1 created after success, sessionStorage draft:signup = {"fullname":"Annabel","plan":"team","terms":"yes","contact":"phone","password":"hunter2hunter2","avatar":"C:\\fakepath\\avatar.txt"} a later visit to the empty form holds: field type value checked files fullname text Annabel - - plan select-one team - - terms checkbox yes true - contact radio email false - contact radio phone true - password password hunter2hunter2 - - avatar file - 0 chrome Chrome/152.0.7977.76The draft is written on every keystroke, so it holds the password before anything is sent. It refilled the error page, and outlived the success: a later visit came back holding an account already created.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| One field filled and the rest empty after a failed submit | The handler echoed that one value and no other | Raise one defect naming the fields, not one saying the form clears. |
| The server log holds values the rendered form does not | The loss is in the response, not in the request | Fix it in the template. The data is already there. |
| files 0 with a filename in the server log | No markup can put an upload back | Keep the file server side and show its name, or state that a retry re-uploads. |
| Zero document responses and every field intact | preventDefault() stopped the navigation | This path proves nothing about the server round trip. Run step 2 as well. |
| Fields refilled after the back button | Chrome restored them from session history, not from your markup | Record how you reached the page. How to test double form submission has the same split. |
| The password present after back on one page and absent on another | The cache header on the form page differed | Decide which behaviour you want, then assert on it. |
| A draft that refills a form after a successful submit | Nothing cleared the key on the success path | Clear it where the success is handled, and check what the key holds. |
Common mistakes
What to check next
- How to test double form submission: where the second attempt is the accident.
- How to check where focus lands after a validation error: the cursor after the same failure.
- How to test that client validation is enforced on the server: why steps 5 and 2 differ.
- How to check field length limits in a form: truncation before a value is kept or lost.
- How to check required field validation: the rule behind most failed submits.
FAQ
How do you retain values in input fields when a submit fails?
Echo each value into the markup the error response renders, field by field. A checkbox needs checked, a select needs selected, and the file input cannot be echoed at all.
Does the browser keep form data when the user presses back?
Chrome did here, on both cache settings, for five of six fields. That is session history restoration, not your markup.
Why did the password come back on one run and not on the other?
The cache header on the form page: no-store in step 6, max-age=60 in step 7. Same actions, same markup.
Should a draft be saved to localStorage or sessionStorage?
Either outlives the submit unless something removes the key. The draft in step 9 refilled a fresh form after an account was created, password included.
Verified
Verified by Maks Vernynode 22.23.2Chrome 152.0.7977.76puppeteer-core 25.10.0
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate10 minpublished updated Maks Verny