Skip to content

Module cache

Requirements
  • A generated project. The module cache reads the project graph Tuist keeps behind Project.swift to hash targets and substitute their binaries.

Tuist Module Cache provides a powerful way to optimize build times by caching your modules as binaries (.xcframeworks) and sharing them across different environments. This capability allows you to leverage previously generated binaries, reducing the need for repeated compilation and speeding up the development process.

Combine with the Xcode cache

The module cache and the Xcode cache are complementary because they work at different granularity levels. The module cache replaces whole modules with prebuilt .xcframeworks before the build runs, while the Xcode cache reuses compilation outputs during the build.

Compilation cache settings aren't part of module cache hashes, so turning the Xcode cache on or off keeps the binaries you already warmed. On Xcode 27 and later, the prefix mapping settings that come with it are hashed. See module cache hashes.

Warming#

Tuist efficiently utilizes hashes for each target in the dependency graph to detect changes. Utilizing this data, it builds and assigns unique identifiers to binaries derived from these targets. At the time of graph generation, Tuist then seamlessly substitutes the original targets with their corresponding binary versions.

This operation, known as "warming," produces binaries for local use or for sharing with teammates and CI environments via Tuist. The process of warming the cache is straightforward and can be initiated with a simple command:

bash
tuist cache

The command re-uses binaries to speed up the process.

If a binary fails to upload, the command still uploads the rest, then exits with a non-zero status and lists the targets that weren't uploaded. Binaries in a machine's local cache count as cached, so to upload them again from the same machine, run tuist clean binaries before warming. Pass --no-upload to only store binaries in the local cache.

Remote upload admission#

Modern module caching uses REAPI. When a busy cache temporarily rejects an upload with RESOURCE_EXHAUSTED, Tuist backs off with jitter, respects standard google.rpc.RetryInfo minimum delays, and retries within a bounded wait budget. Upload admission allows at most six retries per call. Each blob-upload operation shares one 60-second cumulative backoff budget across its presence checks, batch uploads, and streaming writes; concurrent waits each consume the budget, so repeated refusals do not grant every queued blob another minute of retries. Successful transfers do not consume this budget and are not cancelled when it runs out. Action-result publication has its own bounded budget. The exponential delay starts at one second and grows toward 30 seconds before proportional jitter. If a server's required delay exceeds the remaining budget, Tuist fails rather than retrying earlier than instructed. Cancellation interrupts backoff, and cache reads retain short retries so overload does not stall generation.

These defaults also help older Kura versions without retry hints. Longer retries absorb temporary contention, not sustained overload: reduce the cache concurrency limit on warm jobs or increase server capacity if rejections persist. See Kura upload sizing for pod-memory estimates and admission metrics.

Configuration selection#

When warming the cache without passing --configuration, Tuist selects the build configuration to use in the following order:

  1. The defaultConfiguration set in your manifest's Project.Options.generationOptions, if any.
  2. Otherwise, the first build configuration of variant debug, sorted alphabetically by name.

To warm the cache for a specific configuration, pass it explicitly:

bash
tuist cache --configuration Release

Usage#

By default, when Tuist commands necessitate project generation, they automatically substitute dependencies with their binary equivalents from the cache, if available. Additionally, if you specify a list of targets to focus on, Tuist will also replace any dependent targets with their cached binaries, provided they are available. For those who prefer a different approach, there is an option to opt out of this behavior entirely by using a specific flag:

bash
tuist generate # Only dependencies
tuist generate Search # Dependencies + Search dependencies
tuist generate Search Settings # Dependencies, and Search and Settings dependencies
tuist generate --cache-profile none # No cache at all
bash
tuist test
Warning

Binary caching is a feature designed for development workflows such as running the app on a simulator or device, or running tests. It is not intended for release builds. When archiving the app, generate a project with the sources by using --cache-profile none.

Cache profiles#

Tuist supports cache profiles to control how aggressively targets are replaced with cached binaries when generating projects.

  • Built-ins:
    • only-external: replace external dependencies only (system default)
    • all-possible: replace as many targets as possible (including internal targets)
    • none: never replace with cached binaries

Select a profile with --cache-profile on tuist generate:

bash
# Built-in profiles
tuist generate --cache-profile all-possible
# Custom profiles (defined in Tuist Config)
tuist generate --cache-profile development
# Use config default (no flag)
tuist generate
# Focus on specific targets (implies all-possible)
tuist generate MyModule AnotherTarget
# Disable binary replacement entirely
tuist generate --cache-profile none
Deprecated Flag

The --no-binary-cache flag is deprecated. Use --cache-profile none instead. The deprecated flag still works for backwards compatibility.

Precedence when resolving the effective behavior (highest to lowest):

  1. --cache-profile none
  2. Target focus (passing targets to generate) → profile all-possible
  3. --cache-profile <value>
  4. Config default (if set)
  5. System default (only-external)

Supported products#

Only the following target products are cacheable by Tuist:

  • Frameworks (static and dynamic), including test-support frameworks that depend on XCTest or Swift Testing
  • Libraries (static and dynamic), including test-support libraries that depend on XCTest or Swift Testing
  • Bundles
  • Swift Macros

Test bundles remain excluded from the module cache. This means unitTests and uiTests targets are not cached as binaries, but regular framework and library targets that tests depend on can be cached even when they link XCTest or Swift Testing.

Cached library .xcframeworks preserve the metadata needed by generated projects to import them, including Swift modules and public C/Objective-C headers when the source target declares public headers.

Upstream Dependencies

When a target is non-cacheable it makes the upstream targets non-cacheable too. For example, if you have the dependency graph A > B, where A depends on B, if B is non-cacheable, A will also be non-cacheable.

Analytics#

Open Module Cache → Modules in your project's dashboard to find modules with frequent misses. Select the environment you want to improve, such as CI, and a date range. The overview shows cache hits, misses, and modules with misses; opening a module shows its history and the reason assigned to each observation.

The Misses dropdown selects a count and explanation for one reason. The chart shows the distribution of all four reasons. On a module's page, use the history's Reason filter to inspect individual occurrences. Hover over a reason badge, or focus it with the keyboard, for its definition.

Miss reasons#

ReasonWhat it means
ChangedThe module's own compared inputs changed. These include file hashes, build settings, the resolved configuration, and the compiler identifier. A source edit is only one possible cause.
UpstreamThe module's own compared inputs stayed the same, but its dependency or external-package hash changed. The module's source files can be untouched.
ColdThere is no earlier module observation to compare with, or the reported inputs do not explain the miss and there is no qualifying evidence of earlier remote availability. Cold does not prove that the module was never cached.
EvictedThe exact cache key previously had a remote hit in the same project at the same recorded cache endpoint, but now misses.

For misses labeled Evicted, the artifact was previously downloaded from the remote cache but could not be reused this time. Eviction is the likely cause, though access or download failures can also explain the miss. Tuist only assigns this label when it finds an earlier remote hit; previous misses and local hits are not enough.

For example:

ScenarioClassification
A module misses with no earlier observation or qualifying remote hitCold
A changes; B depends on A, and C depends on B; all three keys changeA is Changed; B and C are Upstream
The compiler version changes after warming, with sources and settings unchangedAffected misses are Changed when both observations report the compiler inputs
An exact key was downloaded remotely, then misses after its artifact is evictedEvicted, when the earlier hit qualifies as evidence
A key repeatedly misses and was never successfully warmedIt can remain Cold

Reasons describe observations; they are not permanent labels attached to a key. For example, the first miss after a compiler upgrade can be Changed, while a later miss for that new key can be Cold if it was never observed as available remotely.

Improving the cache hit rate#

  1. Choose a consistent baseline. Start with CI and a representative date range. Sort modules by misses, then consider their hit rates, dependents, and build cost. Fixing a frequently missed, expensive dependency can save more time than improving the hit rate of a tiny module. Compare the same environment and similar workloads after making a change.
  2. Investigate Changed misses. Open the relevant runs and compare their full keys and reported inputs. Align warming and consuming jobs on the intended Xcode/compiler version, configuration, and target destinations. Warm again after intentional changes. If volatile generated files or environment-dependent settings invalidate otherwise stable modules, investigate those inputs. Keep inputs that affect binary compatibility in the hash.
  3. Follow Upstream misses to the changed dependency. Inspect its history to find the direct change. Warming the resulting keys can restore reuse. If a frequently changing implementation invalidates many expensive dependents, consider smaller modules or stable interfaces as described under Efficiency.
  4. Check warming coverage for Cold misses. Verify that warming selects the required modules, uses the consuming job's configuration and environment, and successfully uploads the artifacts. Check the full keys used by the actual CI jobs. If warming and consumption request different keys, inspect the hashing inputs before assuming the cache was evicted. Repeated misses with no earlier successful upload are possible even when the module's sources have not changed.
  5. Use the evidence for Evicted misses. Confirm the consuming run's key and endpoint, then inspect the consuming run's cache warnings and the warming job's upload outcome. Check retention or eviction when applicable. Rewarm the required artifacts and verify a subsequent hit. If they still miss, share the relevant run links and logs with support.

For a hash comparison, run this in each relevant environment, using the configuration you intend to warm and consume:

bash
tuist hash cache --configuration Debug --verbose

Compare the module's full hash and component block. Repeating the command on the same unchanged machine should normally produce the same result; compare the actual warming and consuming environments to investigate a mismatch. Neither command needs to hit the cache. See hashing diagnostics for further checks.

Efficiency#

The level of efficiency that can be achieved with binary caching depends strongly on the graph structure. To achieve the best results, we recommend the following:

  1. Avoid very nested dependency graphs. The shallower the graph, the better.
  2. Define dependencies with protocol/interface targets instead of implementation ones, and dependency-inject implementations from the top-most targets.
  3. Split frequently-modified targets into smaller ones whose likelihood of change is lower.

The above suggestions are part of the The Modular Architecture, which we propose as a way to structure your projects to maximize the benefits not only of binary caching but also of Xcode's capabilities.

Recommended setup#

We recommend having a CI job that runs in every commit in the main branch to warm the cache. This will ensure the cache always contains binaries for the changes in main so local and CI branch build incrementally upon them.

Keep Cache Warming Isolated

Run tuist cache in a dedicated CI step without subsequent steps that depend on the generated workspace. Since tuist cache modifies the workspace for cache building purposes, any CI steps that need the workspace should run tuist generate first to get a fresh, usable workspace.

Cache Warming Uses Binaries

The tuist cache command also makes use of the binary cache to speed up the warming.

The following are some examples of common workflows:

A developer starts to work on a new feature#

  1. They create a new branch from main.
  2. They run tuist generate.
  3. Tuist pulls the most recent binaries from main and generates the project with them.

A developer pushes changes upstream#

  1. The CI pipeline will run xcodebuild build or tuist test to build or test the project.
  2. The workflow will pull the most recent binaries from main and generate the project with them.
  3. It will then build or test the project incrementally.

Configuration#

Cache concurrency limit#

Set TUIST_CACHE_CONCURRENCY_LIMIT to a positive integer to limit both downloads and uploads. For example, reduce the load on a small self-hosted cache in warm jobs:

bash
TUIST_CACHE_CONCURRENCY_LIMIT=2 tuist cache

When unset, REAPI uploads use eight transfer tasks. Downloads use eight when any blob requires ByteStream and 32 for batch-only plans. The legacy HTTP cache path defaults to 100; that default does not apply to REAPI. Invalid values, including the previously documented none, retain the defaults rather than removing limits. Use unset TUIST_CACHE_CONCURRENCY_LIMIT to restore the defaults.

Limits apply per transfer operation, not globally across operations or CI jobs. Three simultaneous warms with a limit of two can have six uploads active against the same cache. Consumer jobs can leave the variable unset to retain default download parallelism.

Cache warm scratch directory#

By default, tuist cache creates a temporary scratch directory for build intermediates and assembled cache artifacts, then removes it when cache warming finishes. To keep those files under a directory you manage, set TUIST_CACHE_WARM_SCRATCH_DIRECTORY.

The path can be absolute or relative to the current working directory. Tuist creates the directory when it does not exist. If it already exists, it must be an empty directory.

When this environment variable is set, Tuist leaves the directory and its contents in place after the command finishes. The caller is responsible for cleaning it before the next cache warm. When the variable is unset, Tuist continues to use and remove a temporary directory.

Tuist rejects a caller-owned scratch directory when a foreign build target needs to be warmed. Foreign build scripts control their own output locations, so Tuist cannot guarantee that those outputs stay inside the scratch directory.

Troubleshooting#

It doesn't use binaries for my targets#

Ensure that the hashes are deterministic across environments and runs. This might happen if the project has references to the environment, for example through absolute paths. You can use the diff command to compare the projects generated by two consecutive invocations of tuist generate or across environments or runs.

Also make sure that the target doesn't depend either directly or indirectly on a non-cacheable target.

Missing symbols#

When using sources, Xcode's build system, through Derived Data, can resolve dependencies that are not declared explicitly. However, when you rely on the binary cache, dependencies must be declared explicitly; otherwise you'll likely see compilation errors when symbols can't be found. To debug this, we recommend using the tuist inspect dependencies --only implicit command and setting it up in CI to prevent regressions in implicit linking.