How to test whitespace trimming in a form field

Build one fixture from five distinct whitespace code points, put it into the field, and read the value back as a U+ column at each hop. Here type="email" kept 20 of 24 characters, trim() cut 13 units to 5, and a field that looked empty reached the store as one character.

Why check this

Whitespace is not one character, and trimming is not one operation. Five characters in the fixture below all look like a gap, and four layers each remove a different subset.

Run this before sign-off on any form that keys a record by a typed string, a user name or a coupon code. Run it again after an upgrade replaces the input component.

The failure it prevents is a record nobody can find. Step 9 reproduces it: three rows display the same three letters, and an exact lookup returns one. Step 6 holds the second, a uniqueness constraint that refuses a true repeat and accepts a twin differing by one invisible character.

Prerequisites

fixture.mjs holds the strings.

export const SP = '\u0020';   // SPACE
export const TAB = '\u0009';  // CHARACTER TABULATION
export const NBSP = '\u00A0'; // NO-BREAK SPACE
export const IDSP = '\u3000'; // IDEOGRAPHIC SPACE
export const ZWSP = '\u200B'; // ZERO WIDTH SPACE

const LEAD = SP + TAB + NBSP + IDSP + ZWSP;
const TRAIL = ZWSP + IDSP + NBSP + TAB + SP;

export const NAME = LEAD + 'Ada' + TRAIL;
export const EMAIL = LEAD + 'qa@example.com' + TRAIL;
export const LINK = LEAD + 'https://example.com/' + TRAIL;
export const NOTE = LEAD + 'one' + '\u000D\u000A' + 'two' + TRAIL;
export const BLANK = SP + TAB + NBSP + IDSP + ZWSP + IDSP + NBSP + TAB + SP;

export const cps = (s) =>
  [...s].map((c) => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' ');

target.mjs serves the form and stores what arrives.

import { createServer } from 'node:http';
import { DatabaseSync } from 'node:sqlite';

const PORT = 8933;
const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT UNIQUE)');

const FORM = `<!doctype html><html><head><meta charset="utf-8"><title>whitespace fixture</title></head>
<body>
<form id="f" method="post" action="/save">
  <input id="name" name="name" type="text" required>
  <input id="email" name="email" type="email">
  <input id="link" name="link" type="url">
  <textarea id="note" name="note"></textarea>
  <button id="go" type="submit">Save</button>
</form>
</body></html>`;

const send = (res, code, type, body) => {
  res.writeHead(code, { 'content-type': type + '; charset=utf-8' });
  res.end(body);
};

createServer((req, res) => {
  const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
  if (req.method === 'GET' && url.pathname === '/') return send(res, 200, 'text/html', FORM);
  if (req.method === 'GET' && url.pathname === '/rows') {
    const rows = db.prepare('SELECT id, length(name) AS len, hex(name) AS hex, name FROM users ORDER BY id').all();
    return send(res, 200, 'text/plain', rows.map((r) => `id ${r.id}  length ${r.len}  bytes ${Buffer.byteLength(r.name, "utf8")}  hex ${r.hex}`).join('\n') + '\n');
  }
  if (req.method === 'GET' && url.pathname === '/find') {
    const key = url.searchParams.get('name') ?? '';
    const n = (sql) => db.prepare(sql).all(key).length;
    const exact = n('SELECT id FROM users WHERE name = ?');
    const trimmed = n('SELECT id FROM users WHERE trim(name) = ?');
    const like = n("SELECT id FROM users WHERE name LIKE '%' || ? || '%'");
    return send(res, 200, 'text/plain', `key units ${key.length}   name = ? matched ${exact}   trim(name) = ? matched ${trimmed}   LIKE %key% matched ${like}\n`);
  }
  if (!(req.method === 'POST' && url.pathname === '/save')) return send(res, 404, 'text/plain', 'not found');
  const chunks = [];
  req.on('data', (c) => chunks.push(c));
  req.on('end', () => {
    const body = new URLSearchParams(Buffer.concat(chunks).toString('utf8'));
    const name = body.get('name') ?? '';
    let error = null;
    let id = null;
    if (!name) error = 'name is required';
    else {
      try {
        id = Number(db.prepare('INSERT INTO users (name) VALUES (?)').run(name).lastInsertRowid);
      } catch (e) {
        error = e.message;
      }
    }
    send(res, error ? 400 : 201, 'application/json', JSON.stringify({
      id,
      error,
      receivedUnits: name.length,
      receivedBytes: Buffer.byteLength(name, 'utf8'),
      afterTrimUnits: name.trim().length,
      emptyAfterTrim: name.trim() === '',
    }) + '\n');
  });
}).listen(PORT, '127.0.0.1', () => console.log('whitespace fixture on http://127.0.0.1:' + PORT));

