Product Engineering

Forms are where product quality leaks

7 min read

Forms are where the screenshot stops helping.

The first screenshot usually looks fine. Labels line up. The primary button is obvious. The empty fields have tasteful placeholders. Everyone nods.

Then a real user types a postal code the validation did not expect, tabs through the fields with a keyboard, loses network halfway through submit, double-clicks the button, pastes a value with trailing whitespace, hits the browser back button, returns to a partially saved draft, and gets an error that says “Something went wrong.”

That is where product quality leaks.

Forms are not glamorous, but they are full of product decisions. They decide what the system accepts, when the user learns something is wrong, what happens to work in progress, how duplicate submissions are handled, and whether failure feels recoverable.

validation is timing

Validation is not one thing. It is timing.

Some validation belongs before submit. Required fields, obvious format issues, length limits, and impossible choices should be caught early enough that the user can fix them while the field is still in context. Some validation belongs after submit because only the server knows the answer: username availability, payment authorization, policy eligibility, permission checks, or whether a record changed while the user was editing.

The product gets annoying when validation timing is wrong.

Validate too early and the form scolds the user while they are still typing. Validate too late and the user fills out the whole thing only to learn the first field was invalid. Validate only on the client and the server rejects something the interface promised was fine. Validate only on the server and the interface feels lazy.

I like thinking of validation in layers:

field: local format and required state
form: cross-field consistency
server: uniqueness, permissions, policy, current state
post-submit: side effects and async processing

Each layer needs copy. “Invalid input” is not copy. “Use a work email address” is a decision. “This project name is already used in this workspace” is a useful error. “You do not have permission to invite billing admins” is a product boundary.

disabled buttons hide problems

Disabled submit buttons are often used as a shortcut for missing validation design.

The button is gray. The user cannot continue. Something is wrong. Good luck finding it.

Sometimes disabling is appropriate. A submit button should not fire while required data is missing if the form makes the missing data obvious. It should not fire twice during an in-flight request. It should not allow an action the user is not allowed to take.

But a disabled button without explanation is a dead end. The form should make the blocker visible near the field or action it belongs to.

I usually prefer one of these patterns:

  • show inline field errors after blur or submit
  • keep the button enabled and show a summary on submit
  • disable only while submitting, with clear pending state
  • explain permission-based disabled states directly
  • focus the first invalid field after submit

The important part is that the user learns what to do next. A disabled button that silently encodes five hidden conditions is not a product decision. It is a puzzle.

pending is a real state

Submitting is not instantaneous. The form should admit that.

A good pending state prevents duplicate action, preserves the user’s input, and explains what is happening. It also avoids fake certainty. “Saving…” is fine when the action is saving. “Creating account…” is better than a spinner if the action has weight. “This may take a minute” is useful when the backend triggers slow work.

The backend matters here. The frontend can disable a button, but idempotency has to exist at the boundary. If a user double-submits because the page froze or the network retried, the server should know whether it is seeing the same request again.

For important mutations, I want an idempotency key:

type CreateProjectRequest = {
  idempotencyKey: string
  name: string
  workspaceId: string
  templateId?: string
}

The UI can generate the key when the user starts the submit. The server can use it to avoid creating two projects for one intent. That is form quality even though the user never sees the key.

Forms are cross-boundary features. The UI and API have to agree on failure.

partial validity needs a plan

Many forms are not all-or-nothing.

A profile can save the name while the avatar upload fails. A billing form can accept address changes while the card processor rejects the payment method. A bulk invite form can create seven users and reject three rows. A settings page can save local preferences while a permission-protected option fails.

If the product treats every partial result as generic failure, users lose trust. If it treats every partial result as success, users miss important work.

Partial validity needs a visible result model:

{
  "status": "partial",
  "saved": ["displayName", "timezone"],
  "failed": [
    {
      "field": "avatar",
      "reason": "File is larger than 5 MB"
    }
  ]
}

That response shape lets the UI say what happened. It also lets the user recover without redoing successful work.

Bulk forms need this even more. A CSV import that fails on row 37 should not make the user guess which rows were accepted. The result should preserve row numbers, reasons, and retry options.

error copy is product design

Error copy is where the system reveals whether it understands the user.

“Something went wrong” is sometimes honest, but it should be a last resort. Most form failures are knowable enough to explain. The record already exists. The user lacks permission. The value is too long. The file type is unsupported. The session expired. The payment provider declined the card. The downstream service timed out.

Each one needs a different recovery path.

I care less about clever wording than useful specificity:

Bad: Could not save.
Better: We could not save because your session expired. Sign in again and your draft will still be here.

Bad: Invalid file.
Better: Upload a PNG or JPG under 5 MB.

Bad: Request failed.
Better: The project was created, but template setup is still running. Refresh in a minute.

The best error copy usually comes from a better error model. If the backend returns one generic exception, the frontend can only decorate confusion.

drafts are respect

If a form takes more than a minute to fill out, losing it should be treated as a bug.

Draft behavior does not have to be elaborate. Local storage, server-side drafts, URL state, or simple browser preservation can be enough depending on the risk. The product should decide what deserves persistence.

A two-field newsletter form probably does not need a draft. A tax form, incident report, application, onboarding flow, or long configuration wizard absolutely does.

Drafts create their own decisions:

  • when to save
  • where to store
  • how to handle sensitive fields
  • how long to keep drafts
  • how to recover after submit failure
  • how to resolve a draft when the underlying record changed

Again, the screenshot does not show any of this. The user feels it immediately.

accessibility is not a polish pass

Forms are one of the easiest places to create accessibility bugs.

Labels need to be programmatically associated with fields. Error messages need to be connected to the controls they describe. Focus should move predictably after submit. Required fields should not rely only on color. Keyboard navigation should follow the visual order. Autocomplete attributes should help the browser help the user. Native controls should be used when they do the job.

This is not charity. It is interface correctness.

If a user cannot tell which field failed, cannot reach the field, cannot understand the error, or cannot submit without a mouse, the form is broken. The fact that it looked good in the design review does not matter.

form quality is mostly hidden work

A good form feels uneventful. That is why it is easy to underbuild.

The hidden work is where quality lives: validation timing, pending state, idempotency, partial success, error copy, draft recovery, focus management, server contracts, and analytics that show where users abandon the flow.

I would rather ship a plain form that handles failure cleanly than a beautiful form that loses input and blames the user.

The screenshot gets the meeting started. The weird states decide whether the product is any good.

Jeremy London

About Jeremy London

Engineering leader and builder in Denver. I write about AI platforms, agents, security, reliability, homelab infrastructure, and the parts of engineering work that have to survive production.