Skip to content

perf: Defer TLS initialization to a background thread#11967

Merged
anthonyshew merged 19 commits intomainfrom
perf/defer-tls-init
Feb 23, 2026
Merged

perf: Defer TLS initialization to a background thread#11967
anthonyshew merged 19 commits intomainfrom
perf/defer-tls-init

Conversation

@anthonyshew
Copy link
Copy Markdown
Contributor

Summary

TLS initialization (~95ms) previously blocked the critical path at the start of every turbo run. This PR moves it to a background thread so it overlaps with package graph construction, SCM indexing, and other startup work.

How it works

  1. cli::run spawns TLS init on spawn_blocking immediately — before arg parsing
  2. Telemetry uses a new DeferredTelemetryClient that resolves the HTTP client lazily on first flush, so telemetry init never blocks
  3. RunBuilder::build() resolves the HTTP client via a shared OnceCell after the package graph and SCM tasks have been running concurrently — by that point, TLS has had the full startup pipeline to complete in the background
  4. Analytics startup moved inside build() since it needs the resolved API client, so build() now returns (Run, Option<AnalyticsHandle>)

Profiling results

Measured with --profile on three repos of different sizes. The key metric is resolve_api_client — how long the main thread blocks waiting for TLS init after all the overlapping work has completed. Lower is better (0ms = fully hidden).

Repo size build_http_client (TLS) resolve_api_client (wait) Critical path savings
~1000 packages 91ms 0ms 91ms
~125 packages 94ms 26ms 68ms
~6 packages 92ms 89ms 3ms

Savings scale with repo size: larger repos have more build() work (package graph construction, lockfile parsing, SCM indexing) running concurrently with TLS init. Small repos see minimal improvement since there's almost nothing to overlap with, but they don't regress — the worst case is parity with the baseline.

Testing

  • cargo check --workspace clean (zero errors, zero warnings)
  • cargo test -p turborepo-api-client --lib — 18 passed
  • Verified --profile captures resolve_api_client, http_client_init, and build_http_client spans correctly
  • Verified only one build_http_client call occurs (OnceCell deduplication works)

…very

Three targeted optimizations to the turbo run hot path:

1. Engine builder: Cache turbo.json chain per package and move the
   visited check before the expensive task_definition() call. The
   chain only depends on the package name, so multiple tasks in the
   same package reuse the cached result.

2. Task visitor: Defer env() computation to non-dry-run branches.
   The execution environment is unused during dry runs, avoiding
   per-task RwLock acquisition and env var map cloning.

3. find_untracked_files: Replace Mutex<Vec> with per-thread local
   buffers flushed via mpsc channel on drop, eliminating per-file
   mutex contention in the parallel walker.
Parallelize several sequential phases of turbo run's pre-execution
pipeline: dependency resolution, turbo.json loading, and task summary
construction. Also reduce per-call allocation overhead in the task
hash tracker and gix index classification.
…parallelize-hot-path

# Conflicts:
#	crates/turborepo-scm/src/repo_index.rs
Adds profiling visibility to functions that were invisible in
--profile output: TLS initialization, rayon-spawned hash tasks,
Visitor constructor, Engine scheduler, and per-task cache phases.
Store task hashes as Arc<str> in TaskHashTrackerState instead of String.
In calculate_dependency_hashes, cloning an Arc<str> is a ref count bump
instead of a heap allocation. For the api monorepo (1687 tasks, ~3 deps
each), this eliminates ~5000 String heap allocations per run.
…Summary

Removes the .to_string() conversion in the HashTrackerInfo::hash() trait
impl by changing the trait return type from Option<String> to
Option<Arc<str>>. Also changes SharedTaskSummary.hash to Arc<str> so the
Arc flows all the way to serialization without any String allocation.
The Arc<str> fields in TaskHashTrackerState and SharedTaskSummary
require serde's "rc" feature for Serialize. Previously this worked
via transitive feature activation; make it explicit so it doesn't
break if the transitive path changes.
The --profile output previously had no visibility into the
pre-execution phases of turbo run. This made it impossible to
diagnose where startup overhead was coming from.

Add info-level tracing spans to every significant phase:

- shim_run: top-level shim execution
- cli_run: CLI dispatch (arg parsing, http client, telemetry)
- http_client_init: TLS initialization for reqwest
- telemetry_init: telemetry client setup
- command_base_new: config loading and opts construction
- run_builder_new: API client and auth setup
- pkg_dep_graph_build: package graph construction
- scm_task_await: waiting for background SCM/git index
- async_cache_new: cache initialization
- calculate_filtered_packages: package filtering
- env_infer: environment variable snapshot
- turbo_json_preload: turbo.json cache warming
- build_engine: task graph construction
- hash_scope: parallel file hashing
- start_ui: UI initialization
- repo_inference: repository root detection