probe.mjs reads the fixture, the controls and the rendering.

import { NAME, EMAIL, LINK, NOTE, BLANK, ZWSP, cps } from './fixture.mjs';

const CHROME = process.env.CHROME || 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const ORIGIN = 'http://127.0.0.1:8933';
const mode = process.argv[2];
const pad = (n, w) => String(n).padStart(w);
const row = (label, s) => label.padEnd(14) + 'units ' + pad(s.length, 2) + '   ' + cps(s);

if (mode === 'points') {
  for (const [k, v] of [['NAME', NAME], ['EMAIL', EMAIL], ['LINK', LINK], ['NOTE', NOTE], ['BLANK', BLANK]]) {
    console.log(row(k, v));
  }
} else if (mode === 'trim') {
  for (const [k, v] of [['NAME', NAME], ['BLANK', BLANK]]) {
    console.log(row(k + ' raw', v));
    console.log(row(k + ' trim()', v.trim()));
  }
  console.log('BLANK trim() === ""      ' + (BLANK.trim() === ''));
  console.log('BLANK trim() length      ' + BLANK.trim().length);
  console.log('BLANK trim() is ZWSP     ' + (BLANK.trim() === ZWSP));
  console.log('Boolean(BLANK.trim())    ' + Boolean(BLANK.trim()));
  console.log('NAME.trim() === "Ada"    ' + (NAME.trim() === 'Ada'));
} else {
  const { launch } = await import('puppeteer-core');
  const browser = await launch({ executablePath: CHROME, headless: true, args: ['--lang=en-US'] });
  const page = (await browser.pages())[0];
  await page.goto(ORIGIN + '/', { waitUntil: 'networkidle2' });

  const assign = (id, v) => page.evaluate(([i, t]) => {
    const el = document.getElementById(i);
    el.value = t;
    return { kept: el.value };
  }, [id, v]);

  if (mode === 'controls') {
    for (const [id, type, v] of [['name', 'text', NAME], ['email', 'email', EMAIL], ['link', 'url', LINK], ['note', 'textarea', NOTE]]) {
      const r = await assign(id, v);
      console.log(type.padEnd(9) + 'set ' + pad(v.length, 2) + '  kept ' + pad(r.kept.length, 2) + '   ' + cps(r.kept));
    }
  } else if (mode === 'insert') {
    const a = await assign('email', EMAIL);
    await page.evaluate(() => { const el = document.getElementById('email'); el.value = ''; el.focus(); });
    for (const c of EMAIL) await page.keyboard.sendCharacter(c);
    const k = await page.evaluate(() => document.getElementById('email').value);
    console.log(row('assigned', a.kept));
    console.log(row('keyed', k));
    console.log('identical     ' + (a.kept === k));
  } else if (mode === 'required') {
    const r = await assign('name', BLANK);
    console.log('name kept units ' + r.kept.length + '   ' + cps(r.kept));
    const v = await page.evaluate(() => {
      const el = document.getElementById('name');
      return { valueMissing: el.validity.valueMissing, formValid: document.getElementById('f').checkValidity(), message: el.validationMessage };
    });
    console.log('valueMissing ' + v.valueMissing + '   form valid ' + v.formValid + '   message "' + v.message + '"');
  } else if (mode === 'render') {
    const out = await page.evaluate((items) => items.map(([label, text]) => {
      const el = document.createElement('span');
      el.textContent = text;
      document.body.appendChild(el);
      return { label, units: text.length, width: el.getBoundingClientRect().width };
    }), [['row 1 raw', NAME], ['row 2 trim()', NAME.trim()], ['row 3 Ada', 'Ada']]);
    for (const r of out) console.log(r.label.padEnd(14) + 'units ' + pad(r.units, 2) + '   rendered width ' + r.width.toFixed(2) + ' px');
  }
  await browser.close();
}

Start the target.

node --no-warnings target.mjs

Steps

  1. Step 1.

    Print the fixture before anything touches it.

    node probe.mjs points
    
    NAME          units 13   U+0020 U+0009 U+00A0 U+3000 U+200B U+0041 U+0064 U+0061 U+200B U+3000 U+00A0 U+0009 U+0020
    EMAIL         units 24   U+0020 U+0009 U+00A0 U+3000 U+200B U+0071 U+0061 U+0040 U+0065 U+0078 U+0061 U+006D U+0070 U+006C U+0065 U+002E U+0063 U+006F U+006D U+200B U+3000 U+00A0 U+0009 U+0020
    LINK          units 30   U+0020 U+0009 U+00A0 U+3000 U+200B U+0068 U+0074 U+0074 U+0070 U+0073 U+003A U+002F U+002F U+0065 U+0078 U+0061 U+006D U+0070 U+006C U+0065 U+002E U+0063 U+006F U+006D U+002F U+200B U+3000 U+00A0 U+0009 U+0020
    NOTE          units 18   U+0020 U+0009 U+00A0 U+3000 U+200B U+006F U+006E U+0065 U+000D U+000A U+0074 U+0077 U+006F U+200B U+3000 U+00A0 U+0009 U+0020
    BLANK         units  9   U+0020 U+0009 U+00A0 U+3000 U+200B U+3000 U+00A0 U+0009 U+0020

    Five whitespace characters, one of each, on both sides of every value. BLANK carries no letter.

  2. Step 2.

    Put each value into its control and read it back.

    node probe.mjs controls
    
    text     set 13  kept 13   U+0020 U+0009 U+00A0 U+3000 U+200B U+0041 U+0064 U+0061 U+200B U+3000 U+00A0 U+0009 U+0020
    email    set 24  kept 20   U+00A0 U+3000 U+200B U+0071 U+0061 U+0040 U+0065 U+0078 U+0061 U+006D U+0070 U+006C U+0065 U+002E U+0063 U+006F U+006D U+200B U+3000 U+00A0
    url      set 30  kept 26   U+00A0 U+3000 U+200B U+0068 U+0074 U+0074 U+0070 U+0073 U+003A U+002F U+002F U+0065 U+0078 U+0061 U+006D U+0070 U+006C U+0065 U+002E U+0063 U+006F U+006D U+002F U+200B U+3000 U+00A0
    textarea set 18  kept 17   U+0020 U+0009 U+00A0 U+3000 U+200B U+006F U+006E U+0065 U+000A U+0074 U+0077 U+006F U+200B U+3000 U+00A0 U+0009 U+0020

    type="text" returned all 13 units. type="email" returned 20 of 24 and type="url" 26 of 30: each dropped a leading space and tab and a trailing tab and space, then stopped at U+00A0. The textarea replaced U+000D U+000A with one U+000A.

  3. Step 3.

    Check that the insertion path does not change the answer.

    node probe.mjs insert
    
    assigned      units 20   U+00A0 U+3000 U+200B U+0071 U+0061 U+0040 U+0065 U+0078 U+0061 U+006D U+0070 U+006C U+0065 U+002E U+0063 U+006F U+006D U+200B U+3000 U+00A0
    keyed         units 20   U+00A0 U+3000 U+200B U+0071 U+0061 U+0040 U+0065 U+0078 U+0061 U+006D U+0070 U+006C U+0065 U+002E U+0063 U+006F U+006D U+200B U+3000 U+00A0
    identical     true

    Assignment to el.value and keyed entry gave the same 20 units, so the rest of this page assigns.

  4. Step 4.

    Run String.prototype.trim() over the raw fixture.

    node probe.mjs trim
    
    NAME raw      units 13   U+0020 U+0009 U+00A0 U+3000 U+200B U+0041 U+0064 U+0061 U+200B U+3000 U+00A0 U+0009 U+0020
    NAME trim()   units  5   U+200B U+0041 U+0064 U+0061 U+200B
    BLANK raw     units  9   U+0020 U+0009 U+00A0 U+3000 U+200B U+3000 U+00A0 U+0009 U+0020
    BLANK trim()  units  1   U+200B
    BLANK trim() === ""      false
    BLANK trim() length      1
    BLANK trim() is ZWSP     true
    Boolean(BLANK.trim())    true
    NAME.trim() === "Ada"    false

    trim() removed U+0020, U+0009, U+00A0 and U+3000 from both ends and stopped at U+200B, a format character and not whitespace by its definition. NAME fell from 13 units to 5 and no longer equals Ada. BLANK fell from 9 to 1, and that survivor is truthy, is not the empty string, and prints as nothing.

  5. Step 5.

    Put the whitespace-only value into a required field.

    node probe.mjs required
    
    name kept units 9   U+0020 U+0009 U+00A0 U+3000 U+200B U+3000 U+00A0 U+0009 U+0020
    valueMissing false   form valid true   message ""

    required asks whether the value is empty, not what the characters are. Nine whitespace characters satisfy it.

  6. Step 6.

    Post five values with no browser in the path.

    for b in '%20%09%C2%A0%E3%80%80%E2%80%8BAda%E2%80%8B%E3%80%80%C2%A0%09%20' '%E2%80%8BAda%E2%80%8B' 'Ada' 'Ada' '%20%09%C2%A0%E3%80%80%E2%80%8B%E3%80%80%C2%A0%09%20'; do curl -s -X POST http://127.0.0.1:8933/save --data "name=$b"; done
    
    {"id":1,"error":null,"receivedUnits":13,"receivedBytes":23,"afterTrimUnits":5,"emptyAfterTrim":false}
    {"id":2,"error":null,"receivedUnits":5,"receivedBytes":9,"afterTrimUnits":5,"emptyAfterTrim":false}
    {"id":3,"error":null,"receivedUnits":3,"receivedBytes":3,"afterTrimUnits":3,"emptyAfterTrim":false}
    {"id":null,"error":"UNIQUE constraint failed: users.name","receivedUnits":3,"receivedBytes":3,"afterTrimUnits":3,"emptyAfterTrim":false}
    {"id":4,"error":null,"receivedUnits":9,"receivedBytes":17,"afterTrimUnits":1,"emptyAfterTrim":false}
    

    Raw, trimmed and bare each got an id; only the exact repeat of Ada hit the constraint. The last row is whitespace and nothing else, stored with emptyAfterTrim false.

  7. Step 7.

    Read the store by character count and bytes.

    curl -s http://127.0.0.1:8933/rows
    
    id 1  length 13  bytes 23  hex 2009C2A0E38080E2808B416461E2808BE38080C2A00920
    id 2  length 5  bytes 9  hex E2808B416461E2808B
    id 3  length 3  bytes 3  hex 416461
    id 4  length 9  bytes 17  hex 2009C2A0E38080E2808BE38080C2A00920

    This is the command to take away. length() counts characters and hex() prints the UTF-8 bytes: 416461 is Ada, E2808B is U+200B, C2A0 is U+00A0, E38080 is U+3000.

  8. Step 8.

    Render the first three stored names and measure them.

    node probe.mjs render
    
    row 1 raw     units 13   rendered width 66.66 px
    row 2 trim()  units  5   rendered width 26.66 px
    row 3 Ada     units  3   rendered width 26.66 px

    Rows 2 and 3 hold different strings at the same width to the hundredth of a pixel. U+200B costs nothing on screen.

  9. Step 9.

    Look the name up as an operator would.

    curl -s 'http://127.0.0.1:8933/find?name=Ada'
    
    key units 3   name = ? matched 1   trim(name) = ? matched 1   LIKE %key% matched 3

    One row of the three that display as Ada. SQL trim() matched the same single row, so it removed none of the tab, U+00A0, U+3000 or U+200B. LIKE found all three, which is how this gets filed as a search bug.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | type="text" kept every unit | No sanitizing applies to that type | The value your handler receives is exactly what was entered. Trim it yourself, and decide which characters count. | | type="email" kept 20 of 24 | The control strips leading and trailing ASCII whitespace only | Do not credit the server for it. Step 6 sends all 24 past the control and the store keeps them. | | The textarea kept 17 of 18 | The API value normalises U+000D U+000A to U+000A | Count line endings on the server, not in the browser. A CRLF paste and an LF paste arrive the same length. | | trim() left U+200B at both ends | U+200B is a format character, not whitespace | Name the characters your rule removes. trim() alone is a rule that keeps four of them. | | emptyAfterTrim is false on a blank field | The value is one invisible character, not zero | Reject on a code point allowlist, or normalise before the emptiness test. required will not fire. | | Two rows at the same rendered width | The difference is a zero width character | Compare hex(), never the display. Step 7 is the reproduction. | | An exact lookup matches 1 of 3 rows | The key is stored in three spellings | Fix the write path first, then backfill. A LIKE search hides the defect. |

