If you’re running JavaScript on a server, how do you import a module? Traditionally, imports looked like this, with CommonJS:
const axios = require('axios');
But now they look like this with ECMAScript Modules:
import axios from 'axios';
// Or, less often, dynamically:
const axios = await import('axios');
However, there’s another layer of complexity: import specifiers.
2021: Node.js introduces the node: protocol
I think the first kind of specifier for a real runtime - and I’ll be specific about that because the Webpack bundler supported a tremendous variety of import styles before this - was Node.js, in …
If you’re running JavaScript on a server, how do you import a module? Traditionally, imports looked like this, with CommonJS:
const axios = require('axios');
But now they look like this with ECMAScript Modules:
import axios from 'axios';
// Or, less often, dynamically:
const axios = await import('axios');
However, there’s another layer of complexity: import specifiers.
2021: Node.js introduces the node: protocol
I think the first kind of specifier for a real runtime - and I’ll be specific about that because the Webpack bundler supported a tremendous variety of import styles before this - was Node.js, in 2021 when they introduced the node: protocol.
Before the node: protocol was introduced, in Node.js you’d import the OS library like this, by importing a module called os:
import os from 'os';
After they introduced the node: protocol, you’d do this:
import os from 'node:os';
This has some benefits:
NPM modules can’t contain the : character, so there can’t be overlap between these node: modules and userspace modules from NPM. So Node.js can introduce more modules in the future without fearing overlap from NPM. Plus, it’s more explicit - you immediately know which modules are from Node.js itself.
Node.js supports both versions still, but there are linter rules like useNodejsImportProtocol to push you to using the node: protocol.
2018: Deno introduces https imports
Deno (a Node alternative) originated from Ryan Dahl (Node’s creator) reflecting on his mistakes and building something new. In his talk 10 Things I Regret about Node.js from 2018, he proposed that defaulting to NPM was bad because it centralized on only one module registry. And then he presented the first look at Deno, which would work with “relative or absolute URLs ONLY.”

2022: Deno introduces the npm: protocol
Unfortunately, this did not last. In 2022, Deno stabilized NPM compatibility and introduced the npm: protocol.
This let you import an NPM module like this:
import { chalk } from "npm:chalk@5";
Supporting the NPM ecosystem, which is the largest and most popular registry for JavaScript, was probably a necessity for Deno to have any traction. At this point, Deno did not support package.json, the NPM standard for storing which versions of NPM modules you were using. So compatibility looked like:
-
Node & Deno:
import * from "chalk"only if Deno has an import map. In this case, you need an import map for Deno and apackage.jsonfile for Node.js. -
Deno only:
-
import * from "https://esm.sh/chalk" -
import * from "npm:chalk"
2023: Deno introduces package.json support
In 2023, Deno introduced support for package.json, which made it significantly more compatible with Node.js:
-
Node & Deno:
import * from "chalk"with package.json -
Deno only:
-
import * from "https://esm.sh/chalk" -
import * from "npm:chalk"
2024: Deno introduces the jsr: protocol
Then Deno introduced JSR, an alternative to NPM. You could import JSR modules a bunch of different ways, but one of them was:
import * as chalk from "jsr:@nothing628/chalk";
So now Deno supports three protocols (jsr:, npm:, and node:) and Node supports one (node:). JSR has been a mixed bag so far, not clearly ‘better’ than NPM in ways that the community values, and it’s very hard to overcome the network effects of something like NPM.
2024: Deno moves away from HTTP imports
Also in 2024, Deno started moving away from HTTP imports, with blog posts about what HTTP imports got wrong and then in 2025, they published ‘If you’re not using npm specifiers, you’re doing it wrong’.
2025: The current messy state of affairs
Here’s a basic chart of the module specifier situation as of today (December 8, 2025), the best I can see based on manual testing and reading documentation.
| Specifier | Deno | Node | Bun | Val Town |
|---|---|---|---|---|
| axios | ✅ (with deno.lock) | ✅ | ✅ | ⛔️ (no user-provided deno.lock yet) |
| npm:axios | ✅ | ⛔️ | ⛔️ | ✅ |
| https://esm.sh/axios | ✅ (discouraged) | 🟠 (in userspace) | ⛔️ | ✅ |
| jsr:axios | ✅ | ⛔️ | ⛔️ | ✅ |
| node:fs | ✅ | ✅ | ✅ | ✅ |
Note that runtime support trickles into downstream tools. So for example, the TypeScript compiler targets Node and doesn’t have any support for the npm: protocol, https imports, or other things that Deno does. The ‘safe subset’ of features for tools is what Node does, which is bare imports and the node: protocol.
Certainly one of the lessons of the last years for both Deno and Bun is that in order to compete effectively with Node they need to provide all of the functionality of Node and then more: there isn’t an angle for support https imports only, as Dahl optimistically planned in 2018.
Having invested heavily into https imports for Val Town, the state of play for module import specifiers is pretty important to me, and this is a difficult ecosystem to play with. The elegance of importing npm:chalk@9 from Deno - specifying the source, module, and version all in an import string - is super nice. HTTPS imports are really great in the sense that you don’t need to put everything on a huge filesystem and you don’t need to publish everything to NPM or create a lot of tarballs. But the future is unpredictable and sometimes we really take two steps forward and one step back.