# A failed save should leave the person’s work where they can recover it

Author: Chris Chabot
Canonical URL: https://chabot.dev/blog/a-failed-save-should-leave-the-work-recoverable/
Published: 2026-11-03

What a form owes you when the server refuses your text: where the only copy lives while a request is in flight, how a task board and a shared document editor were repaired, and how to test a refusal without hiding it.

A lot of web forms treat what you typed as theirs to dispose of the moment you press Enter. The field clears, a new row or a spinner appears, and a request sets off for the server carrying the only remaining copy of your words. When the server accepts it, nobody ever learns in what order those things happened. When the server refuses it, your text is gone and you are looking at an error message about something you can no longer see.

The task board among the sample apps on Ferrite, the application platform I build on and operate, behaved this way in the inline field for adding a card. A pass through the apps’ inputs with deliberately bad values, which I directed with agents around the start of November 2026, submitted a title the server would refuse. The server refused it, correctly, and the title went with it. The editor for renaming a list had the same fault in another form, closing on submit and taking the rejected name with it. Nothing was wrong with the server’s validation and no test of the server would have noticed anything, because the defect lived entirely in the order of operations in the browser, and the repair needed no change to the platform underneath.

## The field is the only copy

Until the server has confirmed a write, the text in the field is the only copy that exists, so the form has no business discarding it. The add form now waits for the request before clearing. Reduced to a sketch, which is not the app’s actual code:

```ts
async function submit() {
  if (pending) return;
  pending = true;
  const result = await addCard(listId, title).catch(networkFailure);
  pending = false;

  if (result.ok) {
    title = "";
    error = null;
  } else {
    error = result.message;
  }
}

function onTitleInput(value) {
  title = value;
  error = null;
}
```

Moving the clear below the `await` is not the whole repair, because the old order was doing a job nobody had asked it to do. An empty field can’t be submitted twice. Once the text stays in place during the request, a second press of Enter on a slow connection sends the same title again, so the form has to refuse its own second submission while the first is in flight. The error has a similar problem with its lifetime. It describes the last attempt, and once you have started correcting the title it is complaining about text that no longer exists, so it has to go when the value it refers to goes. Keeping the value, allowing the correction, clearing the stale error and blocking the duplicate are one piece of work, because doing any of them alone leaves you with a different bug.

The rename editor needed the same treatment and now stays open, holding the rejected value, until a request succeeds.

## Following one title through

The check that goes with this follows the text the way a person would. It submits a title the server refuses, requires the HTTP 400, and requires the text to be still there afterwards. It corrects the title and submits again. Then it reloads the page and looks for the card, because a card that appears in the browser may exist only in the browser, and it finishes by archiving the card it created. That journey failed against the old code locally, then passed against the new code both locally and on the deployed app, and I care about both halves of that sentence. A check you have only ever seen pass has not shown you it can tell the two apart.

A retained value also puts more weight on the refusal, since the person now has to act on it. The same pass found a document vault that published a 160-character limit on file names and counted UTF-8 bytes to enforce it, so a name of 41 emoji, at four bytes each, was refused as too long. Keeping someone’s text in front of them helps only if what you say about it is true.

## What there is to lose

Not every failed request has text in it, and it would be a mistake to make every interaction wait for the server. Dragging a card to another list can reasonably move it at once, because the gesture takes a second to repeat and the old position is known. There the board’s journey injects a failed move and requires the failure to be reported, the card rolled back and the result read back. The music player among the same samples has this case in miniature, with a favourite whose request is deliberately aborted, and the book catalogue has it with a failed search that has to recover. In each of these the person can repeat a small action, provided the interface tells them the truth about what happened. So the question I would ask of any write is what would be lost if it failed: for a drag, a second of your time, and for a typed title, the title.

Longer text changes the answer again, since nobody should have to keep an hour’s writing alive in a browser form. The answer I’ve written about separately, for a planning app, is a draft per person on the server, which survives a reload without becoming a revision, and a publish that carries the revision its draft was based on, so that the server can answer 409 if the document has moved on since then. The shared document editor among these samples keeps a draft too, and its publish journey meets the same refusal. What concerns me here is what that asks of the interface. A 409 on publish is a different event from the add form’s 400, because nothing is wrong with your text: someone else’s revision got there first, your words are still in your draft, and the editor’s job is to tell you so while leaving them in front of you.

The checks go after the orderings where that could slip. Two tabs publish the same document, and the second has to get its 409 and keep its draft. In another step the test watches a save go pending and discards while that save is still queued, which is the ordering that could bring a discarded draft back if the two writes were applied the wrong way round. After a discard and a reload the editor has to show the exact published content, and the check fails on blank text as well as on unrelated text, because an editor that comes back empty after a discard has also lost something. The planning app runs its reviewer at revisions that differ materially, because a person approving an agent’s proposal should be approving the text that proposal was written against.

## A clean console would be the wrong result

Testing failure paths leaves a mess in its own record, and the mess has to stay. A journey that provokes a 400 or a 409, or injects a failed request, produces a real HTTP error and a real console diagnostic. The raw record of the deployed add-card run contains exactly one of each, the deliberate refusal, and the catalogue’s failed searches and the player’s aborted favourite sit in their records too. It would be easy to make these runs look tidy by filtering expected failures, and the filter would then be the place an unexpected failure goes to hide.

The document editor’s run shows what taking that seriously costs. All 14 of its selected behavioural steps passed and the runner still exited 1 with a failed-diagnostics verdict. A separate adjudication worked through why: 13 synthetic navigation errors came from a generation-header assertion that didn’t suit this kind of host, and the 409s were the conflicts we had provoked on purpose. That adjudication sits beside the original result and does not replace it, so the exit 1 is still there. It also records the source, the build artifact and the assets served from each host, apart from the browser’s diagnostics, because whether a person kept their draft and whether this is the code we think it is are different questions, and one noisy runner shouldn’t be answering both.

## Other people’s work

A test’s own writes are writes to somebody’s data as well. The add-card journey archives only the exact card it created. The rename fix has been proved only locally, because the columns on the hosted board are shared and a test has no way to create one of its own, and renaming a column other people are using, in order to prove that renaming looks after people’s work, would have been a strange way to make the point.