Common mistakes

Sign: The value comes back trimmed from the server, so the server is recorded as trimming.Cause: type=email and type=url strip leading and trailing ASCII whitespace through the value sanitization algorithm, before any script or request exists. Step 2 handed the email control 24 characters and the control returned 20. Step 6 sends all 24 with curl and the store keeps all 24, so the trimming was never on the server side.
Sign: A field looks empty, the required check passes, and the record is stored with a blank name.Cause: trim() removes U+0020, U+0009, U+00A0 and U+3000 and stops at U+200B. Step 4 turned nine whitespace characters into one, and that one is truthy and is not the empty string, so every emptiness test downstream says the field was filled in. Step 5 shows valueMissing false on the same value in the browser.
Sign: A uniqueness constraint is in place and duplicate rows appear anyway.Cause: The constraint compares bytes. Step 6 posted Ada twice and the second one was refused, then posted the same letters wrapped in U+200B and got a new id. Rows 2 and 3 in step 8 render at the same width, so a reviewer reading the table sees one value twice and no defect.
Sign: The fix is a trim() in the API layer, and the same tickets keep arriving.Cause: SQL trim() and JavaScript trim() do not remove the same set. Step 9 ran trim(name) over four rows and matched the one row that never had whitespace on it. A cleanup written in SQL against a defect measured in Node repairs nothing, and the rows it skipped stay unreachable.

