How to Safely Manipulate Payload CMS Data in Hooks Without Hanging or Recursion
Fix PostgreSQL transaction deadlocks, foreign key constraints, and infinite loops in Payload CMS hooks

📚 Comprehensive Payload CMS Guides
Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.
I was building an inventory management system with Payload CMS and PostgreSQL when I hit a wall. Every time I tried to create related inventory transactions inside an afterChange
hook, the operation would either hang indefinitely or throw cryptic foreign key constraint errors. After digging through GitHub issues and testing multiple approaches, I discovered the critical patterns that make hook data manipulation work reliably. This guide shows you exactly how to avoid the most common pitfalls that cause req.payload.update()
hanging, infinite loops, and transaction deadlocks in Payload CMS.
The Problem: Why Hooks Hang and Fail
When you create or update documents inside Payload hooks, you're working within an active database transaction. The most common mistake is breaking out of that transaction context, which leads to three critical issues.
First, you'll encounter foreign key constraint violations. When I first tried to create inventory transactions in my afterChange
hook, I got this error:
ERROR: Failed to create received transaction for product
error: {
"code": "23503",
"detail": "Key (delivery_window_batches_id)=(7) is not present in table \"delivery_window_batches\".",
"constraint": "inventory_transactions_rels_delivery_window_batches_fk"
}
The batch document existed in my code, but PostgreSQL couldn't see it yet because the transaction hadn't committed. The relationship table couldn't find the foreign key reference.
Second, operations hang indefinitely when you trigger recursive hooks. A widely-discussed GitHub issue explains that req.payload.update()
inside hooks causes deadlocks in PostgreSQL. Every update call triggers hooks again, and if your hook logic calls update on the same or related collections, you create an infinite loop or database deadlock. Developers report their servers completely freezing with no error message, just endless waiting.
Third, using workarounds like setImmediate()
or setTimeout()
breaks transaction safety. You might think deferring the operation will help, but once you leave the hook execution context, the req
object may no longer be valid and you've lost the transaction boundary that ensures data consistency.
The Wrong Approach: Common Mistakes
Here's what I tried first that caused the hanging and constraint errors:
// File: src/collections/DeliveryWindowBatches.ts
afterChange: [
async ({ doc, operation, req }) => {
// ❌ WRONG: Using setImmediate breaks transaction context
if (operation === 'create' && Array.isArray(doc.products)) {
setImmediate(async () => {
for (const productEntry of doc.products) {
await req.payload.create({
collection: 'inventory-transactions',
data: {
inventoryBatch: {
relationTo: 'delivery-window-batches',
value: doc.id,
},
product: productEntry.product,
type: 'received',
quantityDelta: productEntry.totalStock || 0,
},
});
}
});
}
return doc;
},
],
This code has multiple fatal problems. The setImmediate()
defers execution until after the current transaction completes, so when the inventory transaction tries to reference the batch via foreign key, PostgreSQL returns the "is not present in table" constraint violation. The req
object might also be invalid outside the hook context.
Another common mistake is not passing req
to nested operations:
// ❌ WRONG: Not passing req loses transaction context
await req.payload.create({
collection: 'inventory-transactions',
data: { /* ... */ },
// Missing: req property
});
This creates a new transaction instead of staying within the current one, breaking atomicity and causing timing issues.
The third critical mistake is triggering infinite recursion:
// ❌ WRONG: This causes infinite loops
afterChange: [
async ({ doc, req }) => {
// This update triggers afterChange again, which triggers another update...
await req.payload.update({
collection: 'delivery-window-batches',
id: doc.id,
data: { someField: 'updated' },
});
return doc;
},
],
Every update triggers the hook again. With PostgreSQL's Promise.all
handling, this creates parallel transactions that deadlock, causing the operation to hang with no error message.
The Correct Pattern: Transaction-Safe Hook Operations
The solution requires three key elements: explicitly passing req
, using context flags to prevent recursion, and staying within the hook execution flow without deferring operations.
Here's the working implementation from my delivery window batch system:
// File: src/collections/DeliveryWindowBatches.ts
import type {
CollectionBeforeChangeHook,
CollectionConfig,
} from "payload";
import type { DeliveryWindowBatch } from "@payload-types";
type BeforeChangeArgs = Parameters<CollectionBeforeChangeHook<DeliveryWindowBatch>>[0];
export const DeliveryWindowBatches: CollectionConfig = {
slug: "delivery-window-batches",
hooks: {
beforeChange: [
async ({ data, operation, req, context }: BeforeChangeArgs) => {
// Skip expensive validation when updating from order inventory hook
if (context?.skipInventoryHooks) {
console.log('Skipping batch validation (inventory hook context)');
return data;
}
// ... your validation logic here ...
return data;
},
],
afterChange: [
async ({ doc, operation, req, context }) => {
// Skip during seeding
if (process.env.SEED === 'true') {
return doc;
}
// ✅ CORRECT: Check context flag to prevent recursion
if (context?.skipInventoryHooks) {
console.log('Skipping afterChange (inventory hook context)');
return doc;
}
// Create "received" transactions for each product when batch is created
if (operation === 'create' && Array.isArray(doc.products)) {
for (const productEntry of doc.products) {
try {
// ✅ CORRECT: Pass req explicitly and set context flag
await req.payload.create({
collection: 'inventory-transactions',
req, // Stay within same transaction
context: {
...context,
skipInventoryHooks: true, // Prevent inventory hooks from updating this batch
},
data: {
inventoryBatch: {
relationTo: 'delivery-window-batches',
value: doc.id,
},
product: typeof productEntry.product === 'object'
? productEntry.product.id
: productEntry.product,
variant: productEntry.variant
? (typeof productEntry.variant === 'object'
? productEntry.variant.id
: productEntry.variant)
: null,
type: 'received',
quantityDelta: productEntry.totalStock || 0,
reason: `Initial batch creation - ${productEntry.productionBatchNumber || 'N/A'}`,
snapshotBefore: {
totalStock: 0,
reservedStock: 0,
availableStock: 0,
},
resultingReservedStock: 0,
remainingAvailable: productEntry.totalStock || 0,
triggeredBy: 'admin-manual',
notes: `Delivery window batch created for ${doc.displayName || 'batch'}`,
},
});
} catch (error) {
req.payload.logger.error({
msg: 'Failed to create received transaction for product',
batchId: doc.id,
productId: productEntry.product,
error,
});
}
}
}
return doc;
},
],
},
fields: [
// ... your fields ...
],
};
This pattern works because it respects PostgreSQL's transaction boundaries and prevents hook recursion through context flags.
The req
parameter is passed explicitly to every nested operation. This ensures all operations stay within the same database transaction. When you create the batch and its related inventory transactions, PostgreSQL sees them as a single atomic unit. The foreign key constraints work correctly because all documents are visible within the transaction.
The context
object carries flags that prevent infinite loops. When I create an inventory transaction, I pass skipInventoryHooks: true
in the context. If the inventory transaction collection has its own hooks that might update the batch, those hooks check for this flag and exit early. This breaks the recursion chain before it starts.
The operations happen synchronously within the hook flow using standard async/await. No setImmediate()
, no setTimeout()
, no queuing systems. Everything stays in the transaction context where req
is valid and PostgreSQL can maintain consistency.
Key Principles for Safe Hook Data Manipulation
When you need to create or update documents inside Payload hooks, always follow these principles.
Always pass req
explicitly to nested operations. Even though req.payload
is available, you must pass the req
object itself to stay within the transaction:
await req.payload.create({
collection: 'related-collection',
req, // Critical: keeps operation in same transaction
data: { /* ... */ },
});
Use context flags to prevent recursion. Before any operation that might trigger hooks, check for your guard flag:
if (context?.skipInventoryHooks) {
return data; // Exit early to break recursion
}
When calling operations that might recursively trigger the current hook, always pass a context flag:
await req.payload.update({
collection: 'orders',
id: orderId,
req,
context: {
...context,
skipInventoryHooks: true, // Prevent this update from triggering inventory hooks
},
data: { /* ... */ },
});
Never use async scheduling functions inside hooks. No setImmediate()
, setTimeout()
, or process.nextTick()
. These break you out of the transaction context and invalidate the req
object. Your foreign key constraints will fail with "is not present in table" errors.
Handle errors gracefully without breaking the transaction. If a nested operation fails, decide whether to throw and rollback everything or log and continue:
try {
await req.payload.create({ /* ... */ });
} catch (error) {
req.payload.logger.error({ msg: 'Operation failed', error });
// Don't throw if you want the parent operation to succeed anyway
}
If you're updating the same document you're in a hook for, you almost always need a guard. Otherwise you create infinite loops. Consider if you really need to update in afterChange
, or if you can compute values in beforeChange
instead.
Understanding the GitHub Issue: Why Updates Hang
The Payload GitHub discussion #3098 explains the root cause in detail. When you call req.payload.update()
inside beforeChange
or afterChange
hooks with PostgreSQL, the operation hangs because updates trigger additional hooks, and update
internally uses Promise.all
when handling multiple documents. This creates recursive calls and parallel transactions that PostgreSQL cannot handle properly.
The recommendations from that discussion are clear. Always use the req
passed into hooks so the update runs inside the same transaction. Using getPayloadHMR()
or creating a new Payload instance inside hooks is not recommended because you lose transaction context.
Every update
call runs hooks again. If your hook logic calls update
on the same or related collections, it can loop endlessly or deadlock. The solution is to pass a flag on req.context
to check if a hook has already run and exit early on subsequent calls.
For operations where you don't need validation, hooks, versions, or access control, you can call database methods directly using req.payload.db
to bypass Payload's processing entirely. However, this should be a last resort because you lose Payload's core features.
Right now, hooks that trigger updates on other collections must be carefully designed to avoid recursion. The recommended approach is to use req
and guard against re-entry with context flags.
Conclusion
Safely manipulating Payload CMS data inside hooks requires understanding PostgreSQL's transaction model and Payload's hook execution lifecycle. The hanging operations, "is not present in table" foreign key constraint errors, and infinite recursion loops all stem from breaking transaction context or failing to prevent recursive hook triggers.
By explicitly passing req
to all nested operations, using context flags to guard against recursion, and staying within the hook execution flow without deferring operations, you can build reliable hook chains that maintain data consistency. This pattern has eliminated all hanging issues in my production inventory system and allows complex multi-document operations to work correctly within Payload's architecture.
You now know how to implement transaction-safe hook operations that avoid the common pitfalls causing code 23503 constraint violations and deadlocked update operations. Use these patterns whenever you need to create or update related documents inside Payload hooks.
Let me know in the comments if you have questions, and subscribe for more practical development guides.
Thanks, Matija