Payload CMS Deadlock Prevention
Overview
When working with Payload CMS hooks that perform nested database operations, you may encounter deadlocks or timeout errors. This guide explains the root causes and provides proven patterns to prevent them.
Critical Issue: Calling payload.update(), payload.create(), or payload.findByID() inside hooks without
proper configuration can cause database transactions to hang indefinitely, resulting in 60-second timeouts in tests
and production failures.
The Problem
Symptoms
- Tests timing out at 60+ seconds
- Hooks hanging during execution
- Database operations never completing
- Error messages like:
Hook timed out in 60000ms
Root Cause
Payload CMS 3.x uses PostgreSQL transactions to ensure data consistency. When you call Payload methods inside hooks (like beforeChange, afterChange, etc.) without proper configuration, the nested operation tries to start a new transaction instead of reusing the existing one, causing a deadlock.
// ❌ WRONG - This will deadlock!
hooks: {
afterChange: [
async ({ doc, req }) => {
// This starts a NEW transaction, causing deadlock
await req.payload.update({
collection: "users",
id: doc.userId,
data: { lastUpdated: new Date() },
});
},
];
}The Solution
Core Pattern: Pass req and Use context Flags
To prevent deadlocks, always pass the req object and use context flags when performing nested Payload operations:
// ✅ CORRECT - Stays in same transaction
hooks: {
afterChange: [
async ({ doc, req }) => {
await req.payload.update({
collection: "users",
id: doc.userId,
req, // Pass req to stay in same transaction
data: { lastUpdated: new Date() },
context: {
...(req.context || {}),
skipCustomHooks: true, // Prevent infinite recursion
},
overrideAccess: true, // Skip access control if needed
});
},
];
}Key Components Explained
reqparameter: Ensures the operation uses the same database transactioncontextflags: Prevents infinite loops by skipping hooks on nested operationsoverrideAccess: true: Skips access control to avoid nested authorization checks
Common Deadlock Scenarios
Scenario 1: Usage Tracking in Hooks
Problem: Tracking user quota usage after creating a record causes deadlock.
// ❌ WRONG
hooks: {
afterChange: [
async ({ doc, req }) => {
if (req.user) {
const quotaService = getQuotaService(req.payload);
// Missing req parameter causes deadlock
await quotaService.incrementUsage(req.user.id, "FILE_UPLOADS", 1);
}
},
];
}Solution: Pass req to service methods and ensure they pass it to nested operations.
// ✅ CORRECT
hooks: {
afterChange: [
async ({ doc, req }) => {
if (req.user) {
const quotaService = getQuotaService(req.payload);
// Pass req to service method
await quotaService.incrementUsage(req.user.id, 'FILE_UPLOADS', 1, req);
}
}
]
}
// In quota-service.ts
async incrementUsage(userId: number, usageType: string, amount: number, req?: any) {
await this.payload.update({
collection: "users",
id: userId,
req, // Use passed req
data: { usage: newUsage },
context: {
...(req?.context || {}),
skipUsageHooks: true // Custom flag to prevent recursion
},
overrideAccess: true
});
}Scenario 2: Quota Checks in beforeChange
Problem: Checking quotas before creating records requires reading user data.
// ❌ WRONG
hooks: {
beforeChange: [
async ({ req, operation }) => {
if (operation === "create" && req.user) {
const quotaService = getQuotaService(req.payload);
// Reading user during transaction without overrideAccess
const check = await quotaService.checkQuota(req.user, "FILE_UPLOADS", 1);
if (!check.allowed) throw new Error("Quota exceeded");
}
},
];
}Solution: Read from cached user object instead of querying database.
// ✅ CORRECT
async checkQuota(user: User, quotaType: string, amount: number, req?: any) {
// Read usage directly from user object (already loaded in memory)
const usage = (user as any).usage as UserUsage | undefined;
// Calculate quota without database queries
const limit = this.getEffectiveQuotas(user)[quotaType];
const current = usage?.[quotaType] || 0;
return {
allowed: current + amount <= limit,
current,
limit
};
}Testing for Deadlocks
Tests will timeout (60+ seconds) if deadlocks occur. Use short timeouts (10-15s) to detect issues quickly:
test("should create schedule without hanging", async () => {
const result = await payload.create({
collection: "scheduled-imports",
data: { name: "Test", enabled: true },
user: testUser,
});
expect(result).toBeDefined();
}, 10000); // Should complete quicklyBest Practices Checklist
- Pass
reqparameter to all nestedpayload.update(),payload.create(),payload.findByID()calls - Use
contextflags to prevent infinite hook recursion- Common flags:
skipUsageHooks,skipQuotaChecks,skipImportFileHooks
- Common flags:
- Add
overrideAccess: trueto skip nested access control checks - Avoid database queries in access control functions when possible
- Read from memory instead of querying when data is already available
- Skip complex operations in test environment if they’re not being tested
- Use structured logging (
logger.debug()) instead ofconsole.log() - Test with short timeouts (10-15 seconds) to detect deadlocks quickly
Debugging Tips
If you suspect a deadlock:
- Add
logger.debug()before and after nested Payload operations - Check that
reqis being passed to all nested operations - Verify
contextflags are present and prevent hook recursion - Ensure
overrideAccess: trueis used - Test with short timeouts (10-15s) to detect hangs quickly
Real-World Examples
Example 1: Scheduled Imports Collection
See apps/web/lib/collections/scheduled-imports/index.ts for a complete example of:
- Quota checking in
beforeChange - Usage tracking in
afterChange - Decrement tracking in
afterDelete - All following the deadlock prevention pattern
Example 2: Quota Service
See apps/web/lib/services/quota-service.ts for:
incrementUsage()withreqparameterdecrementUsage()withreqparametercheckQuota()reading from memory instead of database
Example 3: Import Files Collection
See apps/web/lib/collections/import-files.ts for:
- Multiple nested
payload.update()calls inafterChange - Context flags preventing hook recursion
- Error handling with transaction consistency
Additional Resources
- Payload CMS Hooks Documentation
- Payload CMS Access Control
- Article: “How to Safely Manipulate Payload CMS Data in Hooks Without Hanging or Recursion”
Summary
Key Takeaway: When performing nested Payload operations in hooks, always use this pattern:
await req.payload.update({
collection: "your-collection",
id: docId,
req, // ← Stay in same transaction
data: { your: "data" },
context: {
...(req.context || {}),
skipYourHooks: true, // ← Prevent recursion
},
overrideAccess: true, // ← Skip nested access control
});Following this pattern prevents deadlocks, ensures transaction consistency, and keeps your application responsive.