[FEATURE] Bundle architecture improvements: lazy loading, async I/O, platform-specific vendoring

Resolved 💬 3 comments Opened Feb 28, 2026 by paultendo Closed Mar 28, 2026

Preflight Checklist

  • [x] I have searched existing requests and this feature hasn't been requested yet
  • [x] This is a single feature request (not multiple features)

Problem Statement

I profiled the cli.js bundle (v2.1.62) and found several architectural issues that compound into meaningful startup and runtime overhead. These are root causes behind many of the symptom-level issues already filed (#7336, #21261, #17974, #22265, #19452).

This is not an "it feels slow" report. Everything below is backed by analysis of the minified bundle.

1. V8 spends 32% of startup time parsing the bundle

CPU profiling (node --cpu-prof) shows compileSourceTextModule consuming 31.7% of all samples during import. The 11.3MB single-file bundle takes ~300ms to parse before any application code executes (vs 23ms for a bare Node script). An additional 7.3% is spent on spawnSync calls and 3.5% on garbage collection.

2. Everything is eagerly loaded (only 20 dynamic imports)

The entire bundle is parsed and evaluated upfront. There are only 20 import() expressions in the whole file. Every user pays the cost of every feature, regardless of what they use.

Libraries that could be lazily loaded on first use, identified by mapping reference density across 100KB chunks:

| Library | Approx. size in bundle | When actually needed |
|---------|----------------------|---------------------|
| AWS Bedrock SDK | ~1.1MB | Only if using Bedrock |
| highlight.js (182 languages) | ~1MB | Only when rendering code |
| OpenTelemetry | ~900KB | Only if telemetry enabled |
| Google Vertex + gRPC + Protobuf | ~800KB | Only if using Vertex |
| RxJS | ~300KB | Could be deferred |
| sharp (native dep) | 16MB | Only for image processing |

3. highlight.js ships all 182 languages

The bundle includes definitions for Brainfuck, MIPS assembly, Flix, Zephir, Inform7, Lasso, and 137 other languages a coding assistant will never highlight. Trimming to the ~40 commonly needed languages would save ~786KB of parse weight.

4. Four HTTP clients, three validation libraries

Different SDKs each brought their own dependencies, so the bundle contains:

HTTP clients: Axios (104 references, scattered throughout), Undici (77 references, concentrated at 1.3-1.6MB offset), Got (24 references, concentrated at 7.7MB offset), Native fetch/http.request (16 references).

Validation libraries: Zod (dominant, 0-400KB region), Ajv (400-500KB region), JSON Schema (scattered).

These are functionally redundant. Each adds parse time, memory, and bundle size.

5. 413 synchronous filesystem calls

| Call | Count |
|------|-------|
| existsSync | 196 |
| statSync | 109 |
| readFileSync | 108 |
| mkdirSync | 58 |
| readdirSync | 35 |
| writeFileSync | 17 |
| spawnSync | 8 |
| execSync | 4 |

Each blocks the event loop. Many likely follow the existsSync(path) && readFileSync(path) pattern (two syscalls where one try { await readFile(path) } catch {} would do). The spawnSync calls include which lookups at startup.

6. 1,087 CJS/ESM interop wrappers

Every CommonJS module bundled into this ESM bundle gets a __toESM/__commonJS/__require compatibility shim. 1,087 of them adds both parse weight and a small per-module runtime cost.

7. Unnecessary polyfills for Node 20

| Polyfill | Count | Node 20 status |
|----------|-------|----------------|
| Promise shims | 62 | Native since Node 0.12 |
| Symbol shims | 57 | Native since Node 4 |
| Async transform helpers | 57 | Native since Node 8 |
| AbortController polyfill | 3 | Native since Node 15 |
| Object.assign polyfill | 4 | Native since Node 4 |

These are likely from transitive dependencies compiled for older Node versions or browser environments.

8. Ripgrep vendors all 6 platform binaries (61MB)

10M  arm64-darwin
9.4M arm64-linux
7.6M arm64-win32
11M  x64-darwin
11M  x64-linux
12M  x64-win32

Unlike sharp (which correctly uses optional dependencies to install only the current platform's binary), ripgrep ships all six. ~51MB of unused binaries per installation.

9. Ink rendering may scale with conversation length during streaming

The terminal UI uses Ink (React for the terminal). Analysis of the component tree:

| Metric | Count |
|--------|-------|
| createElement calls | 6,457 |
| useState hooks | 578 |
| useMemo hooks | 98 |
| useCallback hooks | 268 |
| React.memo() wrappers | 11 |
| State updates in stream handlers | 16 |

Only 1.9% of stateful components are wrapped in React.memo(). In Ink, every state update triggers a full virtual DOM reconciliation of the component tree. During streaming, each arriving token triggers state updates, which cause React to re-evaluate unmemoised components.

There is some render batching (5 batch update calls, 61 throttle references), and I cannot determine from minified code which of the 578 useState hooks are in the streaming hot path vs. one-time setup (settings panels, dialogs, etc.). However, the low memo() ratio means that components which are in the render path during streaming will re-render unnecessarily. As the conversation grows, the rendered output grows, and each re-render diffs more terminal content. This would make streaming cost scale with conversation length, consistent with #22265 ("becomes slower and slower as session progresses"). A runtime profile of a long session would confirm whether this is the primary cause.

10. Permission matching uses linear scans with per-call string parsing

The permission system uses linear scans over the allow-list (30 linear search sites, 21 iteration sites over the allow array). Permission rules in the format Bash(git:*) are parsed from string using indexOf("(")/split("(") (98 instances), though some of these are in the settings validator (run at load time) rather than the per-call matcher.

Even so, the runtime permission check iterates the allow array (visible in the .some() and for-of loops at the MCP server matching sites). With ~100 allow rules (not unusual for a power user), this is O(rules) per check with glob matching (40 glob references in permission code). Precompiling rules into a Map keyed by tool name at load time would reduce this.

11. 67 .includes() calls inside loop bodies

Linear search inside iteration is the classic O(n*m) pattern. 67 instances were found. Whether these are performance-relevant depends on the size of the collections involved, which cannot be determined from static analysis alone. The most likely candidates for large collections are:

  • Permission allow-list scanning (covered above)
  • Tool matching: 102 sites with linear search over tool collections
  • MCP server matching: multiple passes through allowedMcpServers with .some() then for-of loops

12. 71% compression ratio suggests bundler inefficiency

The raw bundle compresses from 11.3MB to 3.3MB with zlib (71% reduction). Combined with duplicated error messages appearing 3 to 12 times each (e.g. "Invalid base64 string." x12, AWS config errors x8 each), this suggests the bundler is not deduplicating effectively.

Estimated bundle composition

Component                                   Size     %
-------------------------------------------------------
App code + core logic                     7,540 KB  55.7%
AWS Bedrock SDK                           1,100 KB   8.1%
highlight.js (182 langs)                  1,000 KB   7.4%
OpenTelemetry                               900 KB   6.6%
Google Vertex + gRPC + Protobuf             800 KB   5.9%
RxJS                                        300 KB   2.2%
Embedded WASM (base64)                       93 KB   0.7%
Other (Ink, Zod, Ajv, Axios, etc.)        1,807 KB  13.3%
-------------------------------------------------------
Total                                    13,540 KB

Proposed Solution

High impact, lower effort:

  • Use optional/platform-specific dependencies for ripgrep (like sharp already does). Saves ~51MB per installation.
  • Use highlight.js/lib/core with only registered languages. Saves ~786KB of parse weight.
  • Audit spawnSync and which calls on the startup path; convert to async.
  • Wrap message/conversation UI components in React.memo() so unchanged messages skip reconciliation during streaming. (Warrants a runtime profile first to confirm which components are in the hot path.)
  • Precompile permission rules into a Map keyed by tool name at settings load time, avoiding repeated string parsing and linear scans.

High impact, higher effort:

  • Code-split the bundle so provider SDKs (Bedrock, Vertex), OpenTelemetry, sharp, and tree-sitter load on first use via dynamic import().
  • Unify HTTP clients. Pick one (probably native fetch, available since Node 18) and use it across all SDK wrappers.
  • Convert hot-path existsSync + readFileSync patterns to async equivalents with Promise.all where independent.
  • Split cli.js into a thin entrypoint + lazy chunks to reduce V8 parse time.
  • Set bundler target to Node 18+ to eliminate unnecessary polyfills and CJS interop.
  • If runtime profiling confirms that rendering scales with conversation length, virtualise the conversation output so only visible messages are in the render tree.

Alternative Solutions

The existing issues (#7336, #16254, #16826, #18497) propose lazy loading for MCP tools at the context/token level. That addresses token budget but not the underlying bundle architecture. The improvements proposed here are complementary and would benefit all users regardless of MCP configuration.

Priority

High - Significant impact on productivity

Feature Category

Performance and speed

Use Case Example

  1. User runs claude in a project directory
  2. V8 spends ~300ms just parsing the 11.3MB bundle before any code runs
  3. All provider SDKs (Bedrock, Vertex), OpenTelemetry, 182 highlight.js languages, and 4 HTTP clients are loaded regardless of what the user needs
  4. 61MB of ripgrep binaries for other platforms sit on disk unused
  5. As the conversation grows, streaming responses may slow due to under-memoised Ink components

With the proposed changes, startup would parse a fraction of the code, only the needed provider SDK would load, and streaming performance would remain constant regardless of conversation length.

Additional Context

Reproduction:

# Parse time (run outside a Claude Code session)
time node --eval "
const start = performance.now();
import('/path/to/claude-code/cli.js')
  .catch(() => {})
  .finally(() => {
    console.log('Import time:', (performance.now() - start).toFixed(0), 'ms');
    process.exit(0);
  });
"

# CPU profile
node --cpu-prof --cpu-prof-dir=/tmp/claude-prof --eval "
import('/path/to/claude-code/cli.js')
  .catch(() => {})
  .finally(() => setTimeout(() => process.exit(0), 100));
"

# Sync I/O count
python3 -c "
import re
data = open('/path/to/claude-code/cli.js').read()
for pat in ['existsSync','statSync','readFileSync','mkdirSync','readdirSync','writeFileSync','spawnSync','execSync']:
    print(f'{pat}: {len(re.findall(pat, data))}')
"

Environment: Claude Code v2.1.62 (npm), macOS Darwin 25.2.0 arm64, Node v20.19.4 (nvm)

View original on GitHub ↗

This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