Home / Articles / How Deno 2.x Quietly Solved Node Compatibility and Tooling Fatigue

This article is published in English.

How Deno 2.x Quietly Solved Node Compatibility and Tooling Fatigue

This article walks through Deno's 2.0–2.9 releases, showing how npm compatibility, permission sets, and built-in tooling removed the friction that once made developers abandon it.

2160 words

That trial run happened five years back.

Last month, a quick side project needed a small HTTP server. A fresh directory, deno init, and ten minutes later there was a working server complete with TypeScript, tests, formatting, and linting already wired up. No tsconfig file to write. No prettier setup. No eslint config. No jest.config.ts. No package.json in sight.

Checking the version showed 2.9.3.

It turns out Deno had pushed out ten minor releases between October 2024 and July 2026, and each one quietly resolved one of the exact frustrations that caused the original abandonment. None of it registered at the time because the tool had already been mentally filed under "neat idea, not ready for real work," and there was no reason to look again.

Here's a rundown of what actually changed, one old complaint at a time.

Complaint 1: npm packages were second-class citizens

This was the sticking point for a lot of developers, this one included. Under Deno 1.x, anything not published to deno.land/x or lacking proper ES module URLs simply didn't work. The split between the Deno world and the npm world seemed like it was there to stay.

Deno 2.0, released in October 2024, closed that gap completely. You can now pull in any package from npm's catalog of more than two million packages directly, using the npm: specifier:

import express from "npm:express@5";
import { PrismaClient } from "npm:@prisma/client";

If you'd rather stick with a package.json, that's supported as well. Deno parses it, fetches dependencies from the npm registry, and can even generate a local node_modules folder if you turn that setting on. Private registries also work through .npmrc, exactly like they do in Node.

The statistic that really made the case: over 75 percent of Node's own test suite now runs successfully under Deno. That's not a superficial shim — it points to real, verified compatibility.

The moment that clinched it was pointing Deno at an existing Express project. Not a single import statement needed changing. A deno.json file was added with "nodeModulesDir": "auto", deno install was run, and cold installs came in around 900ms, compared to over 3 seconds for npm under the same conditions (tested with a clean cache on a project using React, Vite, Babel, and ESLint). The server ran correctly on the very first attempt.

Complaint 2: the permissions system was annoying in practice

The permissions model made sense conceptually. In practice, it meant retyping something like this on every run:

deno run --allow-read=./data --allow-write=./data --allow-net=api.example.com --allow-env main.ts

Forget one flag and the process dies. Add a new dependency that happens to read an environment variable, and the process dies again. It felt closer to configuring firewall rules than building a side project.

Deno 2.5, shipped in September 2025, introduced permission sets defined right in the config file. You declare named sets under the "permissions" key in deno.json, then invoke them with the -P flag (shorthand for --permission-set):

{
  "permissions": {
    "default": {
      "read": ["./src", "./data"],
      "write": ["./data"],
      "net": ["api.example.com", "0.0.0.0:3000"],
      "env": true
    },
    "test": {
      "read": true,
      "write": ["./tmp"],
      "net": false
    }
  }
}

From there, running deno run -P main.ts automatically pulls the "default" set out of the deno.json in the working directory, or you can run deno test -P=test to apply a separate set just for tests. Test code and production code get different permission scopes without touching a single command-line flag. The underlying security guarantees haven't changed — the tedium around them has simply disappeared.

There's also a DENO_AUDIT_PERMISSIONS environment variable, which produces a JSONL log recording every permission check made while the program runs. That makes it possible to see exactly what your dependencies are trying to access without digging through their source code.

Complaint 3: I still needed a pile of extra tools alongside it

Node's blessing and its burden is that nearly every responsibility gets delegated to a separate package. Need formatting? Grab prettier. Linting? ESLint and a handful of plugins. Testing? Jest or Vitest, plus a config file to handle TypeScript transforms. Type checking? tsc, configured independently from whatever runs your code. Bundling? Take your pick from half a dozen bundlers, each with its own configuration dialect.

Deno bakes all of this in as built-in subcommands. These predate the 2.0 release:

deno fmt          # formats TS, JS, JSON, HTML, CSS, YAML, SQL
deno lint         # built-in linter with quick fixes
deno test         # test runner with coverage, snapshots, sharding
deno check        # type checking
deno compile      # single binary, cross-platform, code signing
deno bench        # benchmarking
deno doc          # documentation generation

Starting with 2.8, six additional subcommands arrived:

deno audit        # security audit of dependencies
deno audit fix    # auto-upgrade vulnerable packages to nearest patched version
deno why          # explain why a package is installed (traces dependency paths)
deno transpile    # strip types, emit .d.ts declarations
deno pack         # build an npm-publishable tarball from JSR/Deno code
deno ci           # reproducible install for CI (errors without lockfile)

And with 2.9 came more:

deno desktop      # build native desktop apps via webview (experimental)
deno list         # show dependency tree (like npm ls)
deno link/unlink  # local package linking for development

The one that gets the most use is deno compile. Building a small CLI tool and running deno compile --target x86_64-unknown-linux-gnu main.ts produces a self-contained binary. Nothing else needs to be installed on the target machine — you just copy it to a server and execute it.

Watch out: the resulting binary bundles V8 and the entire Deno runtime, so typical apps land somewhere around 60–100 MB. When size is a concern, deno compile --bundle (still unstable as of 2.8) applies aggressive tree-shaking and can shrink the output substantially for simple scripts. Deno's own blog demonstrated a lodash "hello world" coming in at 1.5 MB using --bundle --minify.

Complaint 4: migrating a real project felt out of reach

