Startup Performance: How a Heavy CLI Achieves Fast Cold Starts

A deep dive into Claude Code's startup optimization — parallel prefetching, compile-time dead code elimination, dynamic lazy loading, performance profiling, and understanding cold start engineering for large CLIs

The Problem

Claude Code is a large TypeScript CLI application. It depends on heavy modules like OpenTelemetry (~400KB) and gRPC (via @grpc/grpc-js, ~700KB), has over 1,900 source files, and registers 60+ slash commands and 30+ tools. When a user types claude in their terminal and hits Enter, the application needs to:

  1. Parse and evaluate all top-level module imports
  2. Read multi-layered configuration (MDM enterprise policies, macOS Keychain, user settings, project settings...)
  3. Initialize telemetry, permissions, and GrowthBook feature flags
  4. Connect to MCP servers, load plugins and skills
  5. Restore or create a session and render the interactive TUI

If this process were executed naively in sequence, cold start would easily exceed one second. Yet in practice, claude responds quite quickly. How does it pull this off?

This article dives deep into Claude Code's startup path, starting from the first line of code in main.tsx, analyzing every optimization technique it employs layer by layer: parallel prefetching, Bun compile-time dead code elimination, dynamic lazy loading, performance profiling infrastructure, and the deferred require pattern for handling circular dependencies.

Phased Initialization in main.tsx

Claude Code's entry file src/main.tsx serves as the "orchestration center" for the entire startup flow. Its design philosophy is to split startup into multiple phases, parallelize each phase as much as possible, and precisely measure the duration of each phase through profileCheckpoint().

src/main.tsx:1-20
TypeScript
1// These side-effects must run before all other imports:
2// 1. profileCheckpoint marks entry before heavy module evaluation begins
3// 2. startMdmRawRead fires MDM subprocesses (plutil/reg query) so they run in
4// parallel with the remaining ~135ms of imports below
5// 3. startKeychainPrefetch fires both macOS keychain reads (OAuth + legacy API
6// key) in parallel — isRemoteManagedSettingsEligible() otherwise reads them
7// sequentially via sync spawn inside applySafeConfigEnvironmentVariables()
8// (~65ms on every macOS startup)
9import { profileCheckpoint, profileReport } from './utils/startupProfiler.js';
10
11// eslint-disable-next-line custom-rules/no-top-level-side-effects
12profileCheckpoint('main_tsx_entry');
13import { startMdmRawRead } from './utils/settings/mdm/rawRead.js';
14
15// eslint-disable-next-line custom-rules/no-top-level-side-effects
16startMdmRawRead();
17import { ensureKeychainPrefetchCompleted, startKeychainPrefetch } from './utils/secureStorage/keychainPrefetch.js';
18
19// eslint-disable-next-line custom-rules/no-top-level-side-effects
20startKeychainPrefetch();

The placement of these three top-level side effects is carefully deliberate — they are positioned before all other import statements. In JavaScript/TypeScript, import statements are static and modules are synchronously evaluated at import time. main.tsx has close to 200 lines of import statements, and module evaluation takes approximately 135ms. By placing the timestamp at the very first line (profileCheckpoint('main_tsx_entry')) and then immediately launching two async subprocesses, those subprocesses can run in parallel with the subsequent 135ms of module evaluation.

Once all import statements complete, the code immediately records:

src/main.tsx:209
TypeScript
209profileCheckpoint('main_tsx_imports_loaded');

This phased model can be summarized with the following diagram:

...

Note that the main() function itself (line 585) is not where everything happens. After setting up signal handlers and security checks, it calls run() (line 884), which creates a Commander instance and uses a preAction hook to defer initialization — init() only runs when actually executing a command (not when simply displaying --help):

src/main.tsx:905-917
TypeScript
905// Use preAction hook to run initialization only when executing a command,
906// not when displaying help. This avoids the need for env variable signaling.
907program.hook('preAction', async thisCommand => {
908 profileCheckpoint('preAction_start');
909 // Await async subprocess loads started at module evaluation (lines 12-20).
910 // Nearly free — subprocesses complete during the ~135ms of imports above.
911 await Promise.all([ensureMdmSettingsLoaded(), ensureKeychainPrefetchCompleted()]);
912 profileCheckpoint('preAction_after_mdm');
913 await init();
914 profileCheckpoint('preAction_after_init');
915 // ...
916});

The preAction hook first awaits the previously launched async subprocesses — but since they ran in parallel with the 135ms of imports, they've usually already completed by this point, making the await essentially zero-cost.

