Two panels. Left: a script fires click() on a Save button and reads back 'ok: true' because the click dispatched. Right: the database table it was meant to write to, still empty. An arrow labelled 'assert this' points from the script to the table, not to the button.

Here is a failure that never shows up in your logs, because as far as your code is concerned nothing went wrong. A script fills a form, clicks Save, and the click succeeds. The button was there, it was clickable, the handler fired, the dialog closed. Your function returns true and the run marches on. Days later someone notices the records are missing, and there is nothing in the output to explain it — every step reported success.

This is the quiet twin of the click that lands on a cookie banner. That one is loud: the click misses and something breaks soon after. This one is worse, because the click hits. The trigger fires perfectly. It is the outcome that never happened — and you asserted on the trigger.

A click confirms the cause, not the effect

When locator.click() resolves, all it promises is that the pointer sequence was dispatched at the right element. It says nothing about what the page did next. Between your click and the thing you actually wanted, a dozen things can quietly swallow the result:

  • Client-side validation rejected the form and re-rendered it, closing the modal without submitting.
  • The submit fired a request that returned a 4xx, and the app showed an inline error you never read.
  • A second click landed while the first was still in flight, and the app de-duplicated both away.
  • The record saved to a draft, not to the place you were checking.
  • The service accepted the request, then dropped it — rate limit, anti-automation, an eventual-consistency window that had not closed.

In every one of these, the click is blameless and successful. The mistake is upstream of the code, in the assumption that firing the trigger and getting the result are the same event. They are two events, and only the second is the one you care about.

“The button disappeared” is not proof of anything

The most seductive false signal is the button vanishing. The modal closes, the Save button is gone, so surely it saved? No — the button is gone because the modal closed, and a modal closes on cancel, on validation failure, and on success alike. You have confirmed the UI reacted, which was never in doubt. Whether the data changed is a separate question the button cannot answer.

Assert on the resulting state

The fix is a change of target. Do not assert that you clicked. Assert that the world changed the way the click was supposed to change it. After a save, the honest check is: is the new record actually there? After a submit: did the success state appear, or an error? After a navigation: is the URL, or a marker element on the destination, what I expect?

The subtlety is that “is it there” is easy to get wrong in a way that hides the bug again. If you save an item and then check “is there an item in the list,” the answer is almost always yes — from last time. The list was not empty to begin with. A top-of-list or most-recent element is exactly the thing that was there before your click, so reading it back reports an old success as a new one. That is a false positive that quietly marks unsaved work as saved.

So confirm something that could only be true if this action just happened. The cheapest such proof is freshness: the new item carries a timestamp, and you check that the newest one is newer than the moment you clicked.

const startedAt = Date.now();
await saveButton.click();

// Wait for a row whose timestamp is after we clicked — not just "a row exists".
const confirmed = await page.waitForFunction((since) => {
  const rows = [...document.querySelectorAll('[data-row] time[datetime]')];
  return rows.some(t => Date.parse(t.getAttribute('datetime')) >= since);
}, startedAt, { timeout: 15000 }).catch(() => null);

if (!confirmed) {
  throw new Error('clicked Save but no fresh row appeared — the write did not land');
}

Now the two possible endings are both truthful. If a fresh row appears, you have real evidence the write landed. If none appears inside the window, you raise an honest failure at the exact step that failed, instead of returning success and letting the gap surface three stages later as missing data with no cause attached.

Pick the strongest proof the page will give you

Freshness by timestamp is a good default, but it is not the only proof, and some are stronger. A returned identifier in the response is unambiguous. A success toast with the record's own name in it is hard to fake. A row count that went up by exactly one is solid if nothing else writes concurrently. Reach for whatever the app makes checkable, and prefer a signal that carries data unique to this action — an id, a name, a timestamp — over a generic “something is on screen.”

The same trap, one layer down

This is not only a browser problem. A POST that returns 200 has confirmed the request was received, not that the side effect ran; a queue that accepts a job has confirmed enqueue, not completion; a shell command that exits 0 has confirmed it ran, not that it did the thing. Everywhere a system hands you an easy acknowledgement of the attempt, there is a temptation to treat it as acknowledgement of the result — and a stronger, more specific check sitting one query away that you could make instead. The discipline is the same at every layer: confirm the effect, not the call.

We confirm every action before we believe it

We hit this constantly building thinbrowser, a browser an AI agent drives through MCP. An agent cannot glance at the page and notice that a post never appeared — if the tool says “posted,” the agent believes it and moves on, and a whole chain of later steps is built on a thing that did not happen. So thinbrowser never reports success on the click alone. After an action it looks for the resulting state — a genuinely fresh item, a changed URL, a success marker — and if that proof is not there, it says so plainly instead of claiming a win. An honest failure the agent can retry beats a false success it will build on.

If you drive a browser with an agent, it is one line to try:

claude mcp add thinbrowser -- npx -y thinbrowser

We build the unglamorous reliability so the interesting part works. That’s what we do at Rebel Studios.