SiteError.comYour friendly guide to HTTP status codes
Status CodesBlog
  1. Home
  2. Blog
  3. Understanding HTTP 409 Conflict: State Conflicts, Optimistic Locking, and Why It Isn't a Validation Error

Understanding HTTP 409 Conflict: State Conflicts, Optimistic Locking, and Why It Isn't a Validation Error

August 13, 20269 min read
4xxClient Error

409 Conflict gets reached for constantly, and used correctly far less often. It's tempting to fire it at any request the server doesn't like — a duplicate signup, a stale form submission, a field that fails a business rule — but the RFC has a narrower job in mind for it: 409 is specifically for requests that are valid but collide with the resource's current state. This post covers what 409 actually promises, why it's the wrong tool for ordinary validation failures, how optimistic locking and duplicate-resource conflicts produce it in practice, and how to return it correctly.

What Is a 409?

A 409 tells the client that the request itself was fine — well-formed, semantically sound — but the server can't complete it because it collides with the resource's current state.

The 409 (Conflict) status code indicates that the request could not be completed due to a conflict with the current state of the target resource. This code is used in situations where the user might be able to resolve the conflict and resubmit the request. — RFC 9110, Section 15.5.10

In plain English: "I can't do what you asked because it conflicts with the current state of the resource." Trying to create a user with an email that's already taken, or updating a record that someone else modified since you last read it, are both 409s — the request would have worked fine against a different state, and that's exactly what makes it a conflict rather than a validation error.

Don't overuse 409 for all validation errors — use 400 or 422 for basic validation failures. Reserve 409 for genuine state conflicts, especially in scenarios involving optimistic locking, duplicate resources, or version conflicts.

Why 409 Isn't a Validation Error

This is the distinction that gets blurred most: 409, 400, and 422 all sit in the "your request has a problem" family, but they answer different questions about where the problem lives.

  • 400 asks: is the request even parseable? Malformed JSON, a missing required field, garbage query parameters — the server can't make sense of what you sent, full stop.
  • 422 asks: is the request semantically valid on its own terms? The syntax is fine and every field is present, but the values don't satisfy a business rule — an end date before a start date, an out-of-range quantity.
  • 409 asks: would this request succeed against a different server state? The request is syntactically fine and semantically valid in isolation — the problem only exists because of what the resource currently looks like.

The practical test: if you retried the exact same request against the resource's state five minutes ago (or five minutes from now, after someone else's change), would it succeed? If the answer is "yes, under different state," that's a 409. If the request is broken no matter what state the resource is in, that's a 400 or 422.

409 vs 400 vs 422

CodeMeaningUse when
400 Bad RequestMalformed requestThe request can't even be parsed — bad JSON, missing fields, invalid parameters
422 Unprocessable EntitySemantically invalid requestThe request parses fine but the data violates a business rule, independent of current state
409 ConflictState conflictThe request is valid on its own, but collides with the resource's current state — a duplicate, a stale version, a concurrent edit

409 is the only one of the three where the same request body could succeed later, or would have succeeded earlier — the problem is timing and state, not content. That's also why 409 responses are meant to be actionable: the RFC explicitly frames it as a conflict "the user might be able to resolve... and resubmit," which is a different promise than "fix your input and try again."

Common Causes

The recurring shapes of a genuine 409:

  1. Editing an outdated version of a resource — the client fetched a record, someone else updated it, and now the client's write is based on stale data (the classic optimistic-locking scenario).
  2. A username, email, or slug that's already taken — the request is a perfectly valid "create" operation; it's the uniqueness constraint on current state that fails.
  3. Conflicting concurrent updates — two clients modify the same resource around the same time, and the second write can't be safely applied without clobbering the first.

Optimistic Locking: The Canonical 409 Pattern

The most disciplined way to produce a legitimate 409 is optimistic concurrency control: track a version (a version integer, an updatedAt timestamp, or an ETag) on the resource, require the client to send back the version it last read, and reject the write if it's stale.

// Express / Node.js
app.put("/articles/:id", async (req, res) => {
  const { id } = req.params;
  const { expectedVersion, ...updates } = req.body;
 
  const article = await db.getArticle(id);
  if (!article) {
    return res.status(404).json({ error: "not_found" });
  }
 
  if (article.version !== expectedVersion) {
    // The request is well-formed, but it targets a state that no longer exists
    return res.status(409).json({
      error: "conflict",
      message: "This article was modified since you last loaded it.",
      currentVersion: article.version,
    });
  }
 
  const updated = await db.updateArticle(id, {
    ...updates,
    version: article.version + 1,
  });
  res.json(updated);
});

This is the same underlying idea as an If-Match precondition on a PUT — a version mismatch produces a rejected write specifically because the resource's state moved out from under the client. The difference is that 409 is the application-level version of that story (your own version field, checked in your own handler), while 412 Precondition Failed is the HTTP-level version (an ETag checked via a conditional header before the handler even runs). Both exist to stop a client from silently overwriting someone else's change — the lost-update problem — they just operate at different layers.

