Skip to Content
⚠️Active Development Notice: TimeTiles is under active development. Information may be placeholder content or not up-to-date.
Developer GuideDevelopment SetupPayload CMS Deadlock Prevention

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

  1. req parameter: Ensures the operation uses the same database transaction
  2. context flags: Prevents infinite loops by skipping hooks on nested operations
  3. overrideAccess: 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 quickly

Best Practices Checklist

Use this checklist when writing Payload hooks to prevent deadlocks:
  • Pass req parameter to all nested payload.update(), payload.create(), payload.findByID() calls
  • Use context flags to prevent infinite hook recursion
    • Common flags: skipUsageHooks, skipQuotaChecks, skipImportFileHooks
  • Add overrideAccess: true to 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 of console.log()
  • Test with short timeouts (10-15 seconds) to detect deadlocks quickly

Debugging Tips

If you suspect a deadlock:

  1. Add logger.debug() before and after nested Payload operations
  2. Check that req is being passed to all nested operations
  3. Verify context flags are present and prevent hook recursion
  4. Ensure overrideAccess: true is used
  5. 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() with req parameter
  • decrementUsage() with req parameter
  • checkQuota() 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 in afterChange
  • Context flags preventing hook recursion
  • Error handling with transaction consistency

Additional Resources

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.

Last updated on