Thresholds

String.prototype.trim() removes U+0020, U+0009, U+00A0 and U+3000 from a value and leaves U+200B, so a 9 character whitespace-only value becomes a 1 character value that is not the empty string Source: Measured in step 4 on node 22.23.2, 2026-09-12, against the WhiteSpace and LineTerminator productions at https://tc39.es/ecma262/#sec-string.prototype.trim
type=email kept 20 of 24 characters and type=url kept 26 of 30, removing one space and one tab from each end and stopping at U+00A0 Source: Measured in step 2, Chrome 152.0.7977.76, 2026-09-12, against https://html.spec.whatwg.org/multipage/input.html#value-sanitization-algorithm

What to check next

FAQ

Does html input trim whitespace?

Only for some types. Step 2 shows type="email" and type="url" removing leading and trailing ASCII whitespace, type="text" removing none, and a textarea changing CRLF to LF. None touches U+00A0, U+3000 or U+200B.

How do I make input trim whitespace everywhere?

Normalise on the server, and name the characters. A rule built on trim() keeps U+200B, as step 4 measures.

Why does trailing spaces not allowed fire on a value that looks clean?

Something upstream added a character the rule counts as a space and your eyes do not. Run step 1 on that value and read the U+ column.

Is a whitespace-only value the same as an empty one?

No. Step 4 turned a 9 character blank into a 1 character string, and step 5 shows required passing it.

Verified

Verified by Maks Vernynode 22.23.2node:sqlite 3.51.3Chrome 152.0.7977.76puppeteer-core 25.10.0curl 8.21.0Windows 11 build 22631

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.

intermediate12 minpublished updated Maks Verny