The most surprising thing about npm lifecycle hooks is that they’re not just for running scripts after installation; they’re fundamental to how npm manages dependencies and ensures a consistent build environment.

Let’s see them in action. Imagine you have a package, my-package, that needs a specific binary compiled before it can be used. You could put this compilation script directly in your package.json:

{
  "name": "my-package",
  "version": "1.0.0",
  "scripts": {
    "preinstall": "echo 'Compiling my-package binary...'",
    "postinstall": "echo 'Binary compiled!'"
  },
  "dependencies": {
    "some-dependency": "^1.0.0"
  }
}

When you run npm install in a project that depends on my-package, npm doesn’t just download the files. It orchestrates a series of events:

  1. preinstall: npm runs the preinstall script defined in my-package’s package.json. In our example, this prints "Compiling my-package binary…". This is crucial for tasks that must happen before the rest of the package code is unpacked or linked.
  2. install: The actual installation of the package happens here.
  3. postinstall: After the package is installed, npm runs the postinstall script. Here, it prints "Binary compiled!". This is for actions that need to happen after the package files are in place, like moving compiled binaries, setting up environment variables, or linking native modules.

The lifecycle isn’t limited to just preinstall and postinstall. There’s a whole sequence:

  • preinstall: Runs before npm install is executed.
  • install: Runs by default if no preinstall, postinstall, or prepare script is present. If any of those are present, install is not run.
  • postinstall: Runs after npm install is executed.
  • prepublish: Runs before npm publish. (Deprecated in favor of prepublishOnly)
  • prepare: Runs before npm publish and also before npm install when installing from a git URL or local path. It’s also run for npm install in the package itself.
  • prepublishOnly: Runs only before npm publish. This is the modern replacement for prepublish.
  • prepack: Runs before a tarball is created for npm pack and npm publish.
  • postpack: Runs after a tarball is created.
  • build: Runs after installation for packages with native modules, typically used to compile them.
  • publish: Runs after npm publish.
  • postpublish: Runs after npm publish.
  • test: Runs when npm test is executed.
  • stop: Runs when npm stop is executed.
  • restart: Runs when npm restart is executed (runs stop, restart, start).
  • start: Runs when npm start is executed.
  • uninstall: Runs when npm uninstall is executed.
  • postuninstall: Runs after npm uninstall is executed.

The primary problem lifecycle hooks solve is ensuring that packages are in a usable state after they’ve been installed, especially for packages with complex build requirements or external dependencies. For instance, many packages that use native C++ addons (like node-sass or certain database drivers) will have postinstall scripts that compile these addons based on the user’s operating system and architecture.

Consider this package.json for a hypothetical package needing a compiled binary:

{
  "name": "binary-package",
  "version": "1.0.0",
  "scripts": {
    "install": "node scripts/compile-binary.js",
    "postinstall": "echo 'Binary compiled and ready!'"
  },
  "binary": {
    "module_path": "bin/",
    "host": "https://your-cdn.com",
    "remote_path": "./{name}/{version}/{platform}-{arch}/binary.tar.gz"
  }
}

When npm install binary-package is run:

  1. npm sees the install script. It will not run the default install action.
  2. It executes node scripts/compile-binary.js. This script might download a pre-compiled binary or compile one from source.
  3. After the script finishes, npm runs the postinstall script, printing "Binary compiled and ready!".

The binary field in the package.json is a convention used by some packages (like node-gyp itself) to specify where to find pre-compiled binaries. The postinstall script often leverages this information.

The prepare script is particularly interesting because it runs in more contexts than postinstall. If you’re developing a package locally and npm link it into another project, prepare will run in the linked package’s directory when you npm install in the consuming project. This ensures that any build steps are executed before the linked package is used, providing a more seamless local development experience.

If you’re ever tempted to put complex build logic directly into your main scripts section like build or start, pause. Lifecycle hooks are designed for the installation phase. They are the idiomatic way to handle setup that needs to occur once after dependencies are resolved but before the package is fully integrated into the project.

When you execute npm install --ignore-scripts, you’re telling npm to skip all lifecycle scripts, which can break packages that rely on them for essential setup, particularly for native modules.

This might seem like just running scripts, but it’s a sophisticated dependency management system. The order and conditions under which these scripts run are critical for ensuring that what you install is actually functional.

The next thing you’ll likely encounter is how these hooks interact with different installation sources (like git repositories or tarballs) and how npm ci behaves differently regarding lifecycle scripts compared to npm install.

Want structured learning?

Take the full Npm course →