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
Describe the Bug
When two or more requests concurrently call
DELETE /api/<collection>/<id>(or the local APIpayload.delete(...)) for the same document ID, the second request receives a404 Not Foundresponse 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 withfindOneto perform access checks and run hooks, and only later issues the actualdeleteOne. If a concurrent request completes itsdeleteOnein the window between those two steps, the second request'sfindOnereturnsnulland aNotFoundis 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
Or via HTTP:
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):
Step 2 — error thrown if null (line 125):
Between these two steps, the operation runs
checkDocumentLockStatus,deleteAssociatedFiles,deleteCollectionVersions, anddeleteScheduledPublishJobs— all before the actual deletion:Step 3 — actual deletion (line 191):
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 thefindOnecheck. 4The Drizzle adapter's own
deleteOneimplementation has the same pattern — afindFirstfollowed by a separatedeleteWhere— compounding the issue: 5The MongoDB adapter is slightly better here because it uses
findOneAndDelete(atomic), but the outerdeleteByIDOperationstill throwsNotFoundbefore even reaching the adapter. 6Expected 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:
200 OKwith the previously-deleted document data (preferred — matches REST idempotency semantics), or200 OKwith an empty/null body, or404only when the document never existed, not when it was deleted by a concurrent operation.Suggested Fix
There are two complementary approaches:
Option A — Make
deleteOneatomic and return the deleted doc (preferred for Drizzle adapters)In
packages/drizzle/src/deleteOne.ts, replace thefindFirst+deleteWheretwo-step with a single atomicDELETE ... RETURNING *SQL statement. This eliminates the race at the adapter level and also makes the result ofdeleteOnereliable even under concurrency.Option B — Treat a
nullresult fromdeleteOneas success indeleteByIDOperationAfter the
deleteOnecall at line 191, ifresultisnull(document was already gone), fall back todocToDelete(fetched earlier) as the result instead of propagating aNotFound. This is safe becausedocToDeletewas already access-checked:Option C — Catch
NotFoundat the call site (workaround for users today)Until a fix lands, callers can wrap concurrent deletes and treat
NotFoundas success:Additional Context
checkDocumentLockStatuscall betweenfindOneanddeleteOneis for the admin UI's document locking feature and is unrelated to concurrency control.Citations
File: packages/payload/src/collections/operations/deleteByID.ts (L49-49)
File: packages/payload/src/collections/operations/deleteByID.ts (L118-123)
File: packages/payload/src/collections/operations/deleteByID.ts (L125-127)
File: packages/payload/src/collections/operations/deleteByID.ts (L191-196)
File: packages/drizzle/src/deleteOne.ts (L46-85)
File: packages/db-mongodb/src/deleteOne.ts (L39-43)
Which area(s) are affected?
area: core
Environment Info