Returning 409 Correctly

Express / Node.js

app.post("/users", async (req, res) => {
  const { email } = req.body;
 
  const existing = await db.findUserByEmail(email);
  if (existing) {
    return res.status(409).json({
      error: "conflict",
      message: "A user with this email already exists.",
    });
  }
 
  const user = await db.createUser(req.body);
  res.status(201).json(user);
});

Next.js App Router

// app/api/users/route.ts
export async function POST(request: Request) {
  const { email, ...rest } = await request.json();
 
  const existing = await findUserByEmail(email);
  if (existing) {
    return Response.json(
      { error: "conflict", message: "A user with this email already exists." },
      { status: 409 },
    );
  }
 
  const user = await createUser({ email, ...rest });
  return Response.json(user, { status: 201 });
}

NextResponse.json() works the same way if you'd rather import it from next/server — either form takes the status code as part of the response init, the same as any other non-2xx response you'd return from a Route Handler.

NGINX

NGINX itself doesn't generate 409s — conflict detection is inherently an application-layer concern, since it depends on comparing a request against your data's current state. Where NGINX does show up is passing the upstream's 409 straight through:

location /api/ {
    proxy_pass http://backend;
    # 409 responses from the backend are forwarded as-is; nothing
    # to configure here unless you want to rewrite the body.
}

REST API JSON body

HTTP/1.1 409 Conflict
Content-Type: application/json
 
{
  "error": "conflict",
  "message": "The resource was modified by another request. Refresh and try again.",
  "currentVersion": 7
}

Always give the client something to act on — the current version, the conflicting field, or a pointer to the existing resource — since the RFC's own framing is that 409 is a conflict the user might resolve and resubmit, not a dead end.

Common Pitfalls

  1. Reaching for 409 on ordinary validation failures. If a request would fail no matter what state the server is in, that's a 400 or 422, not a 409 — save 409 for conflicts that depend on current state.
  2. Returning 409 without enough information to resolve it. A bare "conflict" with no version, no conflicting field, and no current value leaves the client nothing to act on — defeats the RFC's own framing of 409 as resolvable.
  3. Hard-failing concurrent writes instead of detecting them. Without a version or timestamp check, "conflicting concurrent updates" just silently overwrite each other instead of surfacing as a 409 — you need something to compare against before you can detect a conflict at all.
  4. Using 409 for duplicate-request idempotency instead of returning the existing resource. If a client retries a POST it already succeeded at (e.g., a network retry), consider returning the existing resource with 200/201 via an idempotency key rather than a 409 — a 409 implies the client did something wrong, not that the server is being extra careful.
  5. Forgetting that 409 is about the target resource, not the request body. A field-level typo is a 422 problem; a 409 is about the resource identified by the URL colliding with what the request tries to do to it.

Wrapping Up

409 rewards precision about why a request failed. The rules of thumb:

  • 409 means the request is valid, but conflicts with the resource's current state — not "your input is wrong"
  • Basic validation failures belong to 400 or 422, not 409 — save it for genuine state conflicts
  • Optimistic locking (version fields, ETags) is what makes 409 detectable in the first place
  • A useful 409 response tells the client what changed and how to resolve it, since the conflict is meant to be resubmittable

For more, see our pages on 409 Conflict, 400 Bad Request, and 422 Unprocessable Entity.

Full Reference

409 Conflict

The request conflicts with the current state of the server.

Related Status Codes

🤨400Bad Request🔐401Unauthorized💳402Payment Required🚫403Forbidden🔍404Not Found🙅405Method Not Allowed🍽️406Not Acceptable🎫407Proxy Authentication Required⏰408Request Timeout⚔️409Conflict👻410Gone📏411Length Required❌412Precondition Failed📦413Payload Too Large📜414URI Too Long📼415Unsupported Media Type📖416Range Not Satisfiable😞417Expectation Failed🫖418I'm a Teapot🚪421Misdirected Request🤔422Unprocessable Entity🔒423Locked🎯424Failed Dependency⏰425Too Early⬆️426Upgrade Required🔑428Precondition Required🚦429Too Many Requests📋431Request Header Fields Too Large⚖️451Unavailable For Legal Reasons
Back to Blog

Popular Status Codes

  • 200 OK
  • 301 Moved Permanently
  • 302 Found
  • 400 Bad Request
  • 401 Unauthorized
  • 403 Forbidden
  • 404 Not Found
  • 500 Internal Server Error
  • 502 Bad Gateway
  • 503 Service Unavailable

Compare Codes

  • 401 vs 403
  • 301 vs 302
  • 404 vs 410
  • 500 vs 502
  • Compare any codes →

Categories

  • Informational
  • Success
  • Redirection
  • Client Error
  • Server Error
  • NGINX
  • Cloudflare
  • AWS ELB
  • Microsoft IIS

Tools

  • Cheat Sheet
  • Status Code Quiz
  • URL Checker
  • API Playground
  • Blog

© 2026 SiteError.com. All rights reserved.