[FEATURE] Bundle architecture improvements: lazy loading, async I/O, platform-specific vendoring
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
allowedMcpServerswith.some()thenfor-ofloops
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/corewith only registered languages. Saves ~786KB of parse weight. - Audit
spawnSyncandwhichcalls 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+readFileSyncpatterns to async equivalents withPromise.allwhere independent. - Split
cli.jsinto 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
- User runs
claudein a project directory - V8 spends ~300ms just parsing the 11.3MB bundle before any code runs
- All provider SDKs (Bedrock, Vertex), OpenTelemetry, 182 highlight.js languages, and 4 HTTP clients are loaded regardless of what the user needs
- 61MB of ripgrep binaries for other platforms sit on disk unused
- 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)
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