Parallel Prefetching: startMdmRawRead() and startKeychainPrefetch()

These two functions are among the most elegant designs in Claude Code's startup optimization. Their core idea is: launch async subprocesses to perform time-consuming I/O operations during the synchronous blocking period of module evaluation.

MDM Raw Read

startMdmRawRead() is responsible for reading enterprise MDM (Mobile Device Management) configuration. On macOS, this means reading plist files via the plutil subprocess; on Windows, it reads the registry via reg query.

src/utils/settings/mdm/rawRead.ts:55-60
TypeScript
55export function fireRawRead(): Promise<RawReadResult> {
56 return (async (): Promise<RawReadResult> => {
57 if (process.platform === 'darwin') {
58 const plistPaths = getMacOSPlistPaths()
59 const allResults = await Promise.all(
60 // ... execute plutil in parallel for each plist path

The key point is that fireRawRead() returns a Promise that is called immediately during module evaluation, with the subprocess running in the background. The result is cached via a module-level variable rawReadPromise:

src/utils/settings/mdm/rawRead.ts:30
TypeScript
30let rawReadPromise: Promise<RawReadResult> | null = null

Keychain Prefetch

startKeychainPrefetch() is even more refined. Reading the Keychain on macOS requires calling the system's security command-line tool, with each call taking approximately 32-33ms. Claude Code needs to read two entries:

  1. OAuth credentials ("Claude Code-credentials") — ~32ms
  2. Legacy API key ("Claude Code") — ~33ms

If executed sequentially, this wastes approximately 65ms on every macOS startup. Prefetching parallelizes these two reads:

src/utils/secureStorage/keychainPrefetch.ts:45-60
TypeScript
45function spawnSecurity(serviceName: string): Promise<SpawnResult> {
46 return new Promise(resolve => {
47 execFile(
48 'security',
49 ['find-generic-password', '-a', getUsername(), '-w', '-s', serviceName],
50 { encoding: 'utf-8', timeout: KEYCHAIN_PREFETCH_TIMEOUT_MS },
51 (err, stdout) => {
52 resolve({
53 stdout: err ? null : stdout?.trim() || null,
54 timedOut: Boolean(err && 'killed' in err && err.killed),
55 })
56 },
57 )
58 })
59}

Note that this module's import chain is intentionally minimal — it directly imports child_process and a lightweight macOsKeychainHelpers.ts, rather than the full macOsKeychainStorage.ts. The source code comments explicitly explain why:

流程
// Imports stay minimal: child_process + macOsKeychainHelpers.ts (NOT
// macOsKeychainStorage.ts — that pulls in execa human-signals
// cross-spawn, ~58ms of synchronous module init).

Importing the full keychain storage module would bring in execa, human-signals, cross-spawn, and other dependencies — synchronous module initialization alone would take ~58ms, which completely defeats the purpose of prefetching.

Parallel Timing Diagram

The following timing diagram illustrates the "execute async I/O during synchronous blocking" pattern:

...

By the time the preAction phase awaits these Promises, the subprocesses have long since completed. The await simply retrieves results from cache with virtually zero overhead. This is the essence of the "fire-and-forget + late-collect" pattern.

Special Handling for --bare Mode

It's worth noting that startKeychainPrefetch() is skipped in --bare mode:

src/utils/secureStorage/keychainPrefetch.ts
TypeScript
1if (isBareMode()) return // --bare mode doesn't read keychain

--bare is a minimal mode that skips hooks, LSP, plugin sync, auto-memory, background prefetching, Keychain reads, and CLAUDE.md auto-discovery. Authentication is strictly limited to ANTHROPIC_API_KEY or apiKeyHelper configured via --settings. This is designed for scripting and CI/CD scenarios, optimizing for the fastest possible startup.

feature() and Bun Compile-Time Dead Code Elimination

Claude Code uses Bun for building and bundling. Bun provides a special module bun:bundle whose feature() function implements compile-time conditional compilation — this is not a runtime feature flag but a decision made at build time about whether code is included in the final output.

src/commands.ts:59
TypeScript
59import { feature } from 'bun:bundle';

How It Works

feature() is evaluated at compile time to a true or false constant. Bun's bundler (or the JavaScript engine's dead code elimination) then removes branches that will never execute. This means disabled features don't just avoid execution — their entire module trees are never loaded.

In src/commands.ts, this pattern is used extensively:

src/commands.ts:62-122
TypeScript
62const proactive =
63 feature('PROACTIVE') || feature('KAIROS')
64 ? require('./commands/proactive.js').default
65 : null
66const briefCommand =
67 feature('KAIROS') || feature('KAIROS_BRIEF')
68 ? require('./commands/brief.js').default
69 : null
70const assistantCommand = feature('KAIROS')
71 ? require('./commands/assistant/index.js').default
72 : null
73const bridge = feature('BRIDGE_MODE')
74 ? require('./commands/bridge/index.js').default
75 : null
76const remoteControlServerCommand =
77 feature('DAEMON') && feature('BRIDGE_MODE')
78 ? require('./commands/remoteControlServer/index.js').default
79 : null
80const voiceCommand = feature('VOICE_MODE')
81 ? require('./commands/voice/index.js').default
82 : null
83const forceSnip = feature('HISTORY_SNIP')
84 ? require('./commands/force-snip.js').default
85 : null
86const workflowsCmd = feature('WORKFLOW_SCRIPTS')
87 ? (require('./commands/workflows/index.js') as typeof import('./commands/workflows/index.js')).default
88 : null
89const webCmd = feature('CCR_REMOTE_SETUP')
90 ? (require('./commands/remote-setup/index.js') as typeof import('./commands/remote-setup/index.js')).default
91 : null

Note the use of require() rather than import — this is intentional. import is static and will be executed during module evaluation regardless of any surrounding conditions. require() is dynamic and only executes when feature() returns true. When feature() evaluates to false at compile time, the entire require() call (and its dependency tree) is eliminated.

Application in the Tool System

The same pattern is used extensively in src/tools.ts to control tool loading:

src/tools.ts:26-53
TypeScript
26const SleepTool =
27 feature('PROACTIVE') || feature('KAIROS')
28 ? require('./tools/SleepTool/SleepTool.js').SleepTool
29 : null
30const cronTools = feature('AGENT_TRIGGERS')
31 ? [
32 require('./tools/ScheduleCronTool/CronCreateTool.js').CronCreateTool,
33 require('./tools/ScheduleCronTool/CronDeleteTool.js').CronDeleteTool,
34 require('./tools/ScheduleCronTool/CronListTool.js').CronListTool,
35 ]
36 : []
37const MonitorTool = feature('MONITOR_TOOL')
38 ? require('./tools/MonitorTool/MonitorTool.js').MonitorTool
39 : null
40const WebBrowserTool = feature('WEB_BROWSER_TOOL')
41 ? require('./tools/WebBrowserTool/WebBrowserTool.js').WebBrowserTool
42 : null
43const SnipTool = feature('HISTORY_SNIP')
44 ? require('./tools/SnipTool/SnipTool.js').SnipTool
45 : null

Performance Impact Analysis

Suppose an external release build has PROACTIVE, KAIROS, BRIDGE_MODE, VOICE_MODE, WORKFLOW_SCRIPTS, and other feature flags disabled. In commands.ts alone, there are 16 conditional loading points. If each module and its dependency tree averages 50KB, this means the external build saves approximately 800KB of module loading through compile-time elimination — saving not just disk space and memory, but more importantly, module evaluation time.

This reflects an important architectural decision: feature flags shouldn't just make runtime decisions — they should exclude unnecessary code at compile time.

Compile Time (Bun bundler)
feature('KAIROS')
Keep require('./commands/assistant')
Replace with null constant
Dead code elimination: remove entire module tree
Runtime
Synchronous require loads module
Zero overhead: code doesn't exist in bundle

process.env Conditions vs. feature() Conditions

Claude Code also has another type of conditional loading that uses process.env instead of feature():

src/tools.ts:16-24
TypeScript
16const REPLTool =
17 process.env.USER_TYPE === 'ant'
18 ? require('./tools/REPLTool/REPLTool.js').REPLTool
19 : null
20const SuggestBackgroundPRTool =
21 process.env.USER_TYPE === 'ant'
22 ? require('./tools/SuggestBackgroundPRTool/SuggestBackgroundPRTool.js')
23 .SuggestBackgroundPRTool
24 : null

process.env.USER_TYPE values can also be inlined by Bun at compile time (if specified in the build configuration via define), achieving the same dead code elimination effect. In external release builds, USER_TYPE is set to "external", so all === 'ant' branches are eliminated, and internal-only tools (REPLTool, SuggestBackgroundPRTool, etc.) don't appear in the external build artifact.

Dynamic import() for Lazy Loading Heavy Modules

Even after eliminating unused feature modules via feature(), some large modules are required but not needed at startup. For these, Claude Code uses dynamic import() to defer loading.

Lazy Loading OpenTelemetry

The comment in init.ts is very direct:

src/entrypoints/init.ts:44-46
TypeScript
44// initializeTelemetry is loaded lazily via import() in setMeterState() to defer
45// ~400KB of OpenTelemetry + protobuf modules until telemetry is actually initialized.
46// gRPC exporters (~700KB via @grpc/grpc-js) are further lazy-loaded within instrumentation.ts.

The OpenTelemetry SDK is ~400KB and gRPC is ~700KB — loading over 1MB of modules synchronously at startup would significantly slow down the cold start. Through dynamic import(), these modules are only loaded when telemetry is actually initialized, and this happens asynchronously inside the init() function without blocking the main startup path.

Similarly, first-party event logging is initialized asynchronously:

src/entrypoints/init.ts:94-99
TypeScript
94void Promise.all([
95 import('../services/analytics/firstPartyEventLogger.js'),
96 import('../services/analytics/growthbook.js'),
97]).then(([fp, gb]) => {
98 fp.initialize1PEventLogging()
99 // ...

Note the void prefix — this indicates the Promise is "fire-and-forget" and won't block init()'s return.

The Lazy Shim for the Insights Command

src/commands.ts contains a particularly elegant lazy loading case — the /insights command. insights.ts is a 113KB, 3,200-line file containing diff rendering and HTML generation:

src/commands.ts:188-202
TypeScript
188// insights.ts is 113KB (3200 lines, includes diffLines/html rendering). Lazy
189// shim defers the heavy module until /insights is actually invoked.
190const usageReport: Command = {
191 type: 'prompt',
192 name: 'insights',
193 description: 'Generate a report analyzing your Claude Code sessions',
194 contentLength: 0,
195 progressMessage: 'analyzing your sessions',
196 source: 'builtin',
197 async getPromptForCommand(args, context) {
198 const real = (await import('./commands/insights.js')).default
199 if (real.type !== 'prompt') throw new Error('unreachable')
200 return real.getPromptForCommand(args, context)
201 },
202}

This shim object has the same interface as the real command (type, name, description, etc.), but its getPromptForCommand method internally loads the real module via dynamic import(). Only when the user actually types /insights does the 113KB of code get loaded. This pattern generalizes to any "lightweight at registration, load on invocation" scenario.

Dynamic Loading of setup.js

Even setup.js is dynamically loaded:

src/main.tsx:1908-1909
TypeScript
1908const { setup } = await import('./setup.js');

This ensures the setup module is only loaded when actually needed for setting up the working directory and permissions.

Subcommand Skipping in Print Mode

For -p/--print mode (non-interactive), Claude Code skips registration of all 52 subcommands:

src/main.tsx:3875-3889
TypeScript
3875// -p/--print mode: skip subcommand registration. The 52 subcommands
3876// (mcp, auth, plugin, skill, task, config, doctor, update, etc.) are
3877// never dispatched in print mode — commander routes the prompt to the
3878// default action. The subcommand registration path was measured at ~65ms
3879// on baseline — mostly the isBridgeEnabled() call (25ms settings Zod parse
3880// + 40ms sync keychain subprocess)...
3881const isPrintMode = process.argv.includes('-p') || process.argv.includes('--print');
3882const isCcUrl = process.argv.some(a => a.startsWith('cc://') || a.startsWith('cc+unix://'));
3883if (isPrintMode && !isCcUrl) {
3884 profileCheckpoint('run_before_parse');
3885 await program.parseAsync(process.argv);
3886 profileCheckpoint('run_after_parse');
3887 return program;

With a simple process.argv.includes('-p') check, approximately 65ms of subcommand registration overhead is saved. This is significant for script mode, which is frequently called in pipelines (e.g., echo "fix bug" | claude -p).

The profileCheckpoint() Performance Profiling System

Claude Code has a built-in, comprehensive startup performance profiling system defined in src/utils/startupProfiler.ts. This system has two modes:

  1. Sampled logging mode: 100% of internal users + 0.5% of external users, reporting phase durations to Statsig
  2. Detailed profiling mode: Enabled via the CLAUDE_CODE_PROFILE_STARTUP=1 environment variable, outputting a complete report with memory snapshots
src/utils/startupProfiler.ts:26-36
TypeScript
26const DETAILED_PROFILING = isEnvTruthy(process.env.CLAUDE_CODE_PROFILE_STARTUP)
27const STATSIG_SAMPLE_RATE = 0.005
28const STATSIG_LOGGING_SAMPLED =
29 process.env.USER_TYPE === 'ant' || Math.random() < STATSIG_SAMPLE_RATE
30const SHOULD_PROFILE = DETAILED_PROFILING || STATSIG_LOGGING_SAMPLED

Zero-Overhead Design

When SHOULD_PROFILE is false (~99.5% of external users), profileCheckpoint() is a no-op — completely zero overhead:

src/utils/startupProfiler.ts:65-75
TypeScript
65export function profileCheckpoint(name: string): void {
66 if (!SHOULD_PROFILE) return
67
68 const perf = getPerformance()
69 perf.mark(name)
70
71 // Only capture memory when detailed profiling enabled (env var)
72 if (DETAILED_PROFILING) {
73 memorySnapshots.push(process.memoryUsage())
74 }
75}

It uses Node.js's built-in performance.mark() API for time markers and only collects process.memoryUsage() snapshots in detailed mode (since gathering memory usage information has its own overhead).

Predefined Phases

The system predefines several key phases for Statsig reporting:

src/utils/startupProfiler.ts:48-54
TypeScript
48const PHASE_DEFINITIONS = {
49 import_time: ['cli_entry', 'main_tsx_imports_loaded'],
50 init_time: ['init_function_start', 'init_function_end'],
51 settings_time: ['eagerLoadSettings_start', 'eagerLoadSettings_end'],
52 total_time: ['cli_entry', 'main_after_run'],
53} as const

This allows the team to monitor startup performance trends on the Statsig dashboard and promptly detect regressions.

Checkpoint Distribution

By searching for all profileCheckpoint() calls in main.tsx, we can see that checkpoints cover every critical node in the startup process:

CheckpointLocation (Line)Meaning
main_tsx_entry12Entry point, before module evaluation
main_tsx_imports_loaded209All imports completed
main_function_start586main() entry
main_warning_handler_initialized607Warning handler ready
run_function_start885run() entry
run_commander_initialized903Commander instance created
preAction_start908preAction hook begins
preAction_after_mdm915MDM/Keychain awaits completed
preAction_after_init917init() completed
preAction_after_sinks935Log sinks attached
preAction_after_migrations951Data migrations completed
preAction_after_remote_settings959Remote settings loading launched
action_handler_start1007Action handler begins
action_after_input_prompt1862Input prompt processing completed
action_tools_loaded1878Tools loading completed
action_before_setup1904Before setup()
action_after_setup1936After setup()
action_commands_loaded2031Commands loading completed
action_mcp_configs_loaded2402MCP config loading completed
before_connectMcp / after_connectMcp2728/2730MCP connection duration
action_after_hooks3766SessionStart hooks completed
run_main_options_built3873Commander options definition completed

This dense checkpoint network lets the team precisely pinpoint the source of any performance regression.

Deferred Require Pattern for Circular Dependencies

In a large project with 1,900+ files, circular dependencies are nearly unavoidable. Claude Code uses deferred require() functions to break cycles:

src/tools.ts:61-72
TypeScript
61// Lazy require to break circular dependency: tools.ts -> TeamCreateTool/TeamDeleteTool -> ... -> tools.ts
62const getTeamCreateTool = () =>
63 require('./tools/TeamCreateTool/TeamCreateTool.js')
64 .TeamCreateTool as typeof import('./tools/TeamCreateTool/TeamCreateTool.js').TeamCreateTool
65const getTeamDeleteTool = () =>
66 require('./tools/TeamDeleteTool/TeamDeleteTool.js')
67 .TeamDeleteTool as typeof import('./tools/TeamDeleteTool/TeamDeleteTool.js').TeamDeleteTool
68const getSendMessageTool = () =>
69 require('./tools/SendMessageTool/SendMessageTool.js')
70 .SendMessageTool as typeof import('./tools/SendMessageTool/SendMessageTool.js').SendMessageTool

The same pattern appears in main.tsx:

src/main.tsx:69-73
TypeScript
69// Lazy require to avoid circular dependency: teammate.ts -> AppState.tsx -> ... -> main.tsx
70const getTeammateUtils = () => require('./utils/teammate.js') as typeof import('./utils/teammate.js');
71const getTeammatePromptAddendum = () => require('./utils/swarm/teammatePromptAddendum.js') as typeof import('./utils/swarm/teammatePromptAddendum.js');
72const getTeammateModeSnapshot = () => require('./utils/swarm/backends/teammateModeSnapshot.js') as typeof import('./utils/swarm/backends/teammateModeSnapshot.js');

This pattern has several clever aspects:

  1. Function wrapping: const getX = () => require('...') ensures require() is only executed when the function is called, not during module evaluation
  2. Type safety: as typeof import('...') preserves full TypeScript type inference
  3. Caching: Node.js/Bun's require() has built-in module caching, so calling getTeamCreateTool() multiple times only loads the module once

The difference from the feature() pattern is: feature() is a compile-time decision — code either exists or doesn't; deferred require() is a runtime strategy — code always exists in the bundle but loading is postponed until first use.

Multi-Source Configuration Loading Priority

Claude Code's configuration system supports five sources, ordered from lowest to highest priority:

src/utils/settings/constants.ts:7-22
TypeScript
7export const SETTING_SOURCES = [
8 // User settings (global)
9 'userSettings',
10
11 // Project settings (shared per-directory)
12 'projectSettings',
13
14 // Local settings (gitignored)
15 'localSettings',
16
17 // Flag settings (from --settings flag)
18 'flagSettings',
19
20 // Policy settings (managed-settings.json or remote settings from API)
21 'policySettings',
22] as const

This priority chain means enterprise policies (policySettings) can override all other settings, while command-line flags (flagSettings) can override project and user settings.

Configuration Loading Timing

Configuration loading itself follows the "start early, collect late" pattern:

src/main.tsx:502-515
TypeScript
502function eagerLoadSettings(): void {
503 profileCheckpoint('eagerLoadSettings_start');
504 // Parse --settings flag early to ensure settings are loaded before init()
505 const settingsFile = eagerParseCliFlag('--settings');
506 if (settingsFile) {
507 loadSettingsFromFlag(settingsFile);
508 }
509
510 const settingSourcesArg = eagerParseCliFlag('--setting-sources');
511 if (settingSourcesArg !== undefined) {
512 loadSettingSourcesFromFlag(settingSourcesArg);
513 }
514 profileCheckpoint('eagerLoadSettings_end');
515}

eagerParseCliFlag() is a minimal argv parser — it doesn't use Commander's full parsing but directly scans process.argv to find the --settings flag value. This ensures settings are available before init().

Remote managed settings and policy limits are loaded asynchronously:

src/main.tsx:953-958
TypeScript
953// Load remote managed settings for enterprise customers (non-blocking)
954void loadRemoteManagedSettings();
955void loadPolicyLimits();
956profileCheckpoint('preAction_after_remote_settings');

The void prefix once again indicates these are non-blocking. Remote settings take effect automatically upon arrival via a hot-reload mechanism.

Deferred Evaluation and Memoization of Command Lists

Command list construction embodies the same philosophy — declared as a function to defer evaluation until first invocation:

src/commands.ts:257-258
TypeScript
257// Declared as a function so that we don't run this until getCommands is called,
258// since underlying functions read from config, which can't be read at module initialization time
259const COMMANDS = memoize((): Command[] => [
260 addDir,
261 advisor,
262 agents,
263 // ... 60+ commands
264])

memoize() ensures the command list is built only once. This matters because some commands (like login()) need to read configuration during initialization — if the list were built during module evaluation, the configuration system wouldn't be ready yet.

Session Restoration Paths: teleport, remote, resume

Claude Code has three session restoration modes, each with different startup paths and performance characteristics.

--continue / --resume: Local Restoration

The simplest mode. --continue resumes the most recent conversation in the current directory, while --resume restores a specific conversation via session ID or interactive selector:

src/main.tsx:3355-3363
TypeScript
3355} else if (options.resume || options.fromPr || teleport || remote !== null) {
3356 // Clear stale caches before resuming to ensure fresh file/skill discovery
3357 const { clearSessionCaches } = await import('./commands/clear/caches.js');
3358 clearSessionCaches();
3359 let messages: MessageType[] | null = null;
3360 let processedResume: ProcessedResume | undefined = undefined;
3361 let maybeSessionId = validateUuid(options.resume);

Note that caches are cleared before restoration — this ensures the restored session sees the latest file and skill changes.

--remote: Remote Sessions

--remote creates a Claude Code Web (CCR) remote session:

src/main.tsx:3401-3440
TypeScript
3401// --remote and --teleport both create/resume Claude Code Web (CCR) sessions.
3402if (remote !== null || teleport) {
3403 await waitForPolicyLimitsToLoad();
3404 if (!isPolicyAllowed('allow_remote_sessions')) {
3405 return await exitWithError(root, "Error: Remote sessions are disabled by your organization's policy.", () => gracefulShutdown(1));
3406 }
3407}

Remote mode requires an additional blocking wait for policy limits to load (waitForPolicyLimitsToLoad()), since enterprises may prohibit remote sessions. This is one of the few places that requires a blocking wait.

--teleport: Cross-Device Restoration

Teleport is the most complex restoration path, supporting cross-device session restoration. It requires:

  1. Fetching session data from the API
  2. Verifying Git repository match
  3. Switching to the correct branch
  4. Processing message history
src/main.tsx:3504-3519
TypeScript
3504} else if (teleport) {
3505 if (teleport === true || teleport === '') {
3506 // Interactive selection
3507 logEvent('tengu_teleport_interactive_mode', {});
3508 const teleportResult = await launchTeleportResumeWrapper(root);
3509 if (!teleportResult) {
3510 // User cancelled
3511 }
3512 } = await checkOutTeleportedSessionBranch(teleportResult.branch);
3513 messages = processMessagesForTeleportResume(teleportResult.log, branchError);
3514 } else if (typeof teleport === 'string') {
3515 // Resume directly via session ID
3516 const sessionData = await fetchSession(teleport);

Teleport's progress UI is dynamically imported (teleportWithProgress dynamically imported at call site, comment at line 187), avoiding loading teleport-related modules when teleport isn't being used.

Interaction Between Restoration Paths and Startup Hooks

A subtle but important detail: restoration paths skip startup hooks:

src/main.tsx:2602-2607
TypeScript
2602// continue/resume/teleport paths don't fire startup hooks (or fire them
2603// with a different trigger)
2604const sessionStartHooksPromise = options.continue || options.resume || teleport || setupTrigger
2605 ? undefined
2606 : processSessionStartHooks('startup');

This is because when restoring a session, conversationRecovery.ts triggers a 'resume' type hook, avoiding duplicate execution with the startup hook.

Parallelizing setup() and Command Loading

In the action handler, setup() and command/agent loading are parallelized:

src/main.tsx:1913-1934
TypeScript
1913// Parallelize setup() with commands+agents loading. setup()'s ~28ms is
1914// mostly startUdsMessaging (socket bind, ~20ms) — not disk-bound, so it
1915// doesn't contend with getCommands' file reads.
1916const preSetupCwd = getCwd();
1917// Register bundled skills/plugins before kicking getCommands()
1918if (process.env.CLAUDE_CODE_ENTRYPOINT !== 'local-agent') {
1919 initBuiltinPlugins();
1920 initBundledSkills();
1921}
1922const setupPromise = setup(preSetupCwd, permissionMode, ...);
1923const commandsPromise = worktreeEnabled ? null : getCommands(preSetupCwd);
1924const agentDefsPromise = worktreeEnabled ? null : getAgentDefinitionsWithOverrides(preSetupCwd);
1925// Suppress transient unhandledRejection if these reject during the
1926// ~28ms setupPromise await before Promise.all joins them below.
1927commandsPromise?.catch(() => {});
1928agentDefsPromise?.catch(() => {});
1929await setupPromise;

Several design decisions are worth noting:

  1. initBuiltinPlugins() and initBundledSkills() execute synchronously before parallel launches — they are pure in-memory operations (<1ms, zero I/O), but getCommands() internally calls getBundledSkills() which synchronously reads their results. If placed inside setup() (the previous approach), the parallel getCommands() would memoize an empty list.

  2. commandsPromise?.catch(() => {}) suppresses transient unhandledRejection — during the 28ms wait for setupPromise, if commandsPromise throws an exception before being awaited, Node.js would report an unhandled rejection. The empty catch solves this.

  3. In worktree mode (worktreeEnabled), parallelization isn't possible — because setup() calls process.chdir(), and commands and agents need the post-chdir working directory.

Transferable Patterns: Cold Start Optimization Checklist for Large CLIs

From Claude Code's startup optimization, we can distill a general-purpose cold start optimization checklist for large CLIs:

1. Phased Initialization + Checkpoint Marking

Split the startup process into distinct phases, mark each with checkpoints, and establish a quantifiable performance baseline:

流程
cli_entry imports_loaded init_start init_end action_start setup ready

Don't guess where it's slow — use data. Claude Code's profileCheckpoint() system has zero overhead for 99.5% of cases, collecting data only from sampled users.

2. "Fire Early, Collect Late" Parallel I/O

Identify I/O operations in the startup path (file reads, subprocess calls, network requests), launch them at the earliest possible moment, and collect results at the latest moment they're needed:

Text
1| Operation | Launch Time | Collection Time | Parallel Window |
2|-----------|-------------|-----------------|-----------------|
3| MDM read | Before module evaluation | preAction | ~135ms |
4| Keychain read | Before module evaluation | preAction | ~135ms |
5| Remote settings | After init() | Hot-reload | Unlimited |
6| MCP connection | action handler | After REPL render | ~500ms |

3. Compile-Time Elimination > Runtime Checks

If you know a feature won't be used in a particular build configuration, eliminate it at compile time rather than skipping it at runtime. Bun's feature() is one implementation; Webpack's DefinePlugin + NormalModuleReplacementPlugin is another. The key is enabling the bundler's tree-shaking to remove entire unused module trees.

4. Lazy Shim Pattern

For components that need metadata at registration time but full code only at execution time (commands, routes, plugins), create lightweight shim objects:

TypeScript
1const heavyCommand: Command = {
2 name: 'heavy',
3 description: 'Does heavy work',
4 async execute() {
5 const real = (await import('./heavy-impl.js')).default;
6 return real.execute();
7 }
8}

5. Deferred Require to Break Circular Dependencies

In large codebases, fully resolving circular dependencies through refactoring is often prohibitively expensive. Function-wrapped require() breaks cycles with minimal invasiveness while maintaining type safety:

TypeScript
1const getHeavyDep = () =>
2 require('./heavy-dep.js') as typeof import('./heavy-dep.js');

6. Mode-Aware Fast Paths

Skip unnecessary initialization based on the running mode. Claude Code skips registration of 52 subcommands in -p/--print mode (saving 65ms) and skips hooks, LSP, plugins, and all non-essential components in --bare mode:

TypeScript
1if (isPrintMode && !isCcUrl) {
2 // Skip all subcommand registration
3 await program.parseAsync(process.argv);
4 return program;
5}

7. Memoize Expensive Computations

Once command lists, tool lists, and skill lists are computed, cache results via lodash/memoize:

TypeScript
1const loadAllCommands = memoize(async (cwd: string): Promise<Command[]> => {
2 // ... expensive loading logic
3});

When caches need invalidation (e.g., a new skill was dynamically added), provide an explicit clearCache() method:

TypeScript
1export function clearCommandMemoizationCaches(): void {
2 loadAllCommands.cache?.clear?.()
3 getSkillToolCommands.cache?.clear?.()
4 getSlashCommandToolSkills.cache?.clear?.()
5 clearSkillIndexCache?.()
6}

8. Minimize Import Chains

Prefetch modules (like keychainPrefetch.ts) must maintain minimal import chains. If the prefetch module itself pulls in heavy dependencies, the parallel advantage of prefetching is negated by the synchronous module evaluation overhead. Clearly comment why you chose child_process over execa, and why you import helpers rather than storage.

9. Non-Blocking Background Tasks

Place cleanup, syncing, prefetching, and other non-critical tasks in the background:

TypeScript
1// Non-blocking
2void loadRemoteManagedSettings();
3void loadPolicyLimits();
4
5// Background tasks launched later
6if (!isBareMode()) {
7 startDeferredPrefetches();
8 void import('./utils/backgroundHousekeeping.js')
9 .then(m => m.startBackgroundHousekeeping());
10}

10. Observability First

Before optimizing, establish observability. Claude Code's approach:

  • Sampled reporting to Statsig (production monitoring)
  • CLAUDE_CODE_PROFILE_STARTUP=1 detailed reports (local debugging)
  • Duration and memory usage for each phase
  • Automatic detection and reporting of performance regressions

Without measurement, there is no optimization. Without continuous monitoring, optimizations degrade as new features are added.

Conclusion

Claude Code's cold start optimization isn't a single silver bullet but a combination of carefully designed techniques:

  • Parallel prefetching hides async I/O behind synchronous module evaluation
  • Compile-time dead code elimination removes unused feature module trees at build time
  • Dynamic import() lazy loading defers the cost of heavy modules until first use
  • The profileCheckpoint() profiling system provides zero-overhead performance observability
  • The deferred require pattern solves circular dependencies with minimal invasiveness
  • Mode-aware fast paths skip unnecessary initialization based on the usage scenario

Each technique is well-known in isolation, but their combination — together with the overarching principle of "measure first, optimize second, monitor continuously" — enables a CLI application with 1,900 source files and heavy dependencies like OpenTelemetry and gRPC to deliver a fast cold start experience.

For developers building their own large CLIs or desktop applications, these patterns are highly transferable. The core idea is simple: treat every millisecond on the startup path as a scarce resource, and win them back through parallelization, deferral, and elimination.