DEV Community

Cover image for Reliable Form Submissions: Dirty State, Fingerprints, and Idempotency
mohammad rostami
mohammad rostami

Posted on

Reliable Form Submissions: Dirty State, Fingerprints, and Idempotency

A disabled Submit button protects the UI. It does not protect the data.

It is tempting to treat this as enough protection against duplicate submissions:

<button type="submit" disabled={isSubmitting}>
  Submit
</button>
Enter fullscreen mode Exit fullscreen mode

The button is worth disabling. It gives the user immediate feedback and prevents an obvious second click. But it only controls one interaction in one browser tab.

It cannot tell us whether a request was retried by the client, whether two requests crossed the network at nearly the same time, or whether the server completed a write before the response disappeared.

Consider a small order form:

type OrderFormValues = {
  firstName: string;
  lastName: string;
  amount: number;
  price: number;
};
Enter fullscreen mode Exit fullscreen mode

A straightforward submission might look like this:

await fetch("/api/orders", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(values),
});
Enter fullscreen mode Exit fullscreen mode

Now imagine that the server creates the order, but the response never reaches the browser. The UI sees a network error. The user tries again. Without any other protection, the second request can create a second order.

The difficult part is not the second click. The difficult part is deciding whether the second request represents a new operation or a retry of the first one.

The mental model I use separates two identities:

  • A fingerprint identifies the content of the request.
  • An idempotency key identifies the operation the user intended to perform.

They solve different problems, and neither is a replacement for the other.

Content identity starts with normalization

Two form values can be different in JavaScript while representing the same business input.

These names are not byte-for-byte equal:

"Sara"
" Sara "
Enter fullscreen mode Exit fullscreen mode

But if the application trims names before saving them, that whitespace is not a meaningful change. The same issue appears with values such as "150" and 150, or 150 and 150.0.

I prefer to make those rules explicit before creating a fingerprint:

function normalizeOrder(values: OrderFormValues) {
  return {
    firstName: values.firstName.trim(),
    lastName: values.lastName.trim(),
    amount: Number(values.amount),
    priceInCents: Math.round(Number(values.price) * 100),
  };
}

function canonicalizeOrder(values: OrderFormValues): string {
  return JSON.stringify(normalizeOrder(values));
}
Enter fullscreen mode Exit fullscreen mode

Converting the price to cents is part of the example's business normalization. It avoids using small floating-point differences as evidence that two prices are different.

For local comparisons, the canonical string is already a useful fingerprint:

const fingerprint = canonicalizeOrder(values);
Enter fullscreen mode Exit fullscreen mode

For storage or comparison across system boundaries, I would usually hash that canonical value:

async function createOrderFingerprint(
  values: OrderFormValues,
): Promise<string> {
  const payload = canonicalizeOrder(values);
  const bytes = new TextEncoder().encode(payload);
  const digest = await crypto.subtle.digest("SHA-256", bytes);

  return Array.from(new Uint8Array(digest), (byte) =>
    byte.toString(16).padStart(2, "0"),
  ).join("");
}
Enter fullscreen mode Exit fullscreen mode

The normalization rules must come from the domain. If array order has no business meaning, sort the array before hashing it. If order is meaningful, sorting it would create the wrong identity. A fingerprint is only as correct as the canonical representation behind it.

Dirty state is also a domain question

Form libraries usually expose an isDirty flag. That flag is useful, but it normally compares raw form values with the original defaults. It does not know which differences matter to the business.

A business-aware check can compare the initial and current fingerprints instead:

const initialFingerprint = canonicalizeOrder(initialValues);
const currentFingerprint = canonicalizeOrder(currentValues);

const isMeaningfullyDirty =
  currentFingerprint !== initialFingerprint;
Enter fullscreen mode Exit fullscreen mode

If the user changes "Sara" to " Sara ", the raw form may be dirty while isMeaningfullyDirty remains false.

If the user changes amount from 2 to 3, the fingerprint changes. If they change it back to 2, the fingerprints match again and the form is no longer meaningfully dirty.

This is also why UI-only state should stay out of the fingerprint. A search query, an expanded panel, or the selected tab may change the component state without changing the order that will be sent to the server.

For example, customerSearch does not belong in the normalized payload:

type OrderPageState = OrderFormValues & {
  customerSearch: string;
};

function normalizeOrderPage(values: OrderPageState) {
  return normalizeOrder(values);
}
Enter fullscreen mode Exit fullscreen mode

Using the same canonicalization rules for Dirty State and submission gives both features the same definition of a meaningful change.

Operation identity belongs to the idempotency key

A fingerprint tells us that two payloads contain the same business data. It does not tell us whether the user intended one operation or two.

A user may intentionally create two identical orders. Those orders have the same fingerprint, but they are still separate operations. This is why I would not derive the idempotency key from the fingerprint:

// Incorrect: identical payloads can still be separate operations.
const idempotencyKey = fingerprint;
Enter fullscreen mode Exit fullscreen mode

Instead, the client creates a random key for a new operation and reuses it only when retrying that operation with the same fingerprint.

type PendingOperation = {
  fingerprint: string;
  idempotencyKey: string;
};

let pendingOperation: PendingOperation | null = null;

async function submitOrder(values: OrderFormValues) {
  const fingerprint = canonicalizeOrder(values);

  const operation =
    pendingOperation?.fingerprint === fingerprint
      ? pendingOperation
      : {
          fingerprint,
          idempotencyKey: crypto.randomUUID(),
        };

  pendingOperation = operation;

  const response = await fetch("/api/orders", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Idempotency-Key": operation.idempotencyKey,
    },
    body: JSON.stringify(values),
  });

  if (!response.ok) {
    throw new Error("Order submission failed");
  }

  const result = await response.json();

  // A later submission now represents a new operation,
  // even if the user enters the same values again.
  pendingOperation = null;

  return result;
}
Enter fullscreen mode Exit fullscreen mode

There are two deliberate choices in this code.

First, a failed attempt does not immediately discard the key. Retrying the same payload should reuse the identity of the pending operation.

Second, a successful response clears that identity. If the user intentionally submits the same values again later, the client generates a new key and the server is allowed to create another order.

Disabling the button still makes sense, but the key is what allows the server to recognize a repeated operation.

The server has to bind the key to the payload

The client can help manage operation identity, but the guarantee must exist on the server.

For each idempotency key, the server needs to store at least:

type IdempotencyRecord = {
  key: string;
  fingerprint: string;
  status: "processing" | "succeeded";
  response?: {
    orderId: string;
  };
};
Enter fullscreen mode Exit fullscreen mode

The server should calculate the fingerprint itself. Trusting a fingerprint supplied by the browser would allow a modified client to claim that two different payloads are identical.

The key also has to be claimed atomically. A separate find() followed by insert() leaves a race in which two requests can both observe that the key is missing.

The storage API can expose that atomic decision directly:

type ClaimResult =
  | { kind: "new" }
  | { kind: "mismatch" }
  | { kind: "processing" }
  | {
      kind: "completed";
      response: { orderId: string };
    };

async function createOrder(request: Request) {
  const key = request.headers.get("Idempotency-Key");

  if (!key) {
    return new Response("Idempotency key is required", { status: 400 });
  }

  const values = (await request.json()) as OrderFormValues;
  const fingerprint = await createOrderFingerprint(values);

  const claim: ClaimResult = await idempotencyStore.claim({
    key,
    fingerprint,
  });

  if (claim.kind === "mismatch") {
    return new Response("Key reused with a different payload", {
      status: 422,
    });
  }

  if (claim.kind === "completed") {
    return Response.json(claim.response);
  }

  if (claim.kind === "processing") {
    return new Response("Operation is already processing", {
      status: 202,
    });
  }

  const order = await orderRepository.create(normalizeOrder(values));
  const response = { orderId: order.id };

  await idempotencyStore.complete({ key, response });

  return Response.json(response, { status: 201 });
}
Enter fullscreen mode Exit fullscreen mode

In a relational database, a unique constraint on the key is the minimum protection against concurrent claims:

CREATE UNIQUE INDEX idempotency_key_unique
ON idempotency_records (key);
Enter fullscreen mode Exit fullscreen mode

The exact persistence model depends on the operation, but the invariant should remain the same:

  • The same key and the same fingerprint refer to the same operation.
  • The same key with a different fingerprint is rejected.
  • A new key represents a new operation, even when its fingerprint matches an older one.

The mistakes I would avoid

The first mistake is treating isSubmitting as a data-integrity mechanism. It is UI state, and it cannot protect the server from concurrent or repeated requests.

The second is using a fingerprint as the idempotency key. That incorrectly merges separate operations that happen to have identical payloads.

The third is hashing raw form state. Whitespace, numeric representation, property order, or UI-only fields can create different hashes for the same business input. Canonicalization has to happen before hashing.

The final mistake is implementing the guarantee only in the browser. The browser can choose and reuse a key, but only durable, atomic server-side storage can enforce it.

The compact version of the model is:

Dirty State compares the current content with the initial content.

A fingerprint identifies that content.

An idempotency key identifies the operation performed with it.

Once those responsibilities are separate, the Submit button can remain what it should be: a useful interaction detail, not the last line of defense against duplicate writes.

Top comments (0)