Skip to content

Halve CLI startup time by not loading the app for non-app commands - #8199

Draft
isaacroldan wants to merge 1 commit into
mainfrom
isaac/faster-cli-startup
Draft

Halve CLI startup time by not loading the app for non-app commands#8199
isaacroldan wants to merge 1 commit into
mainfrom
isaac/faster-cli-startup

Conversation

@isaacroldan

Copy link
Copy Markdown
Contributor

What

shopify version goes from 369 ms → 180 ms (−51%). The win applies to every command that isn't an app command.

before after Δ
version total 369 ms 180 ms −189 ms (−51%)
version tail after output 199 ms 8 ms −191 ms
version time to first output 222 ms 213 ms −9 ms
app --help time to first output 182 ms 172 ms −10 ms
modules loaded / bytes read 112 / 8.5 MB 90 / 6.5 MB −22 / −2.0 MB

Measured on bin/run.js (the production entry point — bin/dev.js sets SHOPIFY_CLI_ENV=development, which disables analytics and takes a different path), with HOME pointed at a throwaway dir so the 300/day analytics rate limit couldn't skew repeats, 15 runs, load average under 4.

Why

The dominant cost was the public_command_metadata hook. oclif fans it out on every command via analytics.ts, and @shopify/app's implementation statically imported localAppContext — dragging in the whole app graph, including @shopify/theme-check-common, whose ohm-js Liquid grammar is compiled from source at module-evaluation time. That was ~190 ms of the 199 ms that ran after the command had already printed its output.

Making the import dynamic is not sufficient: the hook always calls localAppContext(), so the import happens anyway. It now gates on getCurrentCommandId(), which cli-kit's BaseCommand already sets to the canonical oclif id — so no hook signature or fanout plumbing had to change.

Also included

  • analytics — check the analyticsDisabled/alwaysLog* gates before buildPayload(), so nothing is built when neither destination can receive it. Guarded with isVerbose() so --verbose still logs the would-be payload.
  • base-command — import session.js on demand, only when --auth-alias is passed (setCurrentSessionAlias(undefined) returns immediately). commandSessionId moves into a new dependency-free private/node/session/command-session-id.ts so clearing the selection stays correct without loading the session/identity/API graph. This is the ~10 ms pre-output gain.
  • otelunref() the throttle timer. It doesn't fire today (onForceFlush is called once per process), but a second call within the window would pin the process for up to 5 s.
  • notifications — skip the background refresh, which spawns a whole extra CLI process, when the cached payload is under an hour old. Previously it spawned on every non-CI command with no freshness check.
  • app-init — skip the fsync'd atomic write when the store is already empty.

Trade-off to review

Non-app commands no longer report app_* analytics fields. They couldn't produce meaningful ones without an app in the working directory, but a non-app command run inside an app directory now loses them too. Worth a look from whoever owns those dashboards.

Testing

  • 3790 unit tests pass across cli-kit, app, cli
  • type-check and lint pass across all 13 projects (CI-equivalent, --skip-nx-cache)
  • knip clean; refresh-code-documentation produces no churn
  • Bundle builds; smoke-tested version, --version, app/theme/store/auth/config --help, app build --help, --verbose, and --auth-alias <bad> (still errors correctly through the lazy import)

Two test files timed out on full-suite runs — different files on different runs, both pass in isolation, both slow timing-based suites (16 s and 26 s of test time against a 5 s per-test limit). Pre-existing flakes.

Unrelated bug found while measuring

packages/cli/project.json declares build with outputs: ["{workspaceRoot}/dist"] (should be {projectRoot}/dist) and bundle with no inputs/outputs at all. nx consequently serves stale bundles: with these changes fully stashed, dist still contained the compiled new code. Every number above comes from rm -rf dist && nx bundle cli --skip-nx-cache. Not fixed here — it deserves its own PR since touching nx caching could affect CI.

Dead end worth recording

