Sitemap

package.json vs package-lock.json: The Dependency Battle Behind Every Node.js App

--

Press enter or click to view image in full size

There is a small moment almost every Node.js developer experiences.

You clone a project:

git clone https://github.com/example/my-app.git
cd my-app
npm install
npm start

And everything works.

You come back three months later, run:

npm install

And suddenly…

💥 Something breaks.

A dependency changed.
A transitive dependency changed.
A bug appeared.
A security patch changed behavior.
Or, even worse, it works on your laptop but fails in CI/CD.

Then you look at the repository and see:

package.json
package-lock.json

Both seem to contain dependencies.

So the obvious question is:

Why does Node.js need two files to describe dependencies?

The answer is much more interesting than it first appears.

📦 package.json is what you WANT

Think of package.json as the contract for your application.

It tells npm:

“These are the packages my application needs, and these are the kinds of versions I’m willing to accept.”

For example:

{
"dependencies": {
"express": "^5.1.0",
"axios": "^1.12.0"
}
}

The important part is:

^5.1.0

This does not necessarily mean:

Install exactly Express 5.1.0.

It means roughly:

Give me a compatible version within the allowed semver range.

So if a newer compatible version exists, npm may install it.

That’s useful for developers because you don’t want to manually update every dependency every time a compatible patch release appears.

🔒 package-lock.json is what you ACTUALLY GOT

Now comes the interesting part.

Suppose your package.json says:

"express": "^5.1.0"

At the time you ran:

npm install

npm might resolve that to:

express 5.1.0

But Express itself depends on other packages.

Those packages have dependencies.

Those dependencies have dependencies.

You end up with a dependency tree:

your-app
│
├── express
│ ├── dependency-A
│ │ ├── dependency-X
│ │ └── dependency-Y
│ └── dependency-B
│ └── dependency-Z
│
└── axios
└── dependency-C

package-lock.json records this resolved dependency tree.

It contains information such as:

  • exact package versions
  • resolved package locations
  • integrity hashes
  • dependency relationships
  • nested dependencies

So you can think of it as:

package.json = dependency requirements

package-lock.json = exact dependency resolution

🎯 Here’s the easiest analogy

Imagine you’re ordering food.

package.json

You say:

“I want pizza.”

That’s your requirement.

package-lock.json

The restaurant records:

“Large chicken pizza, thin crust, extra cheese, delivered to this address.”

That’s the exact result.

Next time you order using the record, you get essentially the same thing.

Without the lock file, npm has more freedom to resolve the request again.

😈 The hidden problem: dependencies have dependencies

This is where things become really important for DevOps.

Imagine your package.json contains:

{
"dependencies": {
"my-framework": "^2.0.0"
}
}

You install it today.

You get:

my-framework 2.0.3

But internally:

my-framework 2.0.3
└── helper-library 4.1.2

Three months later, my-framework releases:

2.4.0

and its dependency changes:

helper-library 4.5.0

Now someone else clones your project.

If npm resolves dependencies from scratch, they could potentially get:

my-framework 2.4.0
└── helper-library 4.5.0

while your original environment had:

my-framework 2.0.3
└── helper-library 4.1.2

Same:

package.json

Different:

node_modules/

And that’s a nightmare for reproducible deployments.

🔐 This is why package-lock.json matters

With a lock file committed to Git:

Git repository
│
├── package.json
└── package-lock.json

your CI/CD pipeline can reproduce the dependency tree much more reliably.

For example:

npm ci

is specifically designed for clean, reproducible installation from the lock file.

A typical CI pipeline might therefore use:

npm ci
npm test
npm run build

rather than:

npm install
npm test
npm run build

⚔️ npm install vs npm ci

This distinction is extremely important.

npm install

Generally used when you’re developing.

It can:

  • install dependencies
  • update package-lock.json
  • resolve dependencies
  • modify the lock file when necessary

Example:

npm install axios

This can modify both:

package.json
package-lock.json

npm ci

ci means Clean Install.

It’s designed for CI/CD environments.

Example:

npm ci

It uses the lock file as the source of truth and installs the dependency tree described there.

It also expects the lock file and package.json to be in sync.

If they aren’t, npm can fail instead of silently resolving a new dependency tree.

That’s actually a good thing in CI.

You want the pipeline to say:

“The dependency definition changed but the lock file wasn’t updated.”

rather than:

“No problem, I’ll figure something out.”

🧠 So why not just put exact versions in package.json?

You might ask:

“Why not simply do this?”

{
"dependencies": {
"express": "5.1.0",
"axios": "1.12.0"
}
}

That still doesn’t completely solve the problem.

Because Express itself has dependencies.

For example:

your application
└── express 5.1.0
├── package-A
├── package-B
└── package-C

You aren’t necessarily specifying the exact versions of those transitive dependencies in package.json.

That’s one of the major jobs of the lock file.

🌳 package.json vs package-lock.json

A useful mental model:

package.json
│
│
"What I need"
│
▼
npm dependency
resolver
│
▼
package-lock.json
│
│
"What to install"
│
▼
node_modules

Or even simpler:

package.json
↓
Requirements

package-lock.json
↓
Resolution

node_modules
↓
Reality

🚨 What happens if you delete package-lock.json?

Suppose your project contains:

package.json
package-lock.json

You delete:

rm package-lock.json

Then run:

npm install

npm has to resolve the dependency tree again.

Get Jahangir’s stories in your inbox

Join Medium for free to get updates from this writer.

It may produce a new:

package-lock.json

Potentially with different versions of dependencies.

