The contact form on this site has a honeypot: a text input named _gotcha that no human can see. Bots fill every field they find, so anything that arrives with _gotcha filled gets dropped. To avoid teaching bots what went wrong, the form still shows 'Message sent.' In July that trap caught a real visitor, and the site told them their message was on its way.
01 Autofill does not read your attributes
The honeypot was hidden the way most tutorials suggest: positioned 9999 pixels off the left edge, one pixel square, opacity zero, tabIndex -1, autocomplete="off". I had already renamed it from the usual 'website' to '_gotcha', because password managers recognise names like 'website' and 'url' and fill them in. It wasn't enough. Chrome's address autofill ignores autocomplete="off", and it treats an off-screen, transparent input as a field like any other. Someone with a saved address accepted the suggestion for their name and email, Chrome filled my trap along with the real fields, and my own code classified them as a bot.
The fix is a wrapper. The input now sits inside a div with display: none. Chrome skips fields that aren't rendered at all, so autofill leaves it alone. A bot reading the raw HTML doesn't compute styles; it sees an input and fills it. The trap still works on the visitors it was built for.
02 The fake success was the expensive part
How Chrome fills forms is Chrome's business. The damage came from my decision to fail silently. A form that rejects a human with an error gets a second attempt, or an annoyed email. A form that says 'sent' gets nothing, and neither do I. I don't know how many enquiries went that way before the one I found out about, and for a freelancer a lost enquiry is the most expensive bug a portfolio can have.
I kept the fake success, because it does make the trap harder to probe. What changed is how I treat that code path. The API route logs every submission it drops, so a tripped honeypot leaves a trace I can read. And code that discards what a visitor typed, without telling them, now gets the testing I'd give a checkout. Before July it had the testing I'd give a hover state.
03 Then the fields started jumping
The same day brought a second bug from the same family. Moving focus from one field to the next made the form twitch, the inputs shifting up and down by a few pixels. It only happened in a browser with extensions installed.
Each field was a flex column with gap: 9px between label, input and error message. Extensions such as 1Password, Grammarly and DeepL inject a small anchor div into whichever input has focus. That div measures zero by zero, but flex gap is applied between children whatever their size, so every injected element added a phantom nine pixels and took them away again on blur. The spacing now comes from a margin-bottom on the label and a margin-top on the error. An injected child has nothing to inflate, and the measured shift went from 9px to 0.
04 A form's DOM is shared property
Both bugs have one cause: I wrote the form as if my markup were the only thing in it. On a visitor's machine the browser writes into my inputs and a few extensions add elements of their own next to them. A clean test profile has none of that going on, which makes it the least representative browser that will ever load the page.
The test I'd add to any contact form is a second run in a profile with saved addresses, a password manager and a grammar checker, clicking through the autofill suggestions instead of typing. For layout, the rule I took from it: no gap inside anything that wraps an input. Gap is the better tool right up to the moment someone else adds a child, and around form fields someone else always does.