What changed when I swapped a Node script for Deno
I have a small script that walks every post in this blog before I publish, follows every outbound link, and flags anything that 404s. It lived as a Node script for about a year, ts-node and all. Last week I rewrote it in Deno mostly out of curiosity. I expected a port. What I got was a list of things the Node version had been getting away with.
The permission prompt caught a real bug
Deno runs everything sandboxed by default. No file access, no network, no env vars, no subprocess, unless you grant it at launch. First run of the port, Deno stopped and asked for network access, which I expected, then asked for read access to a path I did not expect: my shell’s .netrc file. A dependency I’d pulled in for retry logic was probing it for credentials on every request, silently, the whole time it had run under Node. Nothing malicious, just a library being defensive about auth headers in a way I’d never audited because Node never made me look.
That’s the actual pitch of the permissions model. It’s not really about blocking attackers. It’s about making every script’s blast radius visible at the command line instead of buried in a dependency tree three levels down. deno run --allow-net --allow-read=./src/data/blog link-check.ts tells you exactly what this thing touches, and if a future version of a dependency wants more than that, the run stops instead of quietly complying.
No node_modules, until you need one
By default Deno resolves npm packages from a global cache and never writes a node_modules folder in your project. My link checker uses two npm packages for markdown parsing, and the difference showed up immediately: no install step, no folder to gitignore, cold clone to first run in under two seconds. Deno 2 and 3 pushed npm compatibility hard enough that most of what’s on npm just resolves.
Most, not all. One dependency assumed a real node_modules directory existed on disk and broke without one. The fix was nodeModulesDir: "auto" in deno.json, which brings the folder back for the packages that need it. It’s an escape hatch, not a default, and I only reached for it once.
TypeScript with no build step is the part I’d miss most
The script is plain .ts. deno run link-check.ts runs it. No tsconfig.json, no ts-node register hook, no esbuild step in between. Under Node this same script needed three dev dependencies just to execute TypeScript directly, and every one of them was a place the setup could drift out of sync with the rest of the project. Under Deno there’s nothing to drift.
Where npm compatibility still gets in the way
The honest gaps: packages with native addons occasionally still misbehave, postinstall scripts don’t run the way some libraries expect, and anything relying on deep Node internals rather than the documented node: APIs is a coin flip. None of that hit my link checker, which is a small script with boring dependencies. I would not port a large Express API on a Friday afternoon and expect zero surprises. For a standalone script that used to need four dev dependencies just to run, the trade was worth making.