The biggest obstacle to changing runtimes usually has nothing to do with the runtime itself. It's the lockfile, the node_modules layout your existing tools depend on, and the scattered require() calls throughout a real codebase.

Deno 2.9 introduced lockfile seeding. Suppose a project already tracks its dependencies through one of the common package-manager lockfiles — say a package-lock.json from npm, a pnpm-lock.yaml, a yarn.lock, or a bun.lock — but has never generated a deno.lock. Running deno install in that project builds the missing deno.lock straight from whichever of those files it finds. The resolved versions match. The integrity hashes match. There's no resolution drift to worry about.

For situations that genuinely need a physical node_modules directory — native addons, or tools that scan the filesystem directly — setting "nodeModulesDir": "auto" tells Deno to create one. There's also a hoisted-layout option ("nodeModulesLinker": "hoisted" in deno.json, available since 2.8) for older tools built around npm's flat directory structure instead of pnpm-style symlinking.

The node shim is a neat touch: Deno automatically installs a node stand-in binary on your PATH, with no manual setup, the moment you install Deno itself. As long as nothing else already provides a node executable, this shim intercepts Node CLI invocations, translates the arguments, and hands them off to Deno. That means CI scripts still calling node dist/server.js keep functioning without any edits. You can disable this behavior with DENO_DISABLE_NODE_SHIM=1 if you'd rather it not happen automatically.

Bare specifier imports — writing import fs from "fs" and having it resolve to node:fs — shipped in 2.0 and became fully stable, working without any flags or configuration, as of 2.9. There's no need to go back and rewrite your import statements to migrate.

The migration attempt

Consider a modest Hono API project (Hono being a lightweight HTTP framework comparable to Express) with roughly 15 routes, a Postgres database accessed through Drizzle ORM, and a background job processor. It had been running on Node 22 with pnpm. The whole experiment used Deno 2.9.3.

Here's how the conversion went:

  1. Run deno install from the project's root directory. It generates a deno.lock file directly from the existing pnpm-lock.yaml in under two seconds.
  2. Add "nodeModulesDir": "auto" inside a freshly created deno.json.
  3. Launch the app with deno run -A src/server.ts.

It comes up cleanly. Every one of the 15 routes responds correctly, the Drizzle-based queries execute as expected, and the queue worker keeps processing jobs in the background.

Three things did break, though:

  • One test file relied on jest.mock(), which has no equivalent in Deno's built-in test runner. Swapping in a manual mock takes under five minutes.
  • A dependency referenced __dirname inside a CommonJS file, but Deno had loaded it as an ES module. The fix was adding "type": "commonjs" to that specific package's local override.
  • A worker script read process.env.NODE_ENV without importing process explicitly. That part actually worked fine, since Deno has exposed process as a global since version 2.0 — but the permission flags hadn't included env: true for that particular script, so that needed adding.

The whole conversion took roughly 25 minutes from start to finish. Cold start times dropped from about 620ms down to 320ms, based on measurements with hyperfine across 50 runs. Idle memory usage (RSS) fell from 142 MB to 64 MB. On top of that, four separate config files could be deleted: prettier, eslint, jest, and tsconfig.

Remaining rough edges

The title of this piece mentions "everything I hated," and the complaints listed earlier were genuine, common grievances among Node developers. That said, there are a few newer caveats worth flagging:

  • Node API coverage isn't complete. A 75 percent pass rate on Node's test suite implies a quarter of it still fails. That gap may not affect a typical workload, but anyone depending on unusual corners of node:vm, programmatic use of node:inspector, or deeper node:cluster functionality should check the compatibility dashboard at node-test-viewer.deno.dev beforehand.
  • Compiled native modules still need a local node_modules folder. Anything with C++ bindings — sharp, bcrypt, sqlite3, and similar packages — requires both the nodeModulesDir setting and the --allow-ffi flag. It works, but it's an extra setup step that's easy to skip by accident.
  • Install-time scripts are blocked unless you say otherwise. Packages that run postinstall hooks — think node-gyp compilation steps or prisma generate — need explicit permission via --allow-scripts=npm:package-name. This is a deliberate security choice, but it can catch a team off guard mid-migration.
  • Plenty of tooling still assumes it's talking to Node. Some utilities inspect process.versions.node and change behavior accordingly, or hard-code file paths such as /node_modules/.cache. Deno's pnpm-style symlinked module layout trips up a handful of these tools. A hoisted-mode option exists to work around this, but it's a patch, not an actual solution.

None of these issues were serious enough to block the migration described above. They could be for other projects, so it's worth verifying beforehand rather than discovering them mid-migration.

Why these fixes went unnoticed

Ten minor version releases across 21 months, each quietly resolving three to five points of friction. There was no single dramatic rewrite, no "Deno 3.0" launch event. The team rolled out granular permission sets in 2.5, brought npm-level installation speed in 2.8, and added lockfile seeding in 2.9, treating each as routine upkeep rather than headline news.

Contrast that with how framework transitions typically get publicized: a blog post, a conference talk, a migration guide, a wave of social media commentary, hot takes, and rebuttals to those hot takes. Deno bypassed that entire cycle of public debate and just shipped the fixes directly.

Developers who dismissed the runtime early often did so because they formed an opinion once and never revisited it as the tooling matured. That's a lapse in attention, not a shortcoming of the project itself.

For anyone whose impression of Deno was formed before version 2.0, the tool tested back then is essentially gone. What exists today behaves less like "an interesting TypeScript runtime that can't touch npm" and more like "Node minus its accumulated baggage." The frustrations were legitimate at the time. They've since been addressed. It's worth taking another look.