Stubbing out 1.5 MB of graphql + OpenTelemetry + Bugsnag chunks saved −1.4 ms. Bytes loaded are nearly free — V8 lazy-parses and the CJS bodies never execute. Only module bodies that actually run cost anything, which is why one ohm-js grammar compile outweighed 2 MB of dependencies. Same for the 3.5 MB TypeScript compiler chunk (pulled in by @oclif/core's guarded require('typescript')): never executed, ~2 ms. The remaining ~213 ms of pre-output latency lives in base-command's import graph, not in bundle size.

🤖 Generated with Claude Code

`shopify version` goes from 369ms to 180ms (-51%), measured on the production
entry point with a clean bundle and an isolated config dir.

The dominant cost was the `public_command_metadata` hook. oclif fans it out on
every command via analytics, and `@shopify/app`'s implementation statically
imported `localAppContext`, dragging in the whole app graph -- including
theme-check-common, whose ohm-js Liquid grammar is compiled from source at
module evaluation time. That alone accounted for ~190ms of the 199ms that ran
after the command had already printed its output.

Making the import dynamic is not sufficient, because the hook always calls
`localAppContext()`. Instead it now gates on `getCurrentCommandId()`, which
cli-kit's BaseCommand already sets to the canonical oclif id, so no hook
signature or fanout plumbing had to change.

Also included:

- analytics: check the `analyticsDisabled`/`alwaysLog*` gates before building
  the payload, so nothing is built when neither destination can receive it.
  Guarded with `isVerbose()` so `--verbose` still logs the would-be payload.
- base-command: import `session.js` on demand, only when `--auth-alias` is
  passed. `commandSessionId` moves into a new dependency-free
  `private/node/session/command-session-id.ts` so clearing the selection stays
  correct without loading the session/identity/API graph. (~10ms pre-output.)
- otel: `unref()` the throttle timer. It does not fire today, but a second
  `onForceFlush` within the window would pin the process for up to 5s.
- notifications: skip the background refresh -- which spawns a whole extra CLI
  process -- when the cached payload is under an hour old.
- app-init: skip the fsync'd atomic write when the store is already empty.

Note: non-app commands no longer report `app_*` analytics fields. They could
not produce meaningful ones without an app in the working directory, but a
non-app command run inside an app directory now loses them too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@isaacroldan
isaacroldan requested review from a team as code owners July 29, 2026 12:03
@github-actions github-actions Bot added the Area: @shopify/cli @shopify/cli package issues label Jul 29, 2026
@isaacroldan
isaacroldan marked this pull request as draft July 29, 2026 12:04
@github-actions

Copy link
Copy Markdown
Contributor

Differences in type declarations

We detected differences in the type declarations generated by Typescript for this branch compared to the baseline ('main' branch). Please, review them to ensure they are backward-compatible. Here are some important things to keep in mind:

  • Some seemingly private modules might be re-exported through public modules.
  • If the branch is behind main you might see odd diffs, rebase main into this branch.

New type declarations

packages/cli-kit/dist/private/node/session/command-session-id.d.ts
/**
 * Get the session id selected for the current command, if any.
 *
 * @returns The selected session id, or undefined when no alias was selected.
 */
export declare function getCommandSessionId(): string | undefined;
/**
 * Select a stored session for the current command process.
 *
 * @param sessionId - The session id to select, or undefined to clear the selection.
 */
export declare function setCommandSessionId(sessionId: string | undefined): void;

Existing type declarations

packages/cli-kit/dist/private/node/session.d.ts
@@ -90,7 +90,6 @@ export declare function setLastSeenUserIdAfterAuth(id: string): void;
  */
 export declare function getLastSeenAuthMethod(): Promise<AuthMethod>;
 export declare function setLastSeenAuthMethod(method: AuthMethod): void;
-export declare function setCommandSessionId(sessionId: string | undefined): void;
 export interface EnsureAuthenticatedAdditionalOptions {
     noPrompt?: boolean;
     forceRefresh?: boolean;
packages/cli-kit/dist/public/node/base-command.d.ts
@@ -22,6 +22,13 @@ declare abstract class BaseCommand extends Command {
     }>;
     protected environmentsFilename(): string | undefined;
     protected failMissingNonTTYFlags(flags: FlagOutput, requiredFlags: string[]): void;
+    /**
+     * Resolving an alias needs the session/identity/API graph, so it is imported on demand. Almost no
+     * invocation passes `--auth-alias`, and clearing the selection only needs the tiny state module.
+     *
+     * @param alias - The account alias passed via `--auth-alias`, if any.
+     */
+    private selectSessionAlias;
     private resultWithEnvironment;
     /**
      * Tries to load an environment to forward to the command. If no environment
packages/cli-kit/dist/public/node/local-storage.d.ts
@@ -34,6 +34,14 @@ export declare class LocalStorage<T extends Record<string, any>> {
      * @throws BugError if an unexpected error occurs.
      */
     delete<TKey extends keyof T>(key: TKey): void;
+    /**
+     * The number of values held in the local storage.
+     *
+     * Useful to avoid an unnecessary write when there is nothing to clear.
+     *
+     * @returns The number of stored keys.
+     */
+    get size(): number;
     /**
      * Clear the local storage (delete all values).
      *

Comment on lines +25 to +26
// Imported lazily so that non-app commands never pay for the app graph.
const {localAppContext} = await import('../services/app-context.js')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If non-app commands don't call logAppContextMetadata at all is this part necessary?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area: @shopify/cli @shopify/cli package issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants