Software Engineering

Prevent Duplicate Form Submissions with Idempotency Keys

Why retries and double clicks create duplicate records and emails, and how to make submissions idempotent with a client key, unique index and fingerprint.

Quick answer

To prevent duplicate submissions, have the client generate a unique idempotency key for each logical submission and resend the same key on every retry. On the server, store the key with a unique database index, return the original result when the key is seen again, and reject reuse of a key with different content.

Key takeaways

  • Disabling the submit button helps people, but it cannot stop network retries or double requests.
  • Generate the key once per submission on the client, and keep it across retries.
  • A unique index makes the database the final judge, which handles concurrent requests safely.
  • Store a fingerprint of the content so a reused key with different data is rejected, not silently accepted.
  • Save the record before sending side effects such as email, and record their status separately.

Why do duplicate submissions happen?

A request can be sent more than once even when a person clicks only once. Mobile networks drop responses after the server has already processed the request; the browser or the user retries; a slow server makes someone click again. Without protection, each attempt creates another record and sends another email.

An operation is idempotent when performing it several times has the same effect as performing it once. HTTP defines GET, PUT and DELETE as idempotent, but POST is not, so form submissions need idempotency designed in.

How do you generate an idempotency key?

Generate a random UUID when the person starts a submission, and keep it in component state. Send it with every attempt of that submission. Create a new key only after a successful submission, when the form resets for a new message.

const [requestId, setRequestId] = useState(() => crypto.randomUUID());

async function submit(values: FormValues) {
  const res = await fetch("/api/inquiries", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ ...values, requestId }),
  });
  if (res.ok) setRequestId(crypto.randomUUID()); // next submission is new
}

How should the server handle a repeated key?

  1. Look up the key. If it exists with the same content, return the original response.
  2. If it exists with different content, return 409 Conflict.
  3. Otherwise insert the new record, relying on a unique index on the key.
  4. If the insert fails because of the unique index, another request won the race: load that record and return its response.
await inquiries.createIndex({ requestId: 1 }, { unique: true });

const fingerprint = sha256(JSON.stringify({ name, email, message }));
const existing = await inquiries.findOne({ requestId });
if (existing) {
  return existing.fingerprint === fingerprint
    ? created(existing)
    : conflict("This submission was already used.");
}
try {
  await inquiries.insertOne({ requestId, fingerprint, name, email, message });
} catch (error) {
  if (isDuplicateKeyError(error)) {
    return created(await inquiries.findOne({ requestId }));
  }
  throw error;
}

The lookup alone is not enough: two concurrent requests can both find nothing and both insert. The unique index makes the second insert fail, so only one record is ever created.

How do you keep emails and other side effects from duplicating?

Perform side effects only after the insert succeeds, and only in the request that created the record. A replayed request returns the stored result without sending anything again.

Record the status of each side effect, such as accepted, failed or not configured, on the saved record. If email delivery fails, the submission is still saved and someone can follow up. If the process stops before the status is written, the record stays pending, which tells an operator to check the mailbox before resending.

How we use this on the Naarvix website

The inquiry form on this site follows this pattern. The browser assigns a UUID to each submission and keeps it on retry. MongoDB enforces a unique index on that key, a SHA-256 fingerprint detects a changed message under a reused key, and confirmation and notification emails each have their own recorded status. A database failure returns 503 and sends no email, so visitors are never told an inquiry succeeded when it was not saved.

Frequently asked questions

Is disabling the submit button enough to prevent duplicates?

No. It prevents some double clicks, but cannot prevent network retries, multiple tabs or requests sent by scripts. Server-side idempotency is required.

Should the idempotency key be sent in a header or the body?

Either works. Public APIs often use an Idempotency-Key header. For a single form endpoint, a validated field in the JSON body is simple and explicit.

How long should idempotency keys be stored?

For records that are kept anyway, such as inquiries or orders, store the key on the record. For high-volume APIs, keep keys at least as long as clients may retry, often 24 hours, and expire them with a TTL index.

WRITTEN BY

Nikhil Kumar

Co-founder, Naarvix Solutions · Senior Software Developer

A senior software developer and Naarvix co-founder, Nikhil brings an engineering perspective to the company’s products and client projects. His focus is on turning ideas into practical software, with care for how it works and the people who use it.

Start a conversation

Let’s find your next step.

Bring a goal, an idea or a problem worth solving.

Talk to Naarvix