Blog
Build LogsAugust 2, 202613 min read

How I Built TinyPort: What a JavaScript Import Actually Costs

I built TinyPort to measure what one JavaScript import really costs: the tree-shaken, gzipped bytes a bundler ships. The architecture, and what broke.

By Muhammad Ali

Two bars at very different scale comparing a package's size on disk with the gzipped bytes a single import actually ships

TL;DR: The JavaScript import size numbers within easy reach describe the package, not the line you wrote. Measured on my own site on 2 August 2026 against Next.js 16.0.10, the next package is 139.6 MB installed while import Link from "next/link" is 20.4 kB gzipped, and one namespace import of lucide-react costs 88x what the named imports elsewhere in the repo cost. I built TinyPort to measure the real per-import bytes by generating a tiny tree-shaking consumer and handing it to esbuild, then shipped it over two days as one engine behind a CLI, an MCP server, a VS Code extension and a website. Below is how the measurement works and the two bugs that only showed up after I published.

Nobody knows what an import costs. I built TinyPort because I got tired of guessing, and because the number I kept reaching for was measuring the wrong thing entirely.

Why the JavaScript import size you're shown is the wrong number

Two words do most of the work in this post, so here they are up front.

Shipped is a disk number: the bytes in a package directory reachable from its exports, main or module entries, with tests, docs and source maps excluded. Bundled is a measurement: the minified bytes a bundler emits for one specific import, after tree shaking. Shipped is always available and always approximate. Bundled is reproducible, and sometimes unavailable.

I ran TinyPort against the repo behind this site on 2 August 2026. Next.js 16.0.10, lucide-react 0.559.0, React 19.2.1:

What On disk Bundled Gzipped
next (whole package) 139.6 MB installed, 75.4 MB shipped
import Link from "next/link" 63.5 kB 20.4 kB
lucide-react (whole package) 36.1 MB installed, 14.8 MB shipped
import { Menu, X } from "lucide-react" 11.4 kB 4.1 kB

The gap between the top-left number and the bottom-right one is about 6,800x for Next. If you have ever watched someone justify a dependency by pointing at a package page, that ratio is what they were pointing at. (next/link is a client component, which is its own conversation; I wrote about what actually changed with React Server Components when Next.js 16 landed.)

Every figure in that table is a snapshot of one day and one set of versions, and all of them will drift as those packages publish. That's rather the point: the number you want is the one from your own node_modules, today, not one quoted from a page someone wrote two years ago.

There's a sharper version of the problem inside a single package, and my own repo turned out to be the example.

Why can't Import Cost or Bundlephobia tell me this?

They are good tools. Import Cost is the reason a generation of JS developers expects to see a byte count next to an import at all, and it has 5.47 million installs to show for it. It runs webpack behind the scenes to work out an imported size. Its own README lists the limitation that matters: importing two libraries with a common dependency shows both sizes in isolation, even when the shared library only needs to be included once. Its repo has 1,384 stars and 78 open issues, and the most recent commits on its default branch are dated 17 September 2022.

Bundlephobia answers the same question on the web, per package. Nolan Lawson wrote down its two blind spots back in 2021, in a post about JavaScript performance beyond bundle size: it does not tell you the tree-shaken cost when you only import one part of a module, and it tells you nothing about subpaths, so import 'preact/compat' "could be literally anything". Five years on, both are still true.

The third option is a bundle analyzer. Nadia Makarevich's bundle size investigation guide describes the real workflow honestly: build the project, open the stats.html treemap the analyzer generated, and stare at it until you notice a block that is too big.

Tool What it gives you What it can't do
Import Cost a byte count inline in the editor account for shared dependencies (its own known issue), gate CI, or answer an agent
Bundlephobia minified and gzipped size per package tree-shaken cost, subpath cost, your editor, your CI, offline
Bundle analyzers complete truth about a built bundle say anything before the build

An analyzer tells you what happened after a build. The number has to exist at the moment the import is written, and it has to be per import, not per package.

The trick: measure the import, not the package

Bundling a package's entry file is the obvious approach and it is wrong. Feed esbuild lucide-react's entry and you get every icon in the library, because the entry is a barrel that re-exports all of them. That is a real number for a question nobody asked.

A tiny module that imports exactly what you imported

So TinyPort doesn't bundle the entry. It writes a new module, in memory, that imports precisely the bindings your line imports and re-exports them, then hands that to esbuild. For import { Menu, X } from "lucide-react", the generated consumer is this:

// generated by @tinyport/core, never written to disk
import { Menu as __n0, X as __n1 } from "lucide-react";
export { __n0, __n1 };

That module goes to esbuild through stdin, with resolveDir set to the directory of the file being analysed, so the specifier resolves through that project's own node_modules rather than TinyPort's:

// packages/core/src/size/bundle.ts, the esbuild call
await esbuild.build({
  stdin: { contents, resolveDir, loader: "js", sourcefile: "tinyport-import.js" },
  bundle: true,
  minify: true,
  write: false,
  format: "esm",
  platform: "browser",
  logLevel: "silent",
});

A diagram of one import line becoming a generated consumer module, then an esbuild bundle, then a gzipped byte count.

Tree shaking is on by default when esbuild bundles, so what comes back is what a bundler keeps for that import and nothing else. The technique isn't clever, it's a change of question. Instead of asking how big a package is, ask what a bundler does with this exact line, and let the bundler answer.

How good the answer is depends on the package being honest about sideEffects, the flag that tells bundlers its modules are safe to drop when unused. When a package omits it, TinyPort reports the larger number, which is also what your bundler will do.

Gzip then runs in @tinyport/core with node:zlib, never inside the bundler adapter, because four frontends produce that source and they all have to print the same figure. I pinned the level to 9 rather than zlib's default of 6, reasoning that the number stands in for what a CDN serves. Pinning it explicitly also means a future change to zlib's default can't silently move every number TinyPort has ever reported. Results cache per import shape, not per package, since import { Loader2 } and import { Loader2, Home, Menu } cost different amounts.

What it found in my own repo

Here is the sharper version of the problem I promised.

Every lucide-react import on this site is a named import of one to three icons, which measures around 11 kB. One is not. components/projects/tech-badge.tsx opens with import * as LucideIcons from "lucide-react", a deliberate namespace import so a tech badge can look up an icon by name at runtime. TinyPort's own output:

$ npx tinyport check "components/projects/tech-badge.tsx" --bundle --all
1 of 1 file have imports  ·  2 packages  ·  1066.3 kB total

Heaviest files
components/projects/tech-badge.tsx  1066.3 kB
  next/link                    63.5 kB (bundled)
  lucide-react                 1002.7 kB (bundled)

Same package, same repo, 88x apart from the named imports elsewhere in it. To a per-package tool those lines are identical, because per-package they are identical.

To be exact about what that number is: it's what esbuild emits for that import shape. It is not a claim that this site sends a megabyte of icons to every visitor, because what lands in a route's chunk after Next.js has had its way with it is the framework's business. TinyPort measures the line. The bundler still gets a vote.

A measurement must never be mistaken for an estimate

Size resolution runs in three tiers. Disk first, always offline, giving installed and shipped. Registry metadata second, cached, used only for deprecation and outdated checks. The esbuild bundle last and lazily, because it's the expensive one. When the bundle tier can't run, TinyPort falls back to the disk estimate; when neither works, the value is null and the output prints a dash. It never rounds a guess into something that looks measured.

Enforcing that turned out to be an API problem rather than a discipline problem. There is exactly one function that answers "what number should a human see", and every frontend goes through it:

// packages/core/src/size/bundle.ts
export function effectiveSize(record: SizeRecord): EffectiveSize {
  if (record.bundled !== null) return { bytes: record.bundled, source: "bundled" };
  if (record.shipped !== null) return { bytes: record.shipped, source: "shipped" };
  return { bytes: null, source: "unknown" };
}

It returns the provenance along with the bytes, so every printed figure carries (bundled) or (shipped) and a fallback can't quietly wear a measurement's clothes. If reading a size directly were possible, someone would eventually do it, and that someone would be me at 1am.

That provenance is also what makes a byte budget enforceable rather than advisory. check exits non-zero when a file goes over, which is the entire CI story:

npx tinyport check "src/**/*.ts" --budget 100000 --bundle   # exits 1 when over budget

Why is the engine the product and the extension just a frontend?

The design premise, written down before any code, was about who is asking. An agent writing code adds imports blind. It has no idea what framer-motion costs and no feedback loop that would tell it. A human at least gets a hover tooltip. That makes the agent-facing surface a first-class product rather than an add-on, and once you believe that, the extension stops being the product and becomes one consumer of an engine.

So @tinyport/core holds all the logic and the surfaces are thin adapters: the CLI for CI and one-off runs, the MCP server for agents, the VS Code extension for inline sizes, gutter bars and hovers while you type, and tinyport.vantaso.org, which carries the docs for all three and is the only surface a person meets before installing anything. The CLI and the MCP server shipped before the extension existed, which meant the core was validated against real repositories before an editor was ever involved. v0.1.0 through v0.4.0 all went out on 24 July 2026, about 43 hours after the first commit.

A single analysis engine at the centre feeding four surfaces: a command line tool, an MCP server, a VS Code extension, and a website.

Keeping that boundary honest needed a CI check, not a convention. All five packages live in one pnpm workspace, and scripts/check-core-deps.mjs walks packages/core/src and fails the build if core imports vscode, imports esbuild, or touches any Node builtin outside a two-entry allowlist of node:path and node:zlib. Everything else arrives through injected ports: a FileSystemPort, a NetworkPort, a BundlerPort. Tests pass in-memory fakes, the CLI passes node:fs, the extension passes VS Code's filesystem API.

That rule is also why a fifth package exists that nobody installs. esbuild resolution is Node-only, so core can't hold it, and it had already been written twice: once in the CLI, once inlined in the MCP server, cross-checked by hand. Two copies was survivable. When the extension would have made three, I pulled it into @tinyport/node-support, an internal workspace package that exists for that single reason.

One more decision, because it has a real cost. TinyPort parses with the TypeScript compiler API, parser only, no type checker: pure JavaScript with no native binaries, so the VSIX stays portable, and correct on TSX and import type with exact node positions. A native parser like oxc is faster and would have forced platform-specific builds across four packages. The bill is that unused-import detection is an identifier occurrence scan rather than a type-aware one, so a binding referenced only through a string like container.get('Foo') reads as unused.

Making it safe for something that isn't a person

The MCP server speaks the Model Context Protocol, the open standard for connecting AI applications to tools, and runs over stdio with no editor process involved. Six tools: analyze_file, scan_project, check_budget, suggest_alternatives, explain_package, apply_fixes.

Five of those read. The sixth writes, and it's where all the design went.

apply_fixes is dry-run by default. Writing to disk requires the caller to pass dry_run: false explicitly, so modifying a file is an affirmative choice rather than an omission. It also only accepts fix ids that a previous analyze_file call actually produced, and an unknown id comes back rejected with the list of valid ones. An agent can't invent an edit, because the vocabulary of possible edits is issued by the analysis step and nothing else.

Determinism is the other contract. Same input, same bytes out, findings ordered by file, then range, then rule. An agent comparing a report before and after its own change has to see a difference only when something actually changed, or the diff is noise.

And the rule that occurrence-scanning forced: unused imports get greyed out, never removed on save. Removal is an explicit code action, an explicit CLI flag, or an explicit MCP call. Deleting someone's code on a heuristic is how a tool loses trust permanently. Reordering is guarded the same way, since side-effect imports like import './reset.css' are barriers that never move, and statements relocate as opaque text with their comments attached rather than being reprinted, which is also what keeps TinyPort out of a fight with Prettier.