These spans appear in both the chrome trace JSON and the
generated markdown summary when using --profile.
Holding an EnteredSpan guard across .await corrupts tracing's
thread-local state when the future resumes on a different tokio
worker thread. Use .instrument() which correctly re-enters the
span on whichever thread the future polls on.
TLS initialization (loading root certificates, setting up the TLS
backend) takes ~95ms and previously blocked the critical path at
the start of every turbo run. No work could proceed until it
completed.

Move TLS init to a spawn_blocking task that starts immediately
when cli::run begins. The HTTP client is resolved via a shared
OnceCell — the background task and any consumer race to initialize
it, and OnceCell guarantees only one wins.

Telemetry uses a new DeferredTelemetryClient that resolves the
HTTP client lazily on first flush rather than at construction,
so telemetry initialization never blocks on TLS.

The API client for remote cache and analytics is resolved in
RunBuilder::build() after the package graph and SCM tasks have
been running concurrently. By that point, TLS init has had the
full duration of arg parsing, config loading, package graph
construction, and SCM indexing to complete in the background.

RunBuilder::build() now returns (Run, Option<AnalyticsHandle>)
since analytics startup moved inside build() (it needs the
resolved API client).
@anthonyshew anthonyshew requested a review from a team as a code owner February 23, 2026 17:24
@anthonyshew anthonyshew requested review from tknickman and removed request for a team February 23, 2026 17:24
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Feb 23, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
examples-basic-web Ready Ready Preview, Comment, Open in v0 Feb 23, 2026 6:23pm
examples-designsystem-docs Ready Ready Preview, Comment, Open in v0 Feb 23, 2026 6:23pm
examples-gatsby-web Ready Ready Preview, Comment, Open in v0 Feb 23, 2026 6:23pm
examples-kitchensink-blog Ready Ready Preview, Comment, Open in v0 Feb 23, 2026 6:23pm
examples-nonmonorepo Ready Ready Preview, Comment, Open in v0 Feb 23, 2026 6:23pm
examples-svelte-web Ready Ready Preview, Comment, Open in v0 Feb 23, 2026 6:23pm
examples-tailwind-web Ready Ready Preview, Comment, Open in v0 Feb 23, 2026 6:23pm
examples-vite-web Ready Ready Preview, Comment, Open in v0 Feb 23, 2026 6:23pm
turbo-site Ready Ready Preview, Comment, Open in v0 Feb 23, 2026 6:23pm
turborepo-agents Ready Ready Preview, Comment, Open in v0 Feb 23, 2026 6:23pm
turborepo-test-coverage Ready Ready Preview, Comment, Open in v0 Feb 23, 2026 6:23pm

TLS initialization (loading root certificates, setting up the TLS
backend) takes ~95ms and previously blocked the critical path at
the start of every turbo run. No work could proceed until it
completed.

Move TLS init to a spawn_blocking task that starts immediately
when cli::run begins. The HTTP client is resolved via a shared
OnceCell — the background task and any consumer race to initialize
it, and OnceCell guarantees only one wins.

Telemetry uses a new DeferredTelemetryClient that resolves the
HTTP client lazily on first flush rather than at construction,
so telemetry initialization never blocks on TLS.

The API client for remote cache and analytics is resolved in
RunBuilder::build() after the package graph and SCM tasks have
been running concurrently. By that point, TLS init has had the
full duration of arg parsing, config loading, package graph
construction, and SCM indexing to complete in the background.

RunBuilder::build() now returns (Run, Option<AnalyticsHandle>)
since analytics startup moved inside build() (it needs the
resolved API client).
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 23, 2026

Coverage Report

Metric Coverage
Lines 75.15%
Functions 46.83%
Branches 0.00%

View full report

…' into perf/defer-tls-init

# Conflicts:
#	crates/turborepo-api-client/src/telemetry.rs
… init

When the tokio runtime shuts down before the background TLS init
task completes (common for short-lived commands like `unlink`), the
spawn_blocking JoinHandle returns JoinError::Cancelled. The previous
code called .expect() on this, crashing the process.

Replace all three spawn_blocking + expect sites with proper error
propagation via a new HttpClientCancelled error variant. Telemetry
failures silently return errors (telemetry is never worth crashing
over), and the run path surfaces the error normally.
@ghost ghost added area: examples Improvements or additions to examples pkg: turbo-repository labels Feb 23, 2026
@anthonyshew anthonyshew merged commit f1d487f into main Feb 23, 2026
104 of 106 checks passed
@anthonyshew anthonyshew deleted the perf/defer-tls-init branch February 23, 2026 18:36
github-actions Bot added a commit that referenced this pull request Feb 23, 2026
## Release v2.8.11-canary.24

Versioned docs: https://v2-8-11-canary-24.turborepo.dev

### Changes

- perf: Add more tracing spans into startup path (#11965) (`23e144d`)
- release(turborepo): 2.8.11-canary.23 (#11966) (`b425c39`)
- perf: Defer TLS initialization to a background thread (#11967)
(`f1d487f`)

---------

Co-authored-by: Turbobot <turbobot@vercel.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant