Fast TypeScript CLI startup with esbuild and lazy imports
Reduce TypeScript CLI startup latency using esbuild bundling, lazy imports, snapshot caching, and avoiding costly top-level imports.

TypeScript CLIs written in TypeScript often suffer from a startup latency that is noticeable the moment you hit enter. The gap between pressing return and seeing output can be 100–300ms for a medium-sized project, and that delay accumulates every time a developer runs a help command or a subcommand that should be instant. This article walks through the mechanics of that slowdown and how to eliminate it with esbuild bundling, lazy imports, and a few Node.js runtime tricks.
Why TypeScript CLIs start slow by default
Two factors dominate the cold-start cost of a TypeScript CLI: the runtime compilation step and the synchronous module graph resolution.
Tools like ts-node and tsx both compile TypeScript to JavaScript on every invocation. Even with ts-node --transpileOnly, which skips type checking, the process still parses the entire TypeScript source tree, transpiles it, and then hands the result to Node's module loader. tsxuses esbuild under the hood and is faster thants-node`, but it still re-compiles the entry point and its dependencies on each run. For a project with a few hundred files, that adds a reliable 100–200ms before your first line of business logic executes.
The second contributor is the top-level import graph. A typical CLI entry point might import a command registry, which imports a dozen command modules, each of which imports utility libraries, configuration loaders, and third-party packages like chalk, inquirer, or ora. Node's require() is synchronous and must traverse node_modules for every package it encounters. The file system calls alone—stat, readdir, realpath—add measurable latency, especially on cold caches or CI runners.
Together, these two costs mean that a CLI that does nothing but print its version string still takes a third of a second to respond. That is unacceptable for a tool that developers run dozens of times a day.
Bundling with esbuild for instant boot
The most effective single change is to pre-bundle your CLI into a single JavaScript file using esbuild. This eliminates both the compilation step and the module resolution step. Node reads one file, parses it, and runs it. No node_modules traversal, no TypeScript parsing, no loader hooks.
Here is a minimal esbuild configuration for a CLI targeting Node 14 and later:
// scripts/build-cli.mjs
import * as esbuild from 'esbuild';
await esbuild.build({
entryPoints: ['src/cli.ts'],
bundle: true,
platform: 'node',
target: 'node14',
format: 'cjs',
outfile: 'dist/cli.js',
banner: {
js: '#!/usr/bin/env node',
},
treeShaking: true,
minify: false, // keep readable for debugging
sourcemap: 'linked',
sourcesContent: false, // reduces file size
});The banner field prepends the shebang line so the output file is directly executable. Set outfile to whatever your package.json "bin" field points to. After running this build, you can invoke node dist/cli.js and see startup times consistently under 20ms.
If your package also exports types for library consumers, add a step that copies the generated .d.ts files into the output directory. The build script in package.json might look like:
{
"scripts": {
"build": "node scripts/build-cli.mjs && cp src/cli.d.ts dist/cli.d.ts"
}
}This keeps the CLI fast while still allowing downstream consumers to import types from your package without a separate type-generation step.
Lazy imports: only load what you need
Bundling solves the module resolution problem, but it does not automatically prevent esbuild from inlining every imported module into the bundle. If your entry point has a dozen static imports at the top of the file, all of those modules end up in the output, and Node will execute all top-level code in each one before your first console.log.
Replace static imports with dynamic import() inside command handlers or conditionals. For libraries that provide synchronous APIs (like chalk or ora), wrap the dynamic import in a lazy helper that caches the result after the first call:
// src/lazy.ts
export function lazyImport<T>(importFn: () => Promise<T>): () => T {
let cached: T | undefined;
return () => {
if (!cached) {
// Dynamic import is async, but we synchronously throw if not yet loaded.
// In practice, the first call triggers the import, and subsequent calls
// use the cached synchronous value.
throw new Error('Lazy import not yet initialized. Call await init() first.');
}
return cached;
};
}
export async function initLazyImports(): Promise<void> {
// Pre-warm the cache without blocking the main flow.
}A more practical pattern for CLIs is to use a top-level async main() function that awaits dynamic imports before running the command:
// src/cli.ts
#!/usr/bin/env node
async function main() {
const { Command } = await import('commander');
const { default: chalk } = await import('chalk');
const program = new Command();
program
.command('build')
.action(async () => {
const { buildCommand } = await import('./commands/build');
await buildCommand();
});
await program.parseAsync(process.argv);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});Notice that commander and chalk are loaded only after the script starts. The command handler for build does not import ./commands/build until the user actually types my-cli build. This means my-cli --help completes without ever touching the build logic or its dependencies.
Be careful with TypeScript's import type. Those are erased at runtime, but a static import { SomeType } from './types' still tells esbuild to include the file in the bundle. Use import type syntax to keep types out of the emitted JavaScript:
import type { BuildOptions } from './types';
// This line is erased at compile time; no bundle inclusion.Comparison: tsx, ts-node, and bundled output
The following table shows approximate cold-start times for a CLI with 150 source files and 20 third-party dependencies, measured on a MacBook Pro M1 with an SSD. Times are median of ten runs, measured with time:
| Runtime | Cold start (ms) | Notes |
|---|---|---|
ts-node (default) |
~450 | Includes type checking |
ts-node --transpileOnly |
~320 | No type checking, still resolves modules |
tsx |
~150 | Uses esbuild, but re-compiles per run |
| Pre-bundled .js (esbuild) | ~15 | Single file, no resolution |
The pre-bundled file is an order of magnitude faster than the next best option. The difference is especially stark on CI runners or containerized environments where file system caching is absent.
Snapshot caching and v8 compile cache
Node 22 introduced --experimental-v8-compile-cache, which persists compiled bytecode to disk. On a second invocation, Node skips parsing and compilation for files that have not changed. This reduces startup by 30–50% for projects that still use tsx or ts-node, but it is less impactful for a pre-bundled file because the bundler already eliminated most of the compilation cost.
If you cannot bundle—perhaps because your CLI dynamically loads user plugins—you can use the node:module register() API with a custom loader that intercepts require calls and caches compiled output. Here is a minimal example:
// cache-loader.mjs
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const cache = new Map();
export function resolve(specifier, context, nextResolve) {
return nextResolve(specifier, context);
}
export function load(url, context, nextLoad) {
if (cache.has(url)) {
return cache.get(url);
}
const result = nextLoad(url, context);
cache.set(url, result);
return result;
}Run your CLI with node --experimental-loader ./cache-loader.mjs dist/cli.js. The cache lives in memory per process, so it only helps within the same invocation (e.g., if you require() the same module multiple times). For cross-invocation caching, you would need to serialize the cache to disk, which adds complexity and cache invalidation logic.
Snapshot caching is a viable option for teams that cannot adopt bundling, but it introduces a moving target: V8's internal bytecode format changes between Node versions, so cached snapshots must be invalidated on upgrade.
Failure modes and gotchas
Bundling with esbuild breaks dynamic require() calls that rely on runtime file resolution. If your CLI loads plugins or configuration files by path at runtime, esbuild cannot know those paths at build time. Use require.resolve to resolve paths at build time and inline the assets, or mark the dynamic require as external in the esbuild config:
// esbuild config
external: ['./plugins/*'],This keeps the require statement in the output, but you lose the single-file benefit.
Lazy imports hide import errors until a specific code path executes. A typo in a module path inside a command handler will only surface when a user runs that command—potentially in production. Mitigate this by running every command handler in CI. A simple smoke test that invokes each subcommand once will catch broken imports.
Source maps become critical when you bundle. Without them, stack traces point to line numbers in the single output file, which is nearly useless for debugging. Bundle with sourcemap: 'linked' or sourcemap: 'external' and publish the map file alongside the binary. Setting sourcesContent: false keeps the generated map file small while still providing correct line mappings back to your original TypeScript source.
Another subtle issue: if your CLI uses __dirname or __filename for finding assets relative to the script's location, bundling changes those values because the output file lives in a different directory. Use require.resolve with a known relative path, or pass asset paths as environment variables.
Key takeaways
- Pre-bundle your CLI with esbuild to eliminate both the TypeScript compilation step and Node's module resolution overhead. Cold starts drop from hundreds of milliseconds to under 20.
- Replace static top-level imports with dynamic
import()inside command handlers. Only load the modules a specific command needs, and load third-party libraries likechalkandcommanderlazily. - Use
import typefor type-only imports to prevent esbuild from bundling files that contribute no runtime code. - If you cannot bundle, consider Node 22's
--experimental-v8-compile-cacheor a custom loader withnode:module.register()for partial startup improvements. - Test every command path in CI to catch lazy import failures, and always generate external source maps so debugging remains possible.
- For projects that already practice reproducible builds through lockfile discipline, bundling fits naturally into the same deterministic pipeline. And if you maintain a monorepo with multiple CLIs, pnpm workspaces with filters and CI caching can help orchestrate the build for each package independently.
Frequently asked questions
- Why is my TypeScript CLI so slow to start?
- Most likely because you're using ts-node or tsx without bundling, so every invocation re-compiles and resolves modules. The fix is to pre-bundle your CLI into a single JavaScript file with esbuild, and lazy-load large dependencies.
- Should I use tsx or esbuild for my CLI?
- tsx is great during development because it auto-compiles on save, but for production distribution you should bundle with esbuild. Pre-bundled binaries start much faster because Node doesn't need to resolve or compile anything.
- How do I measure startup time accurately?
- Use `hyperfine` with multiple warm-up runs to avoid file system caching effects. Inside your CLI, you can also log `process.hrtime.bigint()` at the very first line and at the end of the bootstrap phase, then subtract.


