tarek.kharsa

home / lessons / pi · lesson 2

PerformancePi · lesson 2 of 82 min read

Entry points are cost contracts

Importing one 6 KB function loaded 106 files. Now a commit check counts them.

Problem

Importing one small function through a barrel file can quietly load a whole package, and nothing fails.

Idea

Treat each entry point as a budget: count the files its imports reach, forbid heavy paths, and fail the commit when it grows.

Here's a performance bug no profiler flags and no test catches: importing one small function made Node evaluate 106 files. The function was one file, 6 KB. The package entry point was a barrel, an index.ts that re-exports everything, and importing through it evaluates the whole graph behind it.

Try it: what an import drags in

switch between the barrel and a narrow subpath, and between two ways to import a type

Through the barrel: 106 files for one function. Through a narrow subpath: one file. But import { type X } from "barrel" still emits a runtime import and brings the barrel back; only import type { X } is fully erased.

The fix, and the guardrail

The fix was ordinary: narrow subpath exports, each mirroring its source path, so callers import ./harness/session instead of the barrel. That took one graph from 42 files to 19, and a server process from 112 files / 98 MB to 28 files / 69 MB.

The guardrail is what's worth copying. A script runs on every commit, walks the value-import graph of each declared entry point, and fails when it exceeds its budget or reaches a forbidden file:

"./models": {
  maxFiles: 15,
  forbid: ["providers/", "models.generated.ts", "index.ts", ...],
},

Entry points are cost contracts. [...] one stray export * can silently make a narrow entry drag an entire barrel [...] and nothing fails until someone measures a process.

How the check works

  1. Start from the exports mapThe package's exports is the only list of public entry points, so the budgets are keyed by it. A budget for an export that doesn't exist fails too.
  2. Follow value imports onlyimport type and export type are erased before Node runs, so they're skipped. Imports across workspace packages are followed into their source.
  3. Compare against two limitsA file count, and a list of paths the entry must never reach, like the provider code or the generated model list. On failure it prints the whole graph, so the bad import is visible.

It paid for itself on the first run, catching two lines written as import { type Usage } from "…", which looks type-only but emits a runtime import of the module.

Why count files, not milliseconds

Deterministic

A file count doesn't flake on a slow CI machine, so it can block a commit.

Points at the cause

The failure lists the files reached, not just "startup got slower".

The same repo also checks that some packages still bundle for the browser: what a module drags in is part of its interface.

Steal this

Budget what a module drags in, not just how fast it runs. A file count and a forbidden-path list per entry point catch the stray export * and the import { type X } that nobody notices in review.

Use it in your code

A prompt for your coding agent

Run it from the root of your repo. It checks whether the idea fits before it changes anything.

Add a check that limits how much code each of this project's entry points pulls in.

The idea: importing one small function through a barrel file (an index that re-exports everything) can load the whole package. Give each entry point a budget, a file count plus forbidden paths, and fail the check when its import graph grows past it.

1. Find the entry points (package.json "exports", main or module fields, or the app's startup modules) and any barrel files.
2. For each entry point, report how many local files its value imports reach. Note that import { type X } from "..." can still emit a runtime import; only import type { X } is fully erased.
3. Write a small script that walks each entry point's import graph and fails if it exceeds its budget or reaches a forbidden path, such as a heavy provider folder. Start budgets at today's numbers plus a little headroom.
4. Add it to the existing lint or check command, and fix any obvious import { type ... } cases.

Keep the script short and dependency-free, and show me the numbers it found.

Read the source

Where this comes from

Pi (earendil-works/pi), the minimal, extensible agent harness. The files behind this lesson: