lockfile
lockfile is a small mutual-exclusion helper for processes on one machine. Acquiring a lock means creating a file with the exclusive-create flag, so exactly one caller wins and everyone else gets EEXIST; releasing means unlinking it. On top of that it adds polling with a timeout, a retry count, and an age-based staleness rule that lets a caller steal a lock left behind by a process that died. It has nothing to do with package-manager lockfiles such as package-lock.json, despite living under the npm organisation, and it registers a process-exit hook so its own locks get cleaned up on a normal shutdown.
A clear, well-scoped exclusive-create lock that does exactly what its README says, on one local filesystem, and has been archived since 2020 with no release since 2018. For new code proper-lockfile covers the same ground with promises, ownership checks and a heartbeat, and is still maintained.
Use it if
- Two or more processes on the same filesystem must take turns over a resource such as a cache directory, a generated file or a migration step
- You want the lock file itself as the coordination artefact so an operator can see it, and delete it, without any daemon or service running
- The callback and sync API suits you, because lockSync and checkSync fit build scripts and CLI tools that are not built around promises
- You need best-effort cleanup on exit and on signals without wiring signal handling yourself
- Your lock directory sits on NFS or any network filesystem, because exclusive create is the only guarantee here and it is not reliable there
- You need to know who holds a lock: the file it writes is zero bytes, with no pid and no hostname, so a leftover lock is indistinguishable from a live one except by age
- You want reentrancy or in-process coordination, since a second lock call from the same process gets EEXIST just like a foreign process would
- You want promises, because the API is callbacks and sync functions only, and lockSync throws outright if you pass wait or retryWait
- You need a maintained dependency: the repository has been archived since December 2020, the last publish was 1.0.4 in April 2018, and twelve issues sit open on a read-only repo
- Staleness has to be exact, because the rule is a wall-clock age comparison against ctime, or mtime on Windows, so clock changes and slow work both cause a live lock to be stolen
Setup reality
npm install lockfile brings in one dependency, signal-exit, and no native build. Writing the first lock takes two minutes; the surprises are all in the options. The biggest one is that lock and lockSync mutate the options object you pass in. Calling lock with { wait: 500, retries: 2, stale: 10000 } leaves that same object as { wait: 500, retries: 0, stale: 10000, req: 1, start: <timestamp> }, so a shared config object silently loses its retries after the first call and every later acquisition tries exactly once. Build a fresh object per call. Next, the lock file carries no information. It is created and immediately closed, size zero, so there is no pid to check and no way to ask whether the holder is alive; the only signal is age. That makes opts.stale a guess, and getting it wrong in either direction hurts: too low and you steal a lock from a process that is merely slow, too high and a crashed process blocks everyone until the timeout expires. Staleness compares against ctime on POSIX and mtime on Windows, exposed as exports.filetime, and lockSync rounds the threshold up to whole seconds when the filesystem looks like it lacks sub-second resolution. Note also what check means once stale is set: a lock older than the threshold reports as not locked, so check is answering can I take this, not is a file there. The sync functions refuse anything that would need to wait, throwing 'opts.wait not supported sync for obvious reasons' if you pass wait or retryWait. Cleanup is best effort: locks acquired in this process are unlinked from a signal-exit hook, which covers normal exits and most signals but not SIGKILL, an OOM kill or a power loss, and the release call will happily remove a lock file that some other process created. Because the whole mechanism rests on fs.open with the exclusive-create flag, correctness ends at the boundary of a local filesystem.
Patterns
Take a lock, do work, release itacquire-and-release
const lockFile = require('lockfile')
lockFile.lock('build.lock', { wait: 5000 }, (err) => {
if (err) return done(err)
doWork((workErr) => {
lockFile.unlock('build.lock', (unlockErr) => done(workErr || unlockErr))
})
})Release in every exit path. The exit hook is a safety net for crashes, not a substitute for releasing when your work finishes.
Never reuse one options objectfresh-options-per-call
const lockFile = require('lockfile')
// wrong: retries is reset to 0 after the first call
const shared = { wait: 500, retries: 2, stale: 10000 }
// right
const opts = () => ({ wait: 500, retries: 2, stale: 10000 })
lockFile.lock('a.lock', opts(), onLocked)
lockFile.lock('b.lock', opts(), onLocked)lock() writes req and start into the object and sets retries to 0. Verified on 1.0.4: a shared object loses its retries silently.
Wrap the callbacks in promisespromise-wrapper
const lockFile = require('lockfile')
const { promisify } = require('node:util')
const lock = promisify(lockFile.lock)
const unlock = promisify(lockFile.unlock)
await lock('build.lock', { wait: 5000, stale: 60000 })
try {
await doWork()
} finally {
await unlock('build.lock')
}promisify works because the callbacks are error-first. Keep building the options object inline so the mutation cannot leak between calls.
Queue behind another processwait-with-polling
const lockFile = require('lockfile')
lockFile.lock('migrate.lock', { wait: 30000, pollPeriod: 250 }, (err) => {
if (err) return console.error('gave up waiting:', err.code)
runMigrations(release)
})wait is the total budget and pollPeriod defaults to 100 ms. On timeout the callback gets the original EEXIST error, not a distinct timeout error.
Take over a lock left by a dead processsteal-stale-lock
const lockFile = require('lockfile')
lockFile.lock('job.lock', { stale: 60000, wait: 5000 }, (err) => {
if (err) return bail(err)
runJob(release)
})Age is compared against ctime, or mtime on Windows. Set stale well above your worst-case runtime, or a slow run gets its lock stolen while it is still working.
Ask whether a lock is heldcheck-before-locking
const lockFile = require('lockfile')
lockFile.check('job.lock', { stale: 60000 }, (err, isLocked) => {
if (err) return bail(err)
console.log(isLocked ? 'held' : 'free or stale')
})With stale set, an old lock reports false. Treat the answer as advisory: another process can take the lock between your check and your lock call.
Lock synchronously in a build stepsync-in-scripts
const lockFile = require('lockfile')
try {
lockFile.lockSync('build.lock', { retries: 3, stale: 30000 })
buildEverything()
} finally {
lockFile.unlockSync('build.lock')
}lockSync throws 'opts.wait not supported sync for obvious reasons' if you pass wait or retryWait. retries is the only backoff available here, and it spins without sleeping.
Expect EEXIST from your own processnot-reentrant
const lockFile = require('lockfile')
lockFile.lock('a.lock', {}, (err) => {
lockFile.lock('a.lock', {}, (again) => {
console.log(again && again.code) // 'EEXIST'
})
})There is no owner tracking, so the second call fails the same way a different process would. Guard in-process reentry with your own flag or a mutex.
See how little is in the lock fileinspect-the-lock-file
const fs = require('node:fs')
const lockFile = require('lockfile')
lockFile.lockSync('a.lock')
console.log(fs.statSync('a.lock').size) // 0
console.log(lockFile.filetime) // 'ctime', or 'mtime' on win32Zero bytes means no pid and no hostname. If an operator needs to know who is holding it, write a sidecar file yourself.
Know what the exit hook does and does not covercleanup-on-exit
const lockFile = require('lockfile')
lockFile.lockSync('a.lock')
// signal-exit unlinks locks taken in this process on normal exit,
// on uncaught exceptions and on catchable signals.
// SIGKILL, an OOM kill and power loss leave the file behind.
process.on('SIGTERM', () => process.exit(0))Set opts.stale so a leftover file is eventually recoverable; without it a hard kill blocks every future acquisition forever.
Keep the lock on a local filesystemavoid-network-fs
const os = require('node:os')
const path = require('node:path')
const lockFile = require('lockfile')
// not on an NFS or SMB mount
const lockPath = path.join(os.tmpdir(), 'my-app.lock')
lockFile.lockSync(lockPath, { stale: 30000 })The whole guarantee is fs.open with the exclusive-create flag, which network filesystems do not implement reliably. Two hosts can both believe they won.
Move to a maintained equivalentmigrate-to-proper-lockfile
const lockfile = require('proper-lockfile')
const release = await lockfile.lock('resource-dir', {
stale: 30000,
retries: { retries: 5, minTimeout: 100 },
})
try {
await doWork()
} finally {
await release()
}It locks a path you already have rather than a separate file, refreshes mtime while you hold it so a slow job is not judged stale, and checks ownership before releasing.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| proper-lockfile | npm | You want the same file-based idea with promises, an mtime heartbeat so live holders refresh their lock, and releases that verify ownership first |
| async-mutex | npm | The contention is between async tasks inside one Node process, where a filesystem lock is the wrong tool entirely |
| redlock | npm | The processes are on different machines and you need a lock backed by Redis rather than by a shared filesystem |
| fs-ext | npm | You want real advisory locking through flock, with kernel-released locks when the process dies, and can accept a native build step |