Two bugs that only showed up after I published

The CLI that printed nothing

v0.3.2, and it was live on npm when I found it. The entry-point guard compared the invoked path against the module's resolved realpath to decide whether it was being run directly. A symlinked bin never matches that comparison, and a symlinked bin is exactly how npx and a global npm i -g run one. So npx tinyport started, decided it had been imported rather than executed, and exited. No output, no error, exit code 0. The fix is one line. The test that keeps it fixed runs the built binary through a symlink, because the only reason this shipped is that every test ran the module directly.

The esbuild that wasn't yours

The whole promise of the bundled number is that it reflects how your project bundles, which means resolving your esbuild. The obvious implementation is require.resolve("esbuild", { paths: [projectDir] }), and it's subtly wrong: Node still consults NODE_PATH and the other global folders even when you pass explicit paths, which is documented resolution behaviour rather than a bug. The failure is silent, because an ambient esbuild bundles perfectly well and produces a number that looks completely fine.

TinyPort now walks node_modules upward from the analysed directory by hand. A directory walk has no fallback path, so it finds what that project actually has, or nothing. If it finds nothing, it uses TinyPort's own bundled esbuild, and if that fails too the figure degrades to a labelled disk estimate.

What I'd change

The limits are real. Unused detection without a type checker will keep producing false positives on string-based lookups, which is why it never deletes anything. TinyPort is per-file and per-package rather than whole-bundle, so it complements an analyzer instead of replacing one. And it only speaks JavaScript and TypeScript, though the core sits behind a language provider interface so a second language wouldn't be a rewrite.

The thing I keep coming back to is the premise. More of the code in my repos starts as an agent's edit than it used to, and an agent is never going to open a treemap and squint at it. Feedback that isn't machine-readable doesn't reach the thing writing the code.

TinyPort is live at tinyport.vantaso.org, the engine is on GitHub under MIT, and there's a fuller write-up on the TinyPort project page alongside other things I have built. If you run it on your own repo and it finds something stupid, I'd like to hear about it.

FAQ

Does TinyPort replace a bundle analyzer? No. An analyzer tells you the complete truth about a bundle you already built. TinyPort tells you what one import costs before you build, per file and per import shape.

Do I need esbuild installed for the bundled sizes to work? No. TinyPort prefers the analysed project's own esbuild so the number matches how that project bundles, falls back to its own bundled copy, and falls back again to a labelled disk estimate if neither is usable.

Does it send my dependency tree anywhere? Disk and bundled sizing are fully offline. The only network call is registry metadata for deprecation and outdated checks, which sends a package name and version, and it can be turned off entirely.

Sources

  1. Bundlephobia does not tell you the tree-shaken cost when you only import one part of a module, and it tells you nothing about subdirectory dependencies, so the cost of import 'preact/compat' is unknowable from it.
  2. Import Cost has 5,476,624 installs, uses webpack to detect the imported size, and its Known Issues state that importing two libraries with a common dependency shows both sizes isolated from each other.
  3. The import-cost repository has 1,384 stars and 78 open issues, and the most recent commits on its default branch are dated 2022-09-17.
  4. esbuild performs tree shaking, removing unused exports, and it is enabled by default when bundling.
  5. sideEffects: false in package.json is the signal a package gives bundlers that its modules are safe to drop when unused.
  6. The Model Context Protocol is an open standard for connecting AI applications to external tools and data sources.
  7. The standard bundle-size workflow is to build the project, open the analyzer's generated stats.html treemap, and inspect it visually for oversized blocks.
  8. Node's CommonJS resolution consults NODE_PATH and the global folders, which is documented behaviour rather than a bug.
  9. Bundlephobia reports the minified and gzipped size of an npm package on the web, per package.

Thanks for reading

I write about what I build.

Mostly notes from building AI products and shipping software. Have a look at what I'm working on, or just say hello.