VMArm Technical Notes

Isolating Metro and Watchman Caches on a Cloud Mac

Isolating Metro and Watchman Caches on a Cloud Mac

When the same cloud Mac runs React Native builds repeatedly, the hardest problem to diagnose is often not a compilation error but a situation where “the code has changed, yet the artifact looks unchanged.” A local rerun works as expected, while a continuous integration job occasionally references an outdated module. Deleting the entire repository makes the problem disappear temporarily, only for it to return a few days later. The usual cause is a failure to separate four layers of state: the Git working tree, Watchman watches, Metro’s transform cache, and Xcode DerivedData.

First identify which cache layer is contaminated

Do not delete every cache as soon as something goes wrong. A full cleanup obscures the source of the failure and slows down subsequent builds. Start with a minimal change that can reveal the boundary, such as modifying a string included only in development builds. Then inspect the file contents, Metro module graph, and final application artifact separately.

Collect evidence for every job

At a minimum, record the commit hash, absolute workspace path, Node and Xcode versions, the command used to start Metro, and the DerivedData path for every job. Also retain the output of the following commands:

set -euo pipefail

printf 'commit=%s\n' "$(git rev-parse HEAD)"
printf 'workspace=%s\n' "$PWD"
node --version
xcodebuild -version
watchman watch-list || true
find "$PWD" -maxdepth 2 -name metro.config.js -print

If the commit and source files are correct but the JavaScript bundle is stale, investigate Metro first. If native code has not been updated in the archive, inspect DerivedData, the build configuration, and the workspace that was actually opened. If Watchman reports an error or is watching a parent directory, correct the watch boundary first.

Cleanup is a remediation step, not a diagnostic conclusion. Identify the layer that failed before deleting its state.

Assign a separate workspace to every job

Parallel jobs must not switch branches in the same checkout directory. Multiple jobs should also not be placed under a shared parent directory watched by Watchman. The recommended path includes both the pipeline ID and job ID, such as /Users/ci/work/4821/ios-release. After a job finishes, its entire directory can be archived or deleted independently.

The workspace must be a real directory rather than a symlink that is continually redirected. Some scripts use a fixed path to point to the “current build,” but Watchman and the toolchain may retain the previously resolved path. The command then appears to enter the new directory while the watch still targets the old checkout.

JOB_ROOT="/Users/ci/work/${PIPELINE_ID}/${JOB_ID}"
WORKSPACE="${JOB_ROOT}/repo"
DERIVED_DATA="${JOB_ROOT}/DerivedData"
METRO_CACHE="${JOB_ROOT}/metro-cache"

mkdir -p "$WORKSPACE" "$DERIVED_DATA" "$METRO_CACHE"
export TMPDIR="${JOB_ROOT}/tmp/"
mkdir -p "$TMPDIR"

Use only stable ASCII characters in directory names, and avoid spaces and dynamic symlinks. Giving each job its own root directory also makes disk usage and cleanup ownership easier to audit.

Narrow the Watchman watch scope

After entering the workspace, run watchman watch-project "$WORKSPACE" and verify that the returned watch value is the expected project root. If Watchman promotes the watch root to a shared parent directory, the repository boundary is usually unclear, or the parent directory contains configuration that affects boundary detection.

Place a .watchmanconfig file at the repository root and exclude large directories that do not participate in the JavaScript dependency graph:

{
  "ignore_dirs": [
    ".git",
    "DerivedData",
    "build",
    "artifacts"
  ]
}

Recreate the watch for the current project after modifying this file. Do not routinely run watchman watch-del-all on a machine handling concurrent jobs, because it also removes watches belonging to other jobs. The safe approach is to make each workspace an independent watch root and run only the following command when the job ends:

watchman watch-del "$WORKSPACE" || true

If the command reports that the target is not a watch root, inspect watchman watch-list first. Do not guess the path and then remove watches in bulk.

Manage Metro and Xcode caches explicitly

The Metro cache should be tied to a job or code baseline rather than relying on historical files of unknown origin in the system temporary directory. The startup script must pass the cache location explicitly. The exact option name may vary by Metro version, so consult the help output for the version locked by the project. Whether the location is set through a command-line option or project configuration, the goal is to keep the cache in ${METRO_CACHE} instead of a default directory shared by multiple jobs.

Use --reset-cache only as a one-time recovery measure after confirming that the transform cache is corrupted. If every job resets the cache, the isolation design is still incomplete. For Xcode, similarly pin DerivedData to the job directory:

xcodebuild \
  -workspace App.xcworkspace \
  -scheme App \
  -configuration Release \
  -derivedDataPath "$DERIVED_DATA" \
  build

Pods, Swift packages, and JavaScript dependencies can use a validated download cache, but the “download cache” must remain separate from “build state.” The former can be reused with keys derived from lockfiles; the latter should be owned exclusively by a single job. After restoring a cache, verify at least the lockfile hashes rather than accepting a hit based only on the branch name.

Use layered cleanup instead of a one-click reset

When an anomaly occurs, start with the layer that has the smallest impact. First stop the current Metro process, delete that job’s Metro cache, and restart it. If the problem remains, remove the Watchman watch for the current workspace and register it again. Delete the job’s DerivedData only when native compilation output is inconsistent. Consider checking out the repository again only as a last resort.

Before cleanup, verify that no process is still using the directory:

pgrep -af "$WORKSPACE" || true
lsof +D "$JOB_ROOT" 2>/dev/null | head -n 50 || true

The job exit script should also verify that the workspace path is under the expected root directory. This prevents an empty variable from causing accidental deletion of a user directory. Use case to enforce an allowlist of path prefixes before running rm -rf -- "$JOB_ROOT".

For validation, run the same commit twice in succession. The second run may reuse the download cache, but the workspace, Metro state, and DerivedData must still remain isolated at the job level. If the two artifacts differ, compare generated files, environment variables, and build logs before expanding the cleanup scope. This process not only resolves an individual cache problem but also preserves enough evidence to explain the next discrepancy.

Frequently asked questions

Should every CI build run Metro with reset-cache?

No. Give each job an explicit cache directory and reserve a reset for confirmed module graph or transformer cache corruption.

Can concurrent React Native jobs share one Watchman root?

They should not. Use non-nested workspaces, register each root separately, and remove only the watch owned by the completed job.

Dedicated Physical Mac Node

Choose an always-on cloud Mac for your next Xcode build

Check the chip, memory, storage, billing cycle, and node region before starting configuration. Availability is based on the live status returned by the console.

Choose a Cloud Mac Configuration