That doesn’t necessarily mean the application will break.

But it means you’ve thrown away an important piece of reproducibility information.

For production projects, that’s usually something you don’t want to do casually.

🛡️ What about security?

This is another reason the lock file matters.

Imagine your application has:

my-app
└── framework
└── vulnerable-library

You can use npm’s auditing capabilities to identify vulnerabilities in your dependency tree.

For example:

npm audit

The lock file helps npm understand the concrete dependency tree being used.

You can also inspect the dependency tree with:

npm ls

For example:

npm ls express

or:

npm ls

This is extremely useful when troubleshooting:

“Why is this vulnerable package installed when I never added it?”

The answer is often:

It’s a transitive dependency.

🤯 The package you never installed

Imagine:

"dependencies": {
"some-framework": "^3.0.0"
}

You never installed:

left-pad

But:

some-framework
↓
library-A
↓
library-B
↓
left-pad

So:

npm ls left-pad

could reveal:

my-app
└─┬ some-framework
└─┬ library-A
└─┬ library-B
└── left-pad

This is one reason dependency management becomes complicated surprisingly quickly.

🐳 And this becomes even more important with Docker

Consider this Dockerfile:

FROM node:24
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npm", "start"]

Notice this:

COPY package*.json ./

That copies:

package.json
package-lock.json

Then:

RUN npm ci

installs the locked dependency tree.

This also creates a useful Docker layer:

Dockerfile
│
├── COPY package*.json
│
├── RUN npm ci
│
└── COPY application source

If you change only your application code:

src/app.js

but don’t change:

package.json
package-lock.json

Docker can often reuse the dependency-install layer.

That makes builds faster.

🚀 CI/CD: This is where DevOps people should care

A production pipeline might look like:

steps:
- checkout
- install:
npm ci
- test:
npm test
- build:
npm run build
- deploy:
deploy-to-production

The important part is:

npm ci

not:

npm install

Because the goal of CI/CD is generally:

Developer machine
│
▼
Git
│
▼
CI
│
▼
Same dependency tree
│
▼
Production

The fewer variables you have, the easier the system is to reproduce.

🧩 Should package-lock.json be committed to Git?

For an npm application, generally yes.

Your repository should typically contain:

my-node-app/
├── package.json
├── package-lock.json
├── src/
├── test/
├── Dockerfile
└── .gitignore

You normally should not commit:

node_modules/

So your .gitignore would commonly contain:

node_modules/

But:

package-lock.json

should normally stay tracked.

🔄 What if I change package.json manually?

Suppose you change:

"express": "^5.1.0"

to:

"express": "^5.2.0"

Now your dependency declaration and lock file may no longer represent the same desired state.

Run:

npm install

npm updates the lock file accordingly.

Then commit both:

git add package.json package-lock.json
git commit -m "Update express dependency"

Think:

package.json changed
↓
dependency intent changed
↓
npm install
↓
lock file updated
↓
commit both

🔥 The most important rule

Here’s the rule I would remember:

Don’t manually edit package-lock.json unless you really know why you’re doing it.

Let npm manage it.

For example:

npm install express@5.2.0

rather than manually opening:

package-lock.json

and changing versions yourself.

🧠 A DevOps mental model

If you’re coming from infrastructure as code, there’s actually a very nice analogy.

Think of:

package.json

like your desired configuration.

And:

package-lock.json

like the resolved plan/state of exactly what dependencies were selected.

Then:

node_modules/

is the actual environment.

So:

package.json
│
│ desired dependencies
▼
package-lock.json
│
│ resolved dependencies
▼
node_modules
│
│ runtime
▼
Application

This isn’t a perfect Terraform analogy, but it’s a useful mental model for understanding the roles.

🎯 One example that makes everything click

Imagine Alice creates a project today.

Her:

package.json

says:

{
"dependencies": {
"express": "^5.1.0"
}
}

She runs:

npm install

and gets:

express 5.1.0

Her repository becomes:

package.json
package-lock.json

Bob clones it tomorrow.

If Bob runs:

npm ci

he gets the dependency tree recorded by the lock file.

Now imagine six months later Express has released:

5.1.1
5.1.2
5.1.3

The ^5.1.0 declaration may allow a newer compatible release.

But Bob’s existing lock file still tells npm which concrete version was resolved for the project.

That’s the fundamental reason lock files exist.

🧠 The 10-second memory trick

Remember:

JSON = WHAT
LOCK = EXACTLY WHAT

Or:

package.json
↓
"I need Express"

package-lock.json
↓
"Here is exactly which Express dependency tree we resolved"

And for DevOps:

npm install for development, npm ci for reproducible CI/CD.

Final picture

package.json
┌───────────────┐
│ Direct deps │
│ Version ranges│
│ Scripts │
│ Project info │
└───────┬───────┘
│
▼
npm
│
▼
package-lock.json
┌─────────────────────┐
│ Exact resolutions │
│ Dependency tree │
│ Integrity hashes │
│ Transitive deps │
└──────────┬──────────┘
│
▼
node_modules
│
▼
Your App

So the next time you see both files in a repository, don’t think:

“Why are there two dependency files?”

Think:

package.json describes the dependency contract. package-lock.json makes that contract reproducible.

And that tiny distinction is one of the foundations of reliable Node.js builds and CI/CD.

--

--

Jahangir
Jahangir

Written by Jahangir

Cloud | DevOps Engineer | AWS & Azure Solution Architect | VMware EsXi | Linux | DevOps | Git | Dockers | kubernetes | Python | Bash Scripting