AI is redefining software quality and security. Insights from 450 CISOs & devs →
Aikido

Release locks even on exception paths: preventing deadlocks

Rule
Release locks even on exception paths. 
Every lock acquisition must have a guaranteed release, even when exceptions occur. 
Supported languages:** Java, C, C++, PHP, JavaScript, TypeScript, Go, Python

Introduction

Unreleased locks are one of the most common causes of deadlocks and system hangs in production Node.js applications. When an exception occurs between lock acquisition and release, the lock remains held indefinitely. Other async operations waiting for that lock hang forever, causing cascading failures across the system. A single unreleased mutex can bring down an entire API because the event loop becomes blocked and requests pile up. This happens with libraries like async-mutex, mutexify, or any manual lock implementation where release isn't automatic.

Why it matters

System stability and availability: Unreleased locks cause deadlocks that freeze async operations in Node.js. In Express or Fastify servers, this exhausts available workers, making the application unable to handle new requests. The only recovery is restarting the process, causing downtime. In microservices architectures, unreleased locks in one service can cascade failures across dependent services as they time out waiting for responses.

Performance degradation: Before complete deadlock, unreleased locks cause severe performance problems. Async operations contend for locked resources, creating a queue of pending promises that never resolve. Lock contention creates unpredictable latency spikes that degrade user experience. As concurrent request count increases under load, contention compounds exponentially.

Debugging complexity: Deadlocks from unreleased locks are notoriously difficult to debug in production Node.js apps. The symptoms appear far from the root cause, process hangs show pending promises but not which exception path failed to release the lock. Reproducing the exact sequence of exceptions that triggered the deadlock is often impossible in development environments.

Resource exhaustion: Beyond locks themselves, failure to release locks often correlates with failure to release other resources like database connections, Redis clients, or file handles. This compounds the problem, creating multiple resource leaks that bring systems down faster under load.

Code examples

❌ Non-compliant:

const { Mutex } = require('async-mutex');
const accountMutex = new Mutex();

async function transferFunds(from, to, amount) {
    await accountMutex.acquire();

    if (from.balance < amount) {
        throw new Error('Insufficient funds');
    }

    from.balance -= amount;
    to.balance += amount;

    accountMutex.release();
}

Why it's unsafe: If the insufficient funds error is thrown, accountMutex.release() never executes and the mutex remains locked forever. All subsequent calls to transferFunds() will hang waiting for the mutex, freezing the entire payment system.

✅ Compliant:

const { Mutex } = require('async-mutex');
const accountMutex = new Mutex();

async function transferFunds(from, to, amount) {
    const release = await accountMutex.acquire();
    try {
        if (from.balance < amount) {
            throw new Error('Insufficient funds');
        }
        
        from.balance -= amount;
        to.balance += amount;
    } catch (error) {
        logger.error('Transfer failed', { 
            fromId: from.id, 
            toId: to.id, 
            amount,
            error: error.message 
        });
        throw error;
    } finally {
        release();
    }
}

Why it's safe: The catch block logs the error with context before re-throwing it, and the finally block guarantees the mutex release function executes whether the operation succeeds, throws an error, or the error is re-thrown from catch. The lock is always released, preventing deadlocks.

Conclusion

Lock release must be guaranteed, not conditional on successful execution. Use try-finally blocks in JavaScript or the runExclusive() helper provided by libraries like async-mutex. Every lock acquisition should have an unconditional release path visible in the same code block. Proper lock management is not optional, it's the difference between a stable system and one that randomly hangs under load.

FAQs

Got Questions?

What's the correct pattern for guaranteed lock release in JavaScript?

Use try-finally blocks with explicit release in finally. Store the release function returned by acquire() and call it in the finally block. Better yet, use the runExclusive() method provided by libraries like async-mutex which handles acquisition and release automatically: await mutex.runExclusive(async () => { /* your code */ }). This eliminates the chance of forgetting the finally block.

Should I use try-catch-finally or just try-finally for lock release?

Use try-finally if you want exceptions to propagate to the caller. Use try-catch-finally if you need to handle the error locally while still guaranteeing lock release. The finally block executes in both cases, but catch gives you a chance to log, transform, or suppress the error. Always put release() in finally, never in catch, because finally executes even if catch re-throws.

What about async locks with callbacks instead of promises?

Convert callback-based code to promises first, then use async/await with try-finally. If that's not possible, ensure every callback path (success, error, timeout) calls the release function. This is error-prone, which is why promise-based locks are preferred. Never rely on garbage collection to release locks, it's not deterministic and will cause deadlocks.

How do I handle multiple locks that need to be acquired together?

Acquire all locks before any business logic, and release them in reverse order in a single finally block. Better approach: use a lock hierarchy where locks are always acquired in the same order to prevent circular dependencies. For complex cases, consider using a transaction coordinator pattern or libraries like async-lock that support multiple resource locking with automatic release on any failure.

Can I release a lock early if I know I'm done with it?

Yes, but be extremely careful. Once released, you have no protection against concurrent access. A common pattern is to release after critical section but before slow operations like logging or external API calls. However, if any exception occurs after early release but before function exit, you risk inconsistent state. Document clearly why early release is safe.

What tools can detect unreleased locks in JavaScript code?

Static analysis tools can flag lock acquisitions without corresponding finally blocks. Runtime detection is harder since JavaScript has no built-in deadlock detection. Implement timeouts on lock acquisition (most libraries support this) to fail fast instead of hanging forever. Monitor promise rejection rates and event loop lag in production to detect lock contention issues.

How do libraries like async-mutex prevent this problem?

async-mutex provides runExclusive() which acquires the lock, runs your function, and releases the lock automatically even if exceptions occur. It's essentially a built-in try-finally wrapper. Use this when possible: await mutex.runExclusive(async () => { /* your code */ }). This eliminates manual release management and prevents the most common mistake of forgetting the finally block.

Get secure for free

Secure your code, cloud, and runtime in one central system.
Find and fix vulnerabilities fast automatically.

No credit card required | Scan results in 32secs.