Same project, same dependencies. And yet, when I asked both tools "how many vulnerabilities do you have?", they answered differently.
$ npm audit
2 vulnerabilities (1 high, 1 critical)
$ deno audit
Found 7 vulnerabilities
Severity: 0 low, 3 moderate, 3 high, 1 critical
I didn't change a single character of package.json. I deliberately pinned just two packages — lodash@4.17.15 and minimist@1.2.5 — to old versions, then ran npm install and deno install separately. And yet npm says "2" while Deno says "7." Neither was lying — as we'll see later, the two were looking at the same database but counting in different ways.
This small discrepancy points precisely at the subject of this article. Deno has long claimed to "replace Node." Usually we hear that as the problem of swapping node server.js for deno run server.ts. But what we actually reach for several times a day in real work isn't the runtime — it's the package-manager routines: you pull dependencies (install), reproduce them identically in CI (ci), scan for vulnerabilities (audit), and build a distributable bundle (pack). Deno 2.8 walked right into this territory.
So I narrowed the question to this: leaving the runtime as Node, can you simply swap in the Deno CLI for npm install, npm ci, npm audit, and npm pack on the same package.json project? To see whether it actually works rather than taking the marketing copy at face value, I set up Windows 11 + Node v24.15 + Deno 2.8.0 and ran all five head-to-head with the same fixture.
[!NOTE]
This article is an experiment pinned to Deno 2.8.x. By the time I was writing it up on 2026-06-25, Deno 2.9.0 had already shipped, and 2.9 added a feature that automatically seedsdeno.lockfrompackage-lock.json,pnpm-lock.yaml, and the like. In other words, the initial lockfile migration behavior changes in 2.9. The numbers and behaviors below are all relative to 2.8.0.
First, just five terms — let's start with "what these are"
Before going deep, let me lay down only the minimal vocabulary you need to read this article, in plain terms. Readers who already know this can skip ahead without losing the thread.
- Runtime: the engine plus the bundle of standard APIs that actually executes JS/TS code. Node, Deno, and Bun fall here. Think of it as different orchestras playing the same score (your code).
-
Package manager: the tool that downloads external libraries, pins their versions, and assembles
node_modules. npm, pnpm, Yarn, Bun — and now the Deno CLI — compete for this spot. -
lockfile: a file recording the exact version, download URL, and integrity hash of every package actually installed. For npm it's
package-lock.json; for Deno it'sdeno.lock. It's a receipt that says not "3 tomatoes" but "3 tomatoes shipped from Farm A on June 25". -
frozen (reproducible) install: an install mode that does not refresh the lockfile and simply fails if the lockfile and
package.jsondisagree. It's cooking strictly to a sealed recipe and stopping if the ingredient list changes. It's the key device for preventing "but it worked on my laptop" in CI. -
audit DB: a database collecting the names and version ranges of vulnerable packages. The
auditcommand checks your lockfile against this list. It's cross-referencing the food in your fridge against a recall list.
Now that the terms are in hand, let's walk through the five scenes I actually ran, one at a time.
Scene 1 — install: same downloads, but faster, and stacked differently
The first thing I measured was install time from a cold state. npm ran as usual; for Deno I emptied DENO_DIR (the global cache location) to a new directory to make it genuinely cold before running.
| Item | npm install |
deno install |
|---|---|---|
| cold install time (3 deps) | 1,744 ms | 559 ms (~3.1x faster) |
| lockfile produced | package-lock.json |
deno.lock (no package-lock generated) |
Deno was 3x faster. But if you just parrot the commonly cited "Deno is 3.66x faster than npm" line, you'll get it wrong.
[!WARNING]
The Deno official blog's "3.66x faster" is not a figure relative to the npm CLI. It's the improvement from Deno 2.7 (3,319ms) → Deno 2.8 (906ms) when running a benchmark that downloads React, Vite, Babel, and ESLint on Linux with a freshDENO_DIR. In other words, it's "new Deno vs. old Deno." The ~3.1x I measured is a separate thing — Deno 2.8 vs. the npm CLI on Windows with the same fixture — and on top of that, npm's global cache (~/.npm) was already warm, yet cold Deno still won. A fully cold npm would have been slower. The honest reading is to take it as a "direction" rather than the multiple itself.
More interesting than the speed was how things get stacked. When I looked at the top level of the node_modules each tool produced, the structure differed.
# npm: flat hoist — direct 3 + transitive 5 = all 8 at top level
node_modules/
ansi-styles/ chalk/ color-convert/ color-name/
has-flag/ lodash/ minimist/ supports-color/
# deno: junction isolation (pnpm style) — only the 3 "declared" at top level
node_modules/
.deno/ <- the real thing lives here, isolated under .deno/<pkg>@<ver>/
chalk -> junction
lodash -> junction
minimist -> junction
npm pulls even transitive dependencies (packages I didn't write down directly but that chalk dragged in, like has-flag) all the way up to the top level and lays them out flat. Deno exposes at the top level only the 3 I wrote directly into package.json, as junctions (Windows directory shortcuts), and hides the rest inside .deno/. You could liken it to npm spreading out the entire warehouse for every project, while Deno keeps a shared distribution warehouse and only sets up the display shelves.
This structural difference isn't an abstract matter of taste. It immediately splits how code behaves.
Scene 2 — phantom dependency: the same code worked on one side and failed on the other
I deliberately tried to require has-flag (a transitive dependency dragged in by chalk) even though it isn't written anywhere in package.json.
// phantom.js
const x = require("has-flag");
console.log("phantom require ok:", typeof x);
# node (flat node_modules)
$ node phantom.js
phantom require ok: function <- success
# deno (junction isolation)
$ deno run -A phantom.js
error: Cannot find module 'has-flag' <- failure
Same file, a node_modules folder of the same name — yet the results are exact opposites. Because npm's flattening lifts even undeclared packages to the top level, the require happens to succeed — and this is the notorious phantom dependency. Your code is secretly leaning on a library you never wrote into package.json, and then one day chalk swaps out its dependencies and you get an inexplicable build failure. Deno's isolation structure makes only what you declared visible, blocking this accidental dependency from the start.
This isn't a simple "Deno is stricter" ranking — it's a signal that the behavior is different. Some tools written assuming a flat layout can break under Deno's isolation structure (which is why Deno 2.8 also added a hoisted linker option that lays things out flat like npm). This "only what you declared is visible" philosophy runs along the same grain as the way uv bolted a security-inspector role onto the package install tool — and the next scene is exactly that audit story.
Scene 3 — ci: both "stop if you break the seal," but their tone differs
npm ci is a command for CI. If the lockfile is missing or disagrees with package.json, it refuses to install; it deletes the existing node_modules and then reproduces strictly from the lockfile. Deno 2.8's deno ci has nearly the same intent — the official docs describe it as roughly equivalent to rm -rf node_modules && deno install --frozen.
To check, I changed only the lodash version in package.json so it disagreed with the lockfile (deliberately not updating the lockfile) and ran both sides. Both failed correctly. The failure is the feature — stopping when you try to buy an ingredient that isn't on the sealed receipt is the heart of reproducibility. The difference was the tone of the refusal.
# npm ci
npm error code EUSAGE
npm error `npm ci` can only install packages when your package.json and
npm error package-lock.json ... are in sync. Please update your lock file
npm error with `npm install` before continuing.
npm error Invalid: lock file's lodash@4.17.15 does not satisfy lodash@4.17.21
# deno ci
error: The lockfile is out of date. Run `deno install --frozen=false`,
or rerun with `--frozen=false` to update it.
changes:
5 | - "npm:lodash@4.17.15": "4.17.15",
5 | + "npm:lodash@4.17.21": "4.17.21",
34 | - "lodash@4.17.15": { ... }
35 | - "integrity": "sha512-8xOcRHvCjno..."
35 | + "integrity": "sha512-v2kDEe57lec..."
npm wraps it up briefly: "they don't match, so refresh the lock with npm install." Deno goes a step further and shows a precise diff that even includes the integrity hashes — it spreads out, right there, which line of the lockfile should change and how. When you're tracking down "why did this break" in a CI log, that difference matters more than you'd expect. Standing two versions side by side and seeing which one gives the friendlier diagnostic is the same verification attitude I took when I compared tsgo (the native TypeScript compiler) head-to-head against the old compiler.
Scene 4 — audit: the truth behind 2 vs. 7
Now back to the puzzle from the opening. npm says "2," Deno says "7." Reading the output all the way through made the answer clear — the two were looking at the same database. Both cited the same GHSA- identifiers from the GitHub Advisory.
# npm audit — counts by "package"
lodash <=4.17.23 Severity: high (6 advisories folded into 1 line)
minimist 1.0.0 - 1.2.5 Severity: critical
=> 2 vulnerabilities (1 high, 1 critical)
# deno audit — counts by "advisory," one by one
Command Injection in lodash ............ GHSA-35jh-r3h4-6jhm high
Prototype Pollution in lodash .......... GHSA-p6mc-m468-83gw high
ReDoS in lodash ........................ GHSA-29mw-wpgm-hmr9 moderate
... (6 lodash advisories expanded)
Prototype Pollution in minimist ........ GHSA-xvch-5gv4-984h critical
=> Found 7 vulnerabilities (3 moderate, 3 high, 1 critical)
npm counts "how many packages are vulnerable" (lodash 1 + minimist 1 = 2), while Deno counts "how many advisories were hit" (lodash 6 + minimist 1 = 7). They merely aggregate the same threat in different units — neither is wrong. So when you compare the two tools' audit results, you should line up not the "counts" but the advisory IDs, vulnerable version ranges, and dependency paths.
[!WARNING]
It's safest not to assert exactly which DBdeno audituses. Even Deno's own docs phrase it differently — the 2.6 announcement post says "GitHub CVE database," thedeno auditCLI docs generally say "vulnerability databases" (+ a socket.dev option via--socket), and the package-manager migration docs say "npm advisory database." An assertion like "Deno uses OSV" is not confirmed by official sources. What I can say empirically from my experiment goes only as far as the fact that "both surfaced the same GHSA identifiers."
On top of that, even with the same package.json, if npm and Deno installed using different lockfiles (package-lock.json vs. deno.lock), the dependency graph being inspected could be subtly different in the first place. In that case the difference in audit results isn't a "false positive" but the result of inspecting a different graph. deno audit --fix, like npm, automatically bumps vulnerable direct dependencies within the SemVer-compatible range, but conservatively leaves major upgrades and transitive dependencies untouched (deno audit docs).
Scene 5 — pack: this one actually wasn't the same kind of tool
Here I ran into the biggest misconception. At first I tried to compare npm pack and deno pack side by side on the same package.json project, but after reading the official docs I had to change my experiment design. The deno pack docs nail it down — deno pack is not equivalent to npm pack. It doesn't even read your existing package.json, and it doesn't honor .npmignore or lifecycle scripts.
So I ran the two each according to its own role. npm pack on a plain package.json project, and deno pack on a deno.json-based TypeScript project.
# npm pack — compresses the source as-is (no transformation)
package/index.js
package/test.js
package/package.json (4 files, 944B)
# deno pack — from a single mod.ts...
package/package.json <- "synthesized" from deno.json metadata
package/mod.js <- TS "transpiled" to JS
package/mod.d.ts <- type declarations "generated" (680B)
The results reveal the essence. npm pack is a compressor — it puts the files you currently have into a tarball as-is. deno pack is a distribution transpiler — it turns .ts into .js, generates .d.ts type declarations, and synthesizes a package.json from deno.json, converting it into a bundle that npm consumers can understand. One "packages the npm package you already have," the other "ushers code from the Deno/JSR world through customs into the npm world."
So the sentence "deno pack swallowed npm pack" is wrong. More precisely: deno pack didn't replace npm's packing — it's a different category of tool that automates the customs process of exporting a library written in Deno/JSR to npm. The docs even honestly note the limits — bin entries for CLIs, and native add-ons, are out of scope.
On the side — how far does runtime compatibility go?
Separate from the package-manager discussion, I ran the same index.js (code that loads Node built-ins fs, path, crypto, and os plus lodash, minimist, and chalk via CommonJS) under both node and deno run. The output was byte-for-byte identical — the sha256 hash, the lodash results, and chalk's ANSI colors were all the same, with the only difference being argv0 (node.exe vs. deno.exe). Deno also passed all seven of the trickier built-ins (child_process, worker_threads, cluster, v8.serialize, vm, process.binding, os.cpus) in a CommonJS context.
The "76.4% Node compatibility" the Deno 2.8 blog touts roughly matches this experience, but you mustn't misread the number.
[!NOTE]
76.4% is relative to Node.js's own test suite (3,405 of 4,457 passing), not the JS language standard (test262) and not a claim that "76% of real projects run." If the API or build tool you use is in the remaining ~24%, that project breaks. Test pass rate and project compatibility are different axes.
In fact I directly saw the boundary of compatibility in two places.
-
requireis context-sensitive. For a bare.jsfile outside apackage.json(type: commonjs), Deno treated it as ESM and threwrequire is not defined. Node is more lenient. It's a porting trap that's sensitive to file location and thetypefield. -
Lifecycle scripts don't run by default. When I set a
postinstallat the root and installed,npm installran the script (creatingPOSTINSTALL_RAN.txt) butdeno installdid not. This is double-edged — it's a security advantage that blocks postinstall, a frequent vector for supply-chain attacks, by default, but at the same time it's a trap that breaks packages relying onnode-gypbuilds or postinstall (e.g., some native add-ons).
At a glance — where in the npm toolchain did Deno 2.8 land?
📊 The decision-flow diagram for this section renders in the original article on var.gg →
An honest verdict table on replaceability
| Question | Verdict | Why |
|---|---|---|
Does deno install replace npm install? |
Partially yes | It reads package.json, pulls from the npm registry, and builds node_modules. But it uses a separate deno.lock, and as of 2.8 it neither reads nor writes package-lock.json. |
Does deno ci replace npm ci? |
Yes, if you adopt deno.lock |
Both fail on a lockfile mismatch, delete node_modules, and do a frozen install. Only the reference lockfile differs. |
Does deno audit replace npm audit? |
Detection is comparable, policy differs | It surfaces the same GHSAs but differs in counting unit, autofix scope, and JSON/signature-verification features. |
Does deno pack replace npm pack? |
Mostly no (different category) | The official docs explicitly say it's "not equivalent." It's a Deno/JSR → npm converter. |
Does deno run replace node? |
Pure JS/npm is worth experimenting with, but not everything | Claims 76.4% Node test pass rate. Native add-ons, postinstall, and layout-dependent tools are the boundary. |
So who else is being compared against npm?
Deno isn't the only one eyeing npm's job. pnpm, Yarn, and Bun have long competed for the same spot.
| Tool | Install structure | CI reproducibility | Built-in audit | pack/publish | One-line take |
|---|---|---|---|---|---|
| npm | flat node_modules (ecosystem baseline) |
npm ci (lock required) |
audit / audit fix / audit signatures
|
npm pack / publish standard |
Can be slow, but it's the baseline. Strongest publish compatibility |
| pnpm | content-addressable + hardlink (disk savings) | frozen-lockfile by default, pnpm ci (11.x) |
pnpm audit, signing supported |
pnpm pack / publish |
Strong for large monorepos and strict dependencies |
| Yarn Berry | PnP by default (.pnp.cjs, no node_modules) |
--immutable (CI default) |
yarn npm audit |
yarn npm publish |
Zero-install philosophy. Migrating existing projects needs care |
| Bun | fast manager + runtime (bun.lock) |
--frozen-lockfile |
bun audit |
bun pm pack (follows npm pack rules) |
Strong on speed and all-in-one. Full Node compat needs separate checking |
| Deno 2.8 | global cache + isolated/hoisted linker |
deno ci (deno.lock) |
deno audit / --fix / --socket
|
deno pack (JSR→npm conversion, npm pack ≠) |
install/ci/audit are worth experimenting with; pack is a different category |
So when is it worth swapping in?
-
Worth trying: small services with mostly pure JS/TS dependencies, no native add-ons or complex workspaces. When you want to keep the runtime on Node and just swap install and audit to Deno for speed. When you want to block postinstall by default for security. Authors who want to also distribute a Deno/JSR library to npm (this matches the original purpose of
deno packexactly). -
Be cautious: native add-on dependencies (which need a local
node_modules,--allow-ffi, and approved scripts), complex bundler/plugin toolchains that assume a flat layout, large monorepos usingpnpm-workspace.yaml(2.8 doesn't read this file), Yarn PnP projects, and organizations bound to the npm publish lifecycle ornpm audit signaturescompliance.
Honest conclusion
The opening 2 vs. 7 wasn't a sign that Deno was wrong or that npm was wrong, but that the two tools stand in the same territory and work by slightly different rules. The five scenes of this article revealed those rules one by one.
Deno 2.8 has not "fully replaced" npm. But for install, ci, and audit, it has now reached a level where you can seriously consider swapping it in on the same package.json project — faster cold installs, an isolation layout that blocks phantom dependencies, friendlier lockfile diffs, and an audit that sees the same GHSAs. On the other hand, pack is not a replacement for npm pack but a bridge that exports Deno/JSR to npm, and run's full Node compatibility remains a per-project thing to verify.
If you smudge over this difference and write "Deno 2.8 swallowed the npm workflow whole," you get a marketing piece. If you surface the difference and split it into "this part is a replacement, that part is a different category," you get a technical piece. The most honest way to evaluate a new tool is always the same — don't trust the slogans; run it yourself on the same fixture. It's the very attitude we confirmed in the case of Vite 8, where a framework was swallowing its build tool as a built-in.
Experiment environment: Windows 11 x64 · Node v24.15.0 / npm 11.12.1 · Deno 2.8.0. The fixture was composed of lodash@4.17.15, minimist@1.2.5, and chalk@4.1.2 (intentionally vulnerable versions for the audit comparison), and all numbers are local measurements from 2026-06-26. lockfile seed behavior may differ on Deno 2.9 and later.
Top comments (1)
Great breakdown! I'm curious if