docs/design/performance/fire-and-forget-startup-prefetch.md
The parent issue #3011 breaks down qwen-code startup optimization into multiple subtasks. The current repository has already landed several foundational capabilities:
QWEN_CODE_PROFILE_STARTUP=1 to output startup phase JSON.Config.initialize() no longer statically instantiates all tools.loadCliConfig().config.initialize() after AppContainer render are also partially implemented.The goal of #3222 is not to redo these capabilities, but to consolidate the non-critical startup operations still scattered across the startup path into a unified fire-and-forget prefetch layer: before first paint, only await operations that truly affect correctness; after first paint, launch background tasks that do not affect the correctness of the first interaction, while preserving compatible semantics for non-interactive modes.
The key flow of the current interactive startup path is as follows:
flowchart TD
A[packages/cli/index.ts] --> B[initStartupProfiler]
B --> C[gemini.main]
C --> D[parseArguments]
D --> E[loadSettings]
E --> F[sandbox / worktree / relaunch checks]
F --> G[loadCliConfig]
G --> H[register cleanup + preconnectApi fire-and-forget]
H --> I[early input capture + kitty/theme probes]
I --> J[initializeApp awaited]
J --> K{interactive?}
K -->|yes| L[startInteractiveUI]
L --> M[Ink render returns / first_paint]
M --> N[checkForUpdates fire-and-forget]
M --> O[AppContainer useEffect]
O --> P[config.initialize awaited after render]
P --> Q[MCP discovery background]
P --> R[input_enabled]
K -->|no| S[config.initialize]
S --> T[waitForMcpReady]
T --> U[runNonInteractive]
Current state assessment:
initializeApp() still executes i18n, auth, theme validation, and IDE client connection serially before first paint.qwen -i "prompt", qwen -p, stream-json, and ACP/Zed — which have no safe post-render window or whose first request needs IDE context/status — IDE connection must continue to be awaited before the first request.checkForUpdates() is already fire-and-forget after render in startInteractiveUI(), but the logic is scattered within the UI startup function.preconnectApi() is already fire-and-forget and should be kept triggering as early as possible, but brought under unified scheduling.Config construction; for plain interactive TUI it can be deferred to after render, while non-interactive paths retain the pre-first-request initialization semantics.config.initialize() already executes after React mount; MCP discovery already runs in the background inside core, with AppContainer batch-refreshing the tool list.config.waitForMcpReady(), otherwise the first prompt might not see MCP tools, causing scripted behavior to regress.Introduce a small startup prefetch scheduling layer that uniformly manages "start but don't await" tasks, split into two categories by trigger timing: early and post-render.
flowchart LR
subgraph CLI[CLI startup]
G[loadCliConfig] --> EP[startEarlyStartupPrefetches]
EP --> PC[API preconnect]
G --> IA[initializeAppCritical]
G --> HI[initializeAppWithAwaitedIde for headless / stream-json / ACP]
IA --> UI[startInteractiveUI]
end
subgraph Prefetch[StartupPrefetchController]
SP[startPostRenderPrefetches]
SP --> UP[update check]
SP --> IDE[IDE client connect only for ordinary TUI]
SP --> OTEL[telemetry SDK init for interactive TUI]
SP --> HK[background housekeeping import]
SP --> PROF[profile async task events]
end
subgraph UI[Interactive UI]
UI --> FP[Ink render / first_paint]
FP --> SP
FP --> AC[AppContainer]
AC --> CI[config.initialize]
CI --> MCP[MCP discovery background]
MCP --> BT[batched setTools]
end
subgraph Headless[Non-interactive]
CI2[config.initialize] --> WM[waitForMcpReady]
WM --> RUN[runNonInteractive]
end
The interactive startup sequence under the new design:
sequenceDiagram
participant Main as gemini.main()
participant Prefetch as StartupPrefetchController
participant UI as startInteractiveUI()
participant App as AppContainer
participant MCP as McpClientManager
Main->>Main: parseArguments + loadSettings
Main->>Main: loadCliConfig
Main->>Prefetch: startEarlyStartupPrefetches(config)
Prefetch-->>Prefetch: void preconnectApi()
Main->>Main: await initializeAppCritical(deferIdeConnection=true for ordinary TUI without initial prompt)
Main->>UI: startInteractiveUI(...)
UI->>UI: render(<AppContainer />)
UI->>Prefetch: startPostRenderPrefetches(config, settings, options)
Prefetch-->>Prefetch: void checkForUpdates()
Prefetch-->>Prefetch: void connectIdeClient() for ordinary TUI only
Prefetch-->>Prefetch: void initializeTelemetry() for interactive TUI
App->>App: await config.initialize()
App->>MCP: start background discovery
App->>App: input_enabled
MCP-->>App: mcp-client-update batches
App-->>MCP: geminiClient.setTools()
Add packages/cli/src/startup/startup-prefetch.ts, providing two entry points:
startEarlyStartupPrefetches(config: Config): void;
startPostRenderPrefetches(
config: Config,
settings: LoadedSettings,
options?: { connectIde?: boolean; initializeTelemetry?: boolean },
): void;
The scheduler does exactly three things:
void task().catch(...) to explicitly not await and not throw.The scheduler must guarantee idempotency per phase, preventing React StrictMode, repeated test calls, or anomalous re-entries from launching the same task multiple times.
startEarlyStartupPrefetches(config) is called immediately after loadCliConfig() succeeds.
The first phase includes only API preconnect:
config.getModelsConfig().config.getProxy().preconnectApi(authType, { resolvedBaseUrl, proxy }).QWEN_CODE_DISABLE_PRECONNECT, sandbox, custom CA, non-Node runtime, no proxy, etc.This adds no new configuration options. Preconnect failures only write debug logs and do not affect startup.
startPostRenderPrefetches(config, settings) is called in startInteractiveUI() after Ink render() returns and first_paint is recorded.
First batch includes:
checkForUpdates().then(handleAutoUpdate) logic, preserving the settings.merged.general?.enableAutoUpdate !== false gate.connectIde: true, and the scheduler internally still checks config.getIdeMode(). qwen -i "prompt", non-interactive, stream-json, and ACP/Zed do not defer IDE connection through this entry point.Config still retains telemetry settings, but skips the construction-time SDK side effect via deferTelemetryInitialization; post-render prefetch launches the SDK via initializeTelemetry(config). Non-interactive, stream-json, and ACP/Zed do not defer.gemini.tsx to post-render prefetch, giving all background startup tasks a unified entry point; still limited to interactive, still uses dynamic import and error swallowing.None of these tasks may affect the return value of startInteractiveUI(), nor may they write user-visible errors to the TUI stderr. Failures only go to debug logs.
initializeApp() Critical Path, Preserve Non-TUI Awaited IDE ConnectionAdd a shared helper to avoid duplicating IDE connection logic between the TUI deferred path and the non-TUI awaited path:
export async function connectIdeForStartup(config: Config): Promise<void> {
if (!config.getIdeMode()) return;
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START));
}
initializeApp() remains as pre-first-paint critical initialization, but gains an explicit option:
interface InitializeAppOptions {
deferIdeConnection?: boolean;
}
The default must remain backward-compatible: deferIdeConnection defaults to false. That is, when no option is passed, IDE connection is still awaited within initializeApp().
The awaited content of initializeApp() becomes:
initializeI18n(...)performInitialAuth(...)validateTheme(settings)deferIdeConnection !== true, await connectIdeForStartup(config)shouldOpenAuthDialogconfig.getGeminiMdFileCount()The call site in gemini.tsx is responsible for selecting based on the run mode:
const deferIdeConnection =
config.isInteractive() && !config.getExperimentalZedIntegration() && !input;
const initializationResult = await initializeApp(config, settings, {
deferIdeConnection,
});
Subsequently, only when deferIdeConnection === true, startInteractiveUI() fires-and-forgets the IDE connection via startPostRenderPrefetches(..., { connectIde: true }); prompt-interactive, which auto-submits the first question, continues to await IDE before render and passes connectIde: false to avoid post-render duplicate connection.
This split addresses the compatibility risk flagged in review:
qwen -i "prompt": continues to await IDE connection before the first auto-submitted request, and post-render does not reconnect.qwen -p / piped stdin: continues to await IDE connection before the first model request.This design does not change the core MCP state machine.
Interactive:
config.initialize() in the mount effect of AppContainer.Config.initialize() continues to launch background MCP discovery.mcp-client-update and batch-call geminiClient.setTools() at ~16ms intervals.Non-interactive / stream-json / ACP:
config.waitForMcpReady() before the first model request.Gains fall into two categories.
The first is shortened critical path before first paint:
The second is first API request gains:
Note: #3219's historical baseline showed module loading once accounted for ~94% of total startup time; #3221's lazy tool registration has already addressed the largest bottleneck. The core benefit of #3222 is more about perceived TTI and first-paint responsiveness, rather than eliminating all module loading costs.
Expected to only involve the CLI startup layer:
packages/cli/src/startup/startup-prefetch.tspackages/cli/src/core/initializer.tspackages/cli/src/gemini.tsxpackages/cli/src/ui/startInteractiveUI.tsxNo changes to:
packages/cli/src/startup/startup-prefetch.test.tsCoverage:
startEarlyStartupPrefetches() calls preconnectApi() with auth type, resolved base URL, and proxy.startPostRenderPrefetches() launches update check when enableAutoUpdate !== false.enableAutoUpdate === false.logIdeConnection() when options.connectIde === true and config.getIdeMode() === true.options.connectIde !== true.config.getIdeMode() === false even if options.connectIde === true.options.initializeTelemetry === true.options.initializeTelemetry !== true.packages/cli/src/core/initializer.test.tsAdjustments and additions:
initializeApp() by default awaits connectIdeForStartup(), preserving non-TUI path compatibility.initializeApp(..., { deferIdeConnection: true }) does not call IdeClient.getInstance() or connect().initializeApp(..., { deferIdeConnection: false }) calls and awaits IDE connect when config.getIdeMode() === true.initializeI18n().performInitialAuth().authError and shouldOpenAuthDialog === true.themeError.shouldOpenAuthDialog === false.packages/cli/src/ui/startInteractiveUI.test.tsxCoverage:
render() returns and first_paint is recorded, calls startPostRenderPrefetches(config, settings).{ connectIde: true, initializeTelemetry: true }.{ connectIde: false, initializeTelemetry: true } to avoid duplicate IDE connect.startInteractiveUI().startInteractiveUI() to reject.startInteractiveUI() inline logic, it is no longer directly called.packages/cli/src/gemini.test.tsxAdjustments and additions:
initializeApp(config, settings, { deferIdeConnection: true }), and connects IDE in post-render prefetch.initializeApp(config, settings, { deferIdeConnection: false }), and post-render prefetch does not reconnect IDE.qwen -p / piped stdin / stream-json calls initializeApp(config, settings, { deferIdeConnection: false }) or uses defaults, ensuring IDE is connected before the first request.packages/core/src/config/config.test.tsCoverage:
deferTelemetryInitialization is not passed, Config construction still calls initializeTelemetry(config).deferTelemetryInitialization === true, Config construction does not call initializeTelemetry(config), but config.getTelemetryEnabled() still returns true.Recommended execution:
cd packages/cli && npx vitest run src/core/initializer.test.ts src/startup/startup-prefetch.test.ts
cd packages/cli && npx vitest run src/gemini.test.tsx
cd packages/core && npx vitest run src/config/config.test.ts -t "telemetry"
loadCliConfig().