DEV Community

curioustore
curioustore

Posted on • Originally published at var.gg

Did Deno 2.8 Swallow the npm Toolchain? I Ran install, ci, audit, and pack Myself

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
Enter fullscreen mode Exit fullscreen mode

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 seeds deno.lock from package-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's deno.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.json disagree. 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 audit command 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 fresh DENO_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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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..."
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 DB deno audit uses. Even Deno's own docs phrase it differently — the 2.6 announcement post says "GitHub CVE database," the deno audit CLI 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)
Enter fullscreen mode Exit fullscreen mode

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.

  1. require is context-sensitive. For a bare .js file outside a package.json (type: commonjs), Deno treated it as ESM and threw require is not defined. Node is more lenient. It's a porting trap that's sensitive to file location and the type field.
  2. Lifecycle scripts don't run by default. When I set a postinstall at the root and installed, npm install ran the script (creating POSTINSTALL_RAN.txt) but deno install did 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 on node-gyp builds 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 pack exactly).
  • 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 using pnpm-workspace.yaml (2.8 doesn't read this file), Yarn PnP projects, and organizations bound to the npm publish lifecycle or npm audit signatures compliance.

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)

Collapse
 
frank_signorini profile image
Frank

Great breakdown! I'm curious if