Skip to content

deleteByID throws NotFound on concurrent deletes of the same document #17133

Description

@MurzNN

Describe the Bug

When two or more requests concurrently call DELETE /api/<collection>/<id> (or the local API payload.delete(...)) for the same document ID, the second request receives a 404 Not Found response instead of a success or a meaningful idempotency response.

This is caused by a check-then-act race condition inside deleteByIDOperation. The operation first fetches the document with findOne to perform access checks and run hooks, and only later issues the actual deleteOne. If a concurrent request completes its deleteOne in the window between those two steps, the second request's findOne returns null and a NotFound is thrown with no way to distinguish "document never existed" from "document was just deleted by a concurrent request."

Link to the code that reproduces this issue

https://github.com/Murz-forks/payload-issues/tree/issue/concurrent-delete-not-found

Reproduction Steps

// Assuming a document with id = "abc123" exists
const [result1, result2] = await Promise.all([
  payload.delete({ collection: 'posts', id: 'abc123' }),
  payload.delete({ collection: 'posts', id: 'abc123' }),
])
// One of these will throw NotFound (404)

Or via HTTP:

curl -X DELETE http://localhost:3000/api/posts/abc123 &
curl -X DELETE http://localhost:3000/api/posts/abc123 &
wait
# One request returns 404 Not Found

Root Cause

In packages/payload/src/collections/operations/deleteByID.ts, the operation is split into two separate, non-atomic database calls with significant work in between:

Step 1 — existence check (line 118):

const docToDelete = await req.payload.db.findOne({ collection, locale, req, where })

Step 2 — error thrown if null (line 125):

if (!docToDelete && !hasWhereAccess) {
  throw new NotFound(req.t)
}

Between these two steps, the operation runs checkDocumentLockStatus, deleteAssociatedFiles, deleteCollectionVersions, and deleteScheduledPublishJobs — all before the actual deletion:

Step 3 — actual deletion (line 191):

let result = await req.payload.db.deleteOne({ collection, req, select, where: { id: { equals: id } } })

The race window is the entire span between lines 118 and 191. A transaction is started (initTransaction), but it does not provide serializable row-level locking that would prevent two concurrent transactions from both passing the findOne check. 4

The Drizzle adapter's own deleteOne implementation has the same pattern — a findFirst followed by a separate deleteWhere — compounding the issue: 5

The MongoDB adapter is slightly better here because it uses findOneAndDelete (atomic), but the outer deleteByIDOperation still throws NotFound before even reaching the adapter. 6


Expected Behavior

Concurrent deletes of the same document should be idempotent. If the document has already been deleted by a concurrent request, the second request should either:

  • Return 200 OK with the previously-deleted document data (preferred — matches REST idempotency semantics), or
  • Return 200 OK with an empty/null body, or
  • At minimum, return 404 only when the document never existed, not when it was deleted by a concurrent operation.

Suggested Fix

There are two complementary approaches:

Option A — Make deleteOne atomic and return the deleted doc (preferred for Drizzle adapters)

In packages/drizzle/src/deleteOne.ts, replace the findFirst + deleteWhere two-step with a single atomic DELETE ... RETURNING * SQL statement. This eliminates the race at the adapter level and also makes the result of deleteOne reliable even under concurrency.

Option B — Treat a null result from deleteOne as success in deleteByIDOperation

After the deleteOne call at line 191, if result is null (document was already gone), fall back to docToDelete (fetched earlier) as the result instead of propagating a NotFound. This is safe because docToDelete was already access-checked:

// packages/payload/src/collections/operations/deleteByID.ts
let result = await req.payload.db.deleteOne({ ... })

// Fallback: concurrent delete already removed the doc; use the pre-fetched snapshot
if (!result) {
  result = docToDelete as DataFromCollectionSlug<TSlug>
}

Option C — Catch NotFound at the call site (workaround for users today)

Until a fix lands, callers can wrap concurrent deletes and treat NotFound as success:

import { NotFound } from 'payload'

async function safeDelete(id: string) {
  try {
    return await payload.delete({ collection: 'posts', id })
  } catch (err) {
    if (err instanceof NotFound) return null // already deleted
    throw err
  }
}

Additional Context

  • The checkDocumentLockStatus call between findOne and deleteOne is for the admin UI's document locking feature and is unrelated to concurrency control.
  • This issue is most likely to surface in queue/worker scenarios, webhook handlers, or any code that issues parallel delete requests.

Citations

File: packages/payload/src/collections/operations/deleteByID.ts (L49-49)

    const shouldCommit = !args.disableTransaction && (await initTransaction(args.req))

File: packages/payload/src/collections/operations/deleteByID.ts (L118-123)

    const docToDelete = await req.payload.db.findOne({
      collection: collectionConfig.slug,
      locale: req.locale!,
      req,
      where,
    })

File: packages/payload/src/collections/operations/deleteByID.ts (L125-127)

    if (!docToDelete && !hasWhereAccess) {
      throw new NotFound(req.t)
    }

File: packages/payload/src/collections/operations/deleteByID.ts (L191-196)

    let result: DataFromCollectionSlug<TSlug> = await req.payload.db.deleteOne({
      collection: collectionConfig.slug,
      req,
      select,
      where: { id: { equals: id } },
    })

File: packages/drizzle/src/deleteOne.ts (L46-85)

  if (selectDistinctResult?.[0]?.id) {
    docToDelete = await db.query[tableName].findFirst({
      where: eq(this.tables[tableName].id, selectDistinctResult[0].id),
    })
  } else {
    const findManyArgs = buildFindManyArgs({
      adapter: this,
      depth: 0,
      fields: collection.flattenedFields,
      joinQuery: false,
      select,
      tableName,
    })

    findManyArgs.where = where

    docToDelete = await db.query[tableName].findFirst(findManyArgs)
  }

  if (!docToDelete) {
    return null
  }

  const result =
    returning === false
      ? null
      : transform({
          adapter: this,
          config: this.payload.config,
          data: docToDelete,
          fields: collection.flattenedFields,
          joinQuery: false,
          tableName,
        })

  await this.deleteWhere({
    db,
    tableName,
    where: eq(this.tables[tableName].id, docToDelete.id),
  })

File: packages/db-mongodb/src/deleteOne.ts (L39-43)

  const doc = await Model.findOneAndDelete(query, options)?.lean()

  if (!doc) {
    return null
  }

Which area(s) are affected?

area: core

Environment Info

Binaries:
  Node: 24.15.0
  npm: 11.12.1
  Yarn: N/A
  pnpm: 10.27.0
Relevant Packages:

Operating System:
  Platform: linux
  Arch: x64
  Version: #27-Ubuntu SMP PREEMPT_DYNAMIC Thu Jun 18 19:13:49 UTC 2026
  Available memory (MB): 31515
  Available CPU cores: 16

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: coreCore Payload functionalitystatus: needs-triagePossible bug which hasn't been reproduced yetv3

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions