Skip to content
Published on

Deno 2.9 — The deno desktop Experiment, Lockfile-Preserving Migration, and a 24-Hour Supply-Chain Default

Share
Authors

Introduction — Deno 2.9 on June 25, Three Weeks of Patches

Back in May, the Deno 2 era retrospective covered Deno's pivot — a runtime that once rejected Node embracing Node compatibility as a first-class citizen. This post is the follow-up. Deno 2.9 shipped on June 25, 2026, and going by GitHub release tags, weekly patches followed: v2.9.0 (June 25), then v2.9.1 (July 1), v2.9.2 (July 8), and v2.9.3 (July 15). The prior minor, 2.8.0, landed May 22 — a five-week release cadence.

The release-notes headline is deno desktop, but in my judgment two other changes matter more to practitioners: a migration path that reads existing npm, pnpm, yarn, and Bun lockfiles as-is, and a supply-chain defense that's now on by default. Let's take them one at a time, and read the vendor's performance numbers alongside their measurement conditions and what's left out. As a side note, the upheaval on the TypeScript compiler side is covered separately in TS 7 GA and the Native Compiler — this post stays at the runtime layer.

deno desktop — An Experiment Stepping Into Electron's Spot

deno desktop is a new subcommand for building native desktop apps with the web stack (PR #33441, merged June 16). The UI runs in a webview, the logic runs in Deno, and the output is a single deployable binary. In its simplest form, the entrypoint just serves the UI — Deno.serve() inside a desktop entrypoint auto-binds to the port the webview opens, so there's no port wiring to do.

// main.ts — that's the whole thing
Deno.serve(() =>
  new Response(
    "<!DOCTYPE html><h1>Hello from Deno desktop</h1>",
    { headers: { "content-type": "text/html" } },
  )
);
deno desktop main.ts        # a native window opens
deno desktop                # auto-detects the web framework in the current directory
deno desktop --hmr          # HMR during development

Framework auto-detection uses the same mechanism as deno compile, and per the official release notes it supports Next.js, Astro, Fresh, Remix, Nuxt, SvelteKit, SolidStart, TanStack Start, and Vite SSR. New runtime APIs include Deno.BrowserWindow (window control and the webview-Deno bridge), Deno.Tray (system tray), Deno.Dock for macOS, prompt()/alert()/confirm() rendered as native dialogs, and Deno.autoUpdate() for applying binary patches in the background.

The rendering engine is chosen with --backend. The default, webview, uses the OS's built-in engine — WebView2 on Windows, WebKit on macOS and Linux — which keeps the binary small and startup fast, but ties rendering to whatever engine version the host OS ships. --backend cef bundles the whole Chromium via the Chromium Embedded Framework to guarantee identical rendering across platforms, at the cost of — in the release notes' own words — tens of megabytes added and a download required at build time. It's exactly the Tauri-(webview) versus Electron-(bundled-engine) tradeoff, offered as a single choice inside one tool.

The deployment side is interesting. Following the --output extension, macOS produces .app/.dmg, Windows produces .exe/.msi, and Linux produces .AppImage/.deb/.rpm, and because the MSI and the deb/rpm installers are built in pure Rust, you can pull binaries for every platform from a single Linux CI box with --all-targets, no platform-specific packaging toolchain required. Supported targets are the same five as deno compile — Linux x64/arm64, Windows x64, macOS x64/arm64 — and Windows arm64 isn't there yet.

When not to use it. The vendor itself is explicit that this is experimental in 2.9 and the surface is still stabilizing. Putting a production desktop app on top of an experimental flag is a decision that already answers itself, and if you're shipping something today, the proven options — Electron, Tauri — are right there. Pick the webview backend and you inherit a pain Tauri users already know: "rendering can differ by the user's OS WebKit/WebView2 version." Pick cef and Electron-scale footprint comes back. The real significance of this experiment isn't production adoption today — it's a directional signal that a tool where runtime, compiler, and packaging are already one body is now reaching for the desktop too.

Lockfile Seeding — The Most Practical Feature in This Release

The biggest chunk of switching cost is always "losing a dependency graph you carefully pinned." 2.9 aims directly at that. Run deno install in a project with no deno.lock, and it reads package-lock.json, pnpm-lock.yaml, yarn.lock, or bun.lock to seed deno.lock with exactly the same resolved versions and integrity hashes (bun.lock support in PR #35394). No re-resolution means no version silently drifting up, per the official explanation. Seeding is one-directional — it reads the external lockfile to produce deno.lock, and deno.lock handles everything after that. There's no write-back.

$ deno install
Seeded deno.lock from package-lock.json

pnpm monorepos are covered too. Deno already read the workspaces field in package.json that npm, yarn, and Bun use, but pnpm alone uses a separate file — now, when it finds pnpm-workspace.yaml, it migrates the packages/catalog/catalogs settings over without touching the existing fields (#34993). And in environments without a real node binary installed, it places a stand-in node on the PATH that forwards to itself and translates Node CLI arguments (#34969) — a fix for tools like Turbopack's worker pool that call node directly; it never shadows a real node if one exists, and it can be turned off with DENO_DISABLE_NODE_SHIM=1.

The point of this bundle of changes is clear: "switching to Deno" is no longer a gamble on rebuilding your dependency graph — it's become a reversible experiment you can try with two commands while keeping your existing lockfile, and back out of if it doesn't work. That said, edge cases in lockfile-format compatibility — like how pnpm inline-encodes peers into dependency keys — were still being polished in follow-up PRs right up to release, so if you're running a complex pnpm monorepo, it's worth checking that the seeded versions actually match the originals before moving on.

Node 26 Compatibility — What "Compatibility Target" Precisely Means

2.9 raises the Node.js compatibility target to Node 26. process.version now reports v26.3.0 (#34747), and the node-compat test suite Deno runs was also bumped to 26.3.0 (#34746). For context — per endoflife.date, Node 26 is the Current line released May 5, 2026, with LTS promotion slated for October 28, 2026, and the actual latest is 26.5.0 from July 8. In other words, the compatibility target trails the real Node by two patch releases.

There are some substantive improvements too. Bare built-in module specifiers like import "fs" now unconditionally resolve to node:fs without a flag, and the bug where a package in node_modules could shadow a built-in module was fixed to match Node's own rule (built-ins always win). Node-API now reports version 10, node:test gained mock.module(), mock.timers, and file snapshots, and previously-missing APIs like process.resourceUsage() were filled in.

Here's the honest caveat: "compatibility target 26" means Deno aligned the baseline version of the compat test suite it runs against to 26.3.0 — it is not a certification that the Node 26 ecosystem works 100%. Native addons, tools that reach into V8 internals, or packages that depend on Node's internal implementation details are still something you have to check individually. The release notes don't say where that boundary sits, so there's no shortcut beyond running your candidate project's own test suite.

Reading the Vendor Benchmarks Precisely

Now the performance numbers from the release notes. All of it is vendor-measured, and the conditions are stated as follows: a single dedicated x86_64 Linux box, server and load generator pinned to non-overlapping cores, concurrency of 100, the median of 3 oha runs, cold start as the mean of 150 hyperfine runs, compared against Deno 2.8.0.

Metric2.82.9Change
Cold start (hello world)34.2 ms17.3 ms1.98x faster
Deno.serve "realworld" (JSON POST + Bearer header echo)56.8k req/s72.4k req/s1.27x
Deno.serve plaintext77.0k req/s85.6k req/s1.11x
Deno.serve 1 MiB response1,617 req/s1,907 req/s1.18x
Peak RSS, realworld142 MB64 MB2.2x reduction
Peak RSS, 1 MiB response197 MB63 MB3.1x reduction

The multipliers check out arithmetically against the raw numbers (I divided them out myself to confirm). The release notes also state where the improvement comes from — lazy-loading node: globals out of the snapshot, confining Node bootstrap to Node workers, attaching a V8 code cache to lazily-loaded ESM, and shrinking the snapshot account for half of the cold-start gain; HTTP throughput comes from Deno's own HTTP/1.1 serving path (#34446), with the rest coming from porting crypto.subtle and console from JS to Rust alongside the RSS flattening.

What these numbers don't say is just as clear.

  • The comparison is against itself. It's 2.9 versus 2.8, not versus Node or Bun. "1.27x" is a reason to upgrade, not a reason to switch.
  • This is loopback traffic. Load was fired from the same box, just split across cores. The 1,907 req/s on the 1 MiB workload works out to roughly 16 Gbps of bandwidth — a figure only possible because it never touches a NIC, being loopback. Once a real network or TLS termination enters the picture, the picture changes.
  • This is the HTTP/1.1 plaintext path. The new serving path itself targets HTTP/1.1, and no TLS or HTTP/2 figures are given.
  • It's throughput and RSS only. There's no latency distribution — no p99 tail latency. And with only 3 runs and a median, there's no variance information either.
  • It's an echo workload. It's labeled "realworld," but it's a JSON echo with no DB and no disk. Real apps usually bottleneck somewhere outside the runtime.

If I had to pick just one thing off that table, I'd watch memory, not throughput. On 2.8, RSS ranged roughly from 94 MB to 197 MB depending on workload; on 2.9, it stays flat around 62 MB across the measured workloads. That means you can pack more instances onto the same machine, and for teams that price things by container density, that matters more in practice than the 1.1–1.3x throughput gain — with the same caveat attached: this is what happened on those specific workloads.

The Supply-Chain Default — A 24-Hour Wait Is Now the Default

The quietly biggest policy change in 2.9 is this: min-release-age — introduced back in 2.6, a feature that refuses to install npm package versions that haven't been published for long enough — is now enabled by default with a 24-hour window (#35458, merged on release day). The rationale is simple. A large share of npm supply-chain attacks get detected and have the malicious version unpublished within a day or two of publication, so waiting alone filters out a big class of attacks. As we saw in last week's axios incident writeup, even provenance leaves gaps that get exploited — so a time delay is a simple but genuinely effective extra layer of defense.

# .npmrc — an explicit value overrides the default
min-release-age=72h    # extend it to three days
min-release-age=0      # turn it off entirely

Worth flagging where this can bite in practice — a pipeline that publishes your own package to npm and installs it right after now fails by default for 24 hours. If your workflow ships a hotfix to production the moment it's published, you'll need to adjust the value in .npmrc, and that tradeoff (delaying fresh patches versus blocking the attack window) is one each team has to weigh for itself. Worth being explicit too: this applies to npm packages.

There's one more opt-in defense layered on top. trust-policy=no-downgrade, following pnpm's design, ranks the trust level of how each version was published — staged publishing with live 2FA approval, trusted publishing with a provenance attestation, or provenance alone — and turns resolution of a new version published with weaker evidence than prior versions into a hard error. It's built to catch a classic sign of a maintainer-token-theft attack (the release notes cite the August 2025 s1ngularity incident as an example): a package that was reliably published with provenance suddenly showing up published with a plain token instead. And true to form, the release notes disclose their own limitation here too — the default is off, because provenance adoption across the registry is still uneven — and that caveat is carried over as-is.

What Bites You on Upgrade

There's one behavior change. Deno.serve's automatic response compression is now disabled by default. If you were relying on the old version's automatic compression to save bandwidth, responses go out uncompressed the moment you move to 2.9 — you can bring it back per-server with automaticCompression: true, or process-wide with the DENO_SERVE_AUTOMATIC_COMPRESSION=1 environment variable. Behind a CDN you probably won't notice, but if you're serving directly, put this on your upgrade checklist.

Everything else arrived carrying the experimental label. CSS module imports (the web-standard way of getting a CSSStyleSheet via with { type: "css" }) sit behind the --unstable-raw-imports flag, and deno compile --bundle — which tree-shakes then embeds to shrink the binary — is an experimental flag with a vendor-measured figure showing a lodash hello-world binary shrinking from 11.6 MB to 1.5 MB (naturally the effect scales with how heavy your npm dependencies are, and generalizing it is something to confirm on your own build).

The test runner got noticeably heavier. Built-in snapshots via t.assertSnapshot(), parameterized tests with Deno.test.each, CI splitting with --shard, --changed to select only tests affected by changed files, --retry for flaky tests, and coverage thresholds — one by one, these erase the reasons you used to reach for Vitest or Jest. Beyond that, this release also brings input-based caching for deno task, deno list (an npm-ls equivalent), and Web Crypto implementations of NIST post-quantum algorithms — ML-KEM, ML-DSA, SLH-DSA (a runtime-side version of the same thread covered in the ML-DSA story in OpenSSH 10.4).

So, Should You Switch?

Let's sum up.

When it's worth trying

  • You're on an existing Node project and can now experiment reversibly without losing your lockfile — a structural change in switching cost is the core of this release.
  • You're a team wanting to consolidate formatter, linter, test runner, task runner, and compiler into one — 2.9's beefed-up test runner has genuinely cut external tool dependence.
  • You're an organization that wants npm supply-chain defense as a tool default — defaulting to a 24-hour wait is the most aggressive default among the major runtimes right now.

Not yet

  • A codebase that leans heavily on native addons or Node internal APIs — "compatibility target 26" doesn't cover that ground.
  • A production desktop app — deno desktop is experimental, not yet at the stage of replacing Electron or Tauri.
  • Code built on Bun-specific APIs (bun:sqlite, Bun.serve, etc.) — the lockfile gets read, but the API doesn't get translated.
  • A team already running fine on Node 26 with no concrete item to point to as the gain from switching — as the benchmark table shows, 2.9's numbers aren't a claim of superiority over Node.

For context, a one-line note on the competing runtime: per Bun's GitHub releases, the last stable was v1.3.14 on May 13 — two months without a new stable release — but commits to the repo continue today, so calling it stalled would be a stretch. Still, the difference in release cadence against Deno's weekly patches is worth noting for the record. For an assessment of where Bun stands, see May's Bun retrospective.

Closing

The logic running through Deno 2.9 is "structurally lower the cost of switching." Reading lockfiles as-is, filling the node binary's spot, migrating pnpm workspace settings, keeping pace with the Node 26 compatibility target — it all points the same direction. The self-negation of embracing Node compatibility two years ago has now reached the stage of reading a competitor's own lockfile formats. Layered on top, deno desktop is still experimental, but it's an extension of the same logic of tool consolidation, and defaulting min-release-age on is a signal that we've reached an era where the runtime itself sets the default for supply-chain defense.

The vendor numbers were useful once read alongside their conditions — within the limits of a loopback microbenchmark, the halved cold start and the flattened RSS are credible improvements, backed by an explanation of the mechanism. But the fact that Node and Bun don't appear anywhere on that table tells you exactly what kind of release this is. 2.9 isn't a "we're faster" release — it's a "we lowered the cost of moving over" release, and that claim is proven not by the numbers but by the feature list.

References