<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Pedro Piñera Buendía</title>
    <description>The latest articles on DEV Community by Pedro Piñera Buendía (@pepicrft).</description>
    <link>https://dev.to/pepicrft</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4169097%2F90b2ae8e-6161-419b-9e4a-6f0e436d34ca.png</url>
      <title>DEV Community: Pedro Piñera Buendía</title>
      <link>https://dev.to/pepicrft</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pepicrft"/>
    <language>en</language>
    <item>
      <title>The physics of build systems</title>
      <dc:creator>Pedro Piñera Buendía</dc:creator>
      <pubDate>Fri, 09 Oct 2026 10:58:05 +0000</pubDate>
      <link>https://dev.to/tuist/the-physics-of-build-systems-i2e</link>
      <guid>https://dev.to/tuist/the-physics-of-build-systems-i2e</guid>
      <description>&lt;p&gt;At first, a build looks simple. Change a file, run a command, and a compiler turns the source into something you can ship or test. On a small project, that is close enough to the whole story. The machine has work to do, it does it, and a few moments later you have an answer.&lt;/p&gt;

&lt;p&gt;Then the project grows. A change that should be local wakes up half the repository. A machine with more processor cores does not make the build noticeably faster. A cache makes some builds fly and seems to do nothing for others. At that point, it is tempting to think there must be one slow compiler, one badly configured cache, or one sufficiently large machine that will make the problem go away. Usually there is not.&lt;/p&gt;

&lt;p&gt;In our &lt;a href="https://tuist.dev/blog/2026/06/30/three-build-systems-one-graph" rel="noopener noreferrer"&gt;last post&lt;/a&gt;, we introduced &lt;a href="https://github.com/tuist/once" rel="noopener noreferrer"&gt;Once&lt;/a&gt;, our prototype for making remote caching and execution accessible without asking people to replace the build systems they already use. Building it has forced us to get more precise about what actually makes a build fast.&lt;/p&gt;

&lt;p&gt;If object storage is cheap, why not put a cache there and call it done? Will adding more processor cores make a build faster? If a continuous integration provider already offers a cache, what is missing? And if a project is already using a remote cache, why can the build still feel slow?&lt;/p&gt;

&lt;p&gt;Start with a graph of tasks. On its own, it is only a description. The moment it meets a machine, the description turns into work, and the machine has only so much capacity to give it. Those constraints are the physics of build systems.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parameters of a build
&lt;/h2&gt;

&lt;p&gt;Start with computation. We will come back to moving data later. Take a small target with two source files. Before it can produce an executable, it has to compile both files, then hand their object files to the linker. The build engine sees three tasks: two compilations with no relationship to each other, and a link task that needs the output of both. That relationship is the &lt;strong&gt;graph&lt;/strong&gt;. It is not just a convenient way to describe a build after the fact. It decides what the machine is allowed to do at any given moment.&lt;/p&gt;

&lt;p&gt;The simulator below uses generic task names. What matters is not whether a task is a compilation, a link, or something else. It is how much work the task has, what must finish before it can begin, and what the machine has available to run it.&lt;/p&gt;

&lt;p&gt;Try changing the machine and the number of tasks below. Every task in this model takes the same amount of time. It starts with three tasks: two can run immediately, while the third waits for both. As you add tasks, the graph grows wider and deeper. More work becomes available at once, but more layers also have to wait for the work before them. Extra processor cores can help with the first part. They cannot remove the second.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🧪 &lt;strong&gt;Interactive simulator:&lt;/strong&gt; this section has a live model you can play with in the &lt;a href="https://tuist.dev/blog/2026/08/25/the-physics-of-build-systems" rel="noopener noreferrer"&gt;original post on tuist.dev&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Say each task contains the same amount of &lt;strong&gt;work&lt;/strong&gt;, enough to occupy one processor for ten seconds. On a machine with one processor core, the two initial tasks have to take turns, and the final task runs last. The build takes thirty seconds. Increase &lt;strong&gt;processor speed&lt;/strong&gt; and each task finishes sooner. Add a second processor core and the two initial tasks can run together, so the final task begins after ten seconds and the whole build takes twenty. This is the result people expect when they buy a bigger machine, and it is real, provided the build has enough independent work to keep the machine busy.&lt;/p&gt;

&lt;p&gt;Now give that same build thirty-two &lt;strong&gt;processor cores&lt;/strong&gt;. It still takes twenty seconds. At the start, only two tasks are ready. When they finish, only the final task is ready. The rest of the machine is idle because no scheduler can start a task before the input it needs exists. Large builds have thousands of tasks instead of three, but they are subject to the same limit.&lt;/p&gt;

&lt;p&gt;There are two useful ways to see that limit. One is the total amount of work divided across the processor cores. If enough tasks are ready, more cores lower that number. The other is the longest chain of tasks that must happen in order. More cores cannot shorten that chain. A build cannot finish before either limit allows it to.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The graph is part of the performance budget.&lt;/strong&gt; It decides which tasks can use the machine at the same time and which must wait. Buying a faster machine or adding processor cores is tempting because it is immediate. But if the graph does not expose enough independent work, that extra capacity sits idle. Make the graph easier to run first. Then bigger machines have something to accelerate.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In the simulator, a unit of work is one second of processor time on a reference machine. This is only a model. In a real build, recorded processor time is a useful estimate, but wall-clock time also includes waiting for dependencies, memory pressure, scheduling, and eventually moving data. Caches and networks change the picture too. We will get to them, but the graph comes first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the work comes from
&lt;/h2&gt;

&lt;p&gt;A clean build, where every task has to run, is a useful starting point. But most developers do not work that way. They change a file, run the build again, change another file, and run it again. An incremental build looks at that change, finds the part of the graph it can affect, and leaves everything else alone. It still has to answer two questions: what needs to rebuild, and when can the required work run? They are different decisions, a distinction explored in &lt;a href="https://simon.peytonjones.org/assets/pdfs/build-systems-original.pdf" rel="noopener noreferrer"&gt;Build Systems à la Carte&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For one changed file, the affected part of the graph contains the task that reads it and every downstream task whose result could now be different. It is usually much smaller than the graph for the whole project. That is what incremental compilation buys us. It removes tasks from the build, and it can shorten the &lt;strong&gt;longest dependency path&lt;/strong&gt; that remains. It cannot turn a dependency into independent work, but it can make that dependency irrelevant for the change in front of you.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🧪 &lt;strong&gt;Interactive simulator:&lt;/strong&gt; this section has a live model you can play with in the &lt;a href="https://tuist.dev/blog/2026/08/25/the-physics-of-build-systems" rel="noopener noreferrer"&gt;original post on tuist.dev&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Over time, the number that matters is not the cost of one arbitrary edit. It is the &lt;strong&gt;cost of the edits a team actually makes&lt;/strong&gt;, weighted by &lt;strong&gt;how often they happen&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;An incremental build is a distribution, not a single number.&lt;/strong&gt; A slow path that nobody changes is less important than a moderately slow path that developers touch all day.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There are two dimensions to a build: the graph, and the way that graph changes over time. The graph tells us the cost of one change, what has to rebuild, what can run in parallel, and what must wait. History tells us which changes the team actually makes. A shared module with many downstream dependents might be exactly what the project needs. It becomes an everyday cost only when it changes often, or when rebuilding it lands on a long path through the build. Put those two dimensions together and the questions become useful: which modules change most, how far do those changes travel, and which ones add time to a build already waiting on other work?&lt;/p&gt;

&lt;p&gt;Looking at a graph or a build in isolation misses that intersection. A graph can show what might be expensive. An individual build can show what happened once. Neither says whether developers regularly pay that cost. &lt;a href="https://tuist.dev/en/docs/guides/features/build-insights" rel="noopener noreferrer"&gt;Build Insights&lt;/a&gt; keeps the history of local and continuous integration builds, so the graph can be read alongside the way a project is actually used. At Tuist, we do not think caching and execution are enough on their own. Connecting a project should also give a team the data it needs to make better decisions about its setup.&lt;/p&gt;

&lt;p&gt;Architecture turns this into a design problem. Shared foundations need to be small and stable, while fast-moving code needs boundaries that keep its changes from waking up the rest of the project. Then the cache and the extra machines have something worthwhile to accelerate. We will come back to the trade-offs behind those choices in a future post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incrementality across worktrees
&lt;/h2&gt;

&lt;p&gt;Open a feature, a review, and a small fix at the same time. Most files are unchanged between them. So are the dependencies, compiler, and a surprising amount of the work. Worktrees let those copies coexist without stepping on one another, but a build system usually meets each copy as if it were a new project.&lt;/p&gt;

&lt;p&gt;There are two ways to carry work from one build to the next. &lt;strong&gt;Incrementality by location&lt;/strong&gt; keeps mutable state in a build directory and benefits only when the next build returns to that directory. A second worktree has a second directory, so it starts without the first worktree's incremental state. &lt;strong&gt;Incrementality by identity&lt;/strong&gt; names the result of a task by the inputs, toolchain, settings, and dependencies that produced it. Two worktrees can keep their own graphs and bookkeeping, but still ask for the same result.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🧪 &lt;strong&gt;Interactive simulator:&lt;/strong&gt; this section has a live model you can play with in the &lt;a href="https://tuist.dev/blog/2026/08/25/the-physics-of-build-systems" rel="noopener noreferrer"&gt;original post on tuist.dev&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Xcode is a familiar example of the first approach. Its DerivedData directory holds the build products and bookkeeping that make a later build incremental. Another checkout usually has another DerivedData directory, even when the source trees are nearly identical. &lt;a href="https://www.swift.org/documentation/package-manager/" rel="noopener noreferrer"&gt;Swift Package Manager&lt;/a&gt;, &lt;a href="https://doc.rust-lang.org/cargo/reference/build-cache.html" rel="noopener noreferrer"&gt;Cargo&lt;/a&gt;, and &lt;a href="https://hexdocs.pm/mix/Mix.html" rel="noopener noreferrer"&gt;Mix&lt;/a&gt; also use local build directories. The normal consequence is simple: worktree B rebuilds work already completed in worktree A.&lt;/p&gt;

&lt;p&gt;Sharing one directory changes the failure mode, rather than creating reuse. Two builds that point at the same directory have to coordinate updates to mutable state. The safe response is a lock: one build waits while the other changes the bookkeeping.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A shared build directory is a queue, not a cache.&lt;/strong&gt; It protects mutable state, but the second build still waits for the first.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Xcode's compilation cache uses the second approach for cacheable compiler work: it looks up a result by its content-addressed identity rather than through a worktree's DerivedData directory. Bazel, when it uses a disk or remote cache, does the same. Its &lt;a href="https://bazel.build/remote/caching" rel="noopener noreferrer"&gt;action cache and content-addressable storage&lt;/a&gt; keep the lookup from an action to its result separate from the output files themselves. That is why another checkout can reuse the work without borrowing the first checkout's build directory.&lt;/p&gt;

&lt;p&gt;The boundary is not native tools on one side and Bazel on the other. Go and Gradle have forms of action caching too, and Cargo can use &lt;code&gt;sccache&lt;/code&gt; to share built dependencies. Go's &lt;a href="https://pkg.go.dev/cmd/go#hdr-Build_and_test_caching" rel="noopener noreferrer"&gt;build cache&lt;/a&gt;, for example, lives outside the project and is safe for concurrent builds. But unless a build uses &lt;code&gt;-trimpath&lt;/code&gt;, the path of a local package can end up in debug information. Two worktrees with the same source are therefore not always asking Go for the same result. What matters is where a tool puts the state it needs to reuse work.&lt;/p&gt;

&lt;p&gt;For tools that keep that state in a directory, the obvious answer in continuous integration is to keep the directory around. The next job mounts a volume with the last build's outputs and database. From the tool's perspective, it looks like the previous build never ended. We will call this a sticky volume. It makes a directory-scoped build feel incremental. It does not make the work inside that directory broadly reusable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making a build directory stick
&lt;/h2&gt;

&lt;p&gt;The idea is appealing because it can be effective quickly. A branch can start from a fork of the volume that its base commit produced, then keep its own volume from there. Its next build lands on a runner that can mount it. Dependencies are already fetched, generated files are still there, and an incremental compiler sees the state it expects. The difficulty appears when there is more than one plausible volume. Should a pull request inherit its branch's last build, the main branch, or the closest common commit? If every branch gets its own volume, each one starts cold and storage grows with the number of active branches. If they all share one, the build system is back to locking a mutable directory.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🧪 &lt;strong&gt;Interactive simulator:&lt;/strong&gt; this section has a live model you can play with in the &lt;a href="https://tuist.dev/blog/2026/08/25/the-physics-of-build-systems" rel="noopener noreferrer"&gt;original post on tuist.dev&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The volume also becomes a &lt;strong&gt;scheduling constraint&lt;/strong&gt;. A volume that can be written by one node restricts where a job can run. A shared read-write volume has different storage and concurrency requirements. &lt;a href="https://kubernetes.io/docs/concepts/storage/persistent-volumes/" rel="noopener noreferrer"&gt;Kubernetes makes those choices explicit through volume access modes&lt;/a&gt;. The runner still has to choose the right volume, place the job on a machine that can mount it, and prevent two writers from corrupting the same state.&lt;/p&gt;

&lt;p&gt;That is &lt;strong&gt;operational complexity&lt;/strong&gt;, not cache configuration. Someone has to create and retire volumes, choose which history a job inherits, keep data close to the machine that needs it, and coordinate concurrent jobs. &lt;a href="https://tuist.dev/en/docs/guides/get-started/tuist-runners" rel="noopener noreferrer"&gt;Tuist Runners&lt;/a&gt; handles that runner infrastructure and connects jobs to a cache shared with developer machines. The job can reuse valid results without a team having to select and mount yesterday's mutable build directory.&lt;/p&gt;

&lt;p&gt;There is also a &lt;strong&gt;correctness boundary&lt;/strong&gt;. A persistent build directory is not automatically flaky, but it preserves more than source files. It can hold paths, software development kit state, compiler outputs, and assumptions about the machine that last wrote to it. A sound build system notices the difference and rebuilds. A missed input can turn into the kind of failure that only appears on one runner, after one particular sequence of jobs. Absolute paths and undeclared environment variables are common reasons shared caches either miss or reuse the wrong thing. &lt;a href="https://docs.gradle.org/current/userguide/build_cache.html" rel="noopener noreferrer"&gt;Gradle's cache documentation&lt;/a&gt; treats both as part of making a task relocatable.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A sticky volume does not make a build system share work. It makes one runner remember it.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Persistent volumes are still useful. They are a good home for downloads, source mirrors, and other state deliberately designed to survive a new checkout. But they leave the platform choosing a history for every job and carrying the cost of that choice. A task-result cache has a narrower question: are these inputs the same? If they are, it can reuse the result wherever the next build happens to run. That is the second model in practice: reuse follows the identity of the work, not the directory that happened to run it last.&lt;/p&gt;

&lt;h2&gt;
  
  
  The distance to a cache
&lt;/h2&gt;

&lt;p&gt;A task-result cache makes reuse portable, but it changes the question. The build no longer needs the previous directory. It needs a result that may be somewhere else. That brings us back to the question from the beginning: if object storage is cheap, why not put the cache there? Object storage can hold the artifacts, and often should. But the price of keeping those bytes says almost nothing about the time it takes to serve a build.&lt;/p&gt;

&lt;p&gt;Cost changes with the traffic shape too. Many managed object stores charge not only for bytes at rest, but also for requests, retrievals, transfer, and sometimes replication. A cache full of small artifacts can turn a modest amount of stored data into a great many operations. That may be immaterial for one team and material for another, but it is another reason to evaluate the cache path, not only the storage price.&lt;/p&gt;

&lt;p&gt;Every remote cache hit has two time components. &lt;strong&gt;Latency&lt;/strong&gt; is the time spent waiting for a request to reach the cache and a response to begin. &lt;strong&gt;Bandwidth&lt;/strong&gt; is how quickly the artifact can move once that response arrives. A useful lower bound for a cache hit is:&lt;/p&gt;

&lt;p&gt;

&lt;/p&gt;
&lt;div class="katex-element"&gt;
  &lt;span class="katex-display"&gt;&lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord mathnormal"&gt;T&lt;/span&gt;&lt;span class="msupsub"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mtight"&gt;&lt;span class="mord mtight"&gt;&lt;span class="mord mathrm mtight"&gt;hit&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mrel"&gt;≥&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;R&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mbin"&gt;×&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;L&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mbin"&gt;+&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mopen nulldelimiter"&gt;&lt;/span&gt;&lt;span class="mfrac"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord mathnormal"&gt;B&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="frac-line"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord mathnormal"&gt;S&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="mclose nulldelimiter"&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/div&gt;


&lt;p&gt;Here, 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;R&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 is the number of request-response steps that cannot overlap, 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;L&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 is latency, 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;S&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 is the number of bytes to transfer, and 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;B&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 is the effective bandwidth. It is a lower bound, not a prediction. Real cache hits also pay for cache lookup, decompression, and work that cannot be parallelized.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🧪 &lt;strong&gt;Interactive simulator:&lt;/strong&gt; this section has a live model you can play with in the &lt;a href="https://tuist.dev/blog/2026/08/25/the-physics-of-build-systems" rel="noopener noreferrer"&gt;original post on tuist.dev&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;R&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mbin"&gt;×&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;L&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 dominates, the cache is &lt;strong&gt;latency-bound&lt;/strong&gt;. A result split across thousands of small blobs can spend much of its time waiting for requests, unless the cache client batches metadata, reuses connections, prefetches, and downloads in parallel. When 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mopen nulldelimiter"&gt;&lt;/span&gt;&lt;span class="mfrac"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;B&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="frac-line"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;S&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="mclose nulldelimiter"&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 dominates, it is &lt;strong&gt;bandwidth-bound&lt;/strong&gt;: artifact size and the available network throughput decide how long the transfer takes. Some lookups still sit on the dependency path, so their latency lands directly on wall-clock time. Concurrent requests can help throughput, and keeping compute in the same region reduces latency, but neither makes a distant object store behave like a local file system.&lt;/p&gt;

&lt;p&gt;That is what &lt;strong&gt;infrastructure collocation&lt;/strong&gt; means here. Cache and execution should sit on a short, high-bandwidth network path. In &lt;a href="https://docs.tuist.dev/guides/features/runners" rel="noopener noreferrer"&gt;Tuist Runners&lt;/a&gt;, compute and cache sit on the same private network. Keeping latency low and bandwidth high is part of the environment we operate, not a setting we ask a team to tune after the fact. But runners are not the whole picture. Developer machines can be spread across regions, and remote coding environments such as &lt;a href="https://openai.com/codex/" rel="noopener noreferrer"&gt;Codex&lt;/a&gt; and &lt;a href="https://www.anthropic.com/claude-code" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; may reach a cache over the public internet. They should share the same valid results as the runners, but they cannot all be close to one central cache at once.&lt;/p&gt;

&lt;p&gt;This is why we are building &lt;a href="https://github.com/tuist/tuist/tree/main/kura" rel="noopener noreferrer"&gt;Kura&lt;/a&gt; as a decentralized cache mesh. A Kura node serves hot reads from its own disk and replicates artifacts and metadata to its peers. Kura is designed to sit beside our runners and to be deployed across regions, so each environment can have a nearby cache path while still sharing results with the rest of the team. In terms of the earlier bound, it is designed to reduce 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;L&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 without giving up the shared history that makes a remote cache useful in the first place.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🧪 &lt;strong&gt;Interactive simulator:&lt;/strong&gt; this section has a live model you can play with in the &lt;a href="https://tuist.dev/blog/2026/08/25/the-physics-of-build-systems" rel="noopener noreferrer"&gt;original post on tuist.dev&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A remote cache hit is only a win when retrieving the result is faster than producing it again.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The limits of one machine
&lt;/h2&gt;

&lt;p&gt;Collocation makes a cache hit fast. It does not help when there is no result to fetch. New work, invalidated work, and a task nobody has run before still need a machine. The limit returns to where we started: the finite number of processor cores in the environment that started the build. That machine can run the tasks that are ready in the graph, and no more.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Remote execution&lt;/strong&gt; is the next step. Instead of keeping every cache miss on the machine that started the build, the build system can send a ready task to a pool of workers. Those workers need to be warm: they need the right toolchain, system image, and enough capacity to begin without first reconstructing the environment. They run the task and return the result to the shared cache. More workers help only when the graph has enough ready work to fill them, just as more processor cores help only when one machine has enough independent work. A long dependency chain still runs one step at a time.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A remote cache avoids work that has already been done. Remote execution gives the work that remains more places to run.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is not a common capability in native toolchains. &lt;a href="https://bazel.build/remote/rbe" rel="noopener noreferrer"&gt;Bazel's remote execution support&lt;/a&gt; is explicitly designed to distribute build and test actions across multiple machines. &lt;a href="https://buck2.build/docs/users/remote_execution/" rel="noopener noreferrer"&gt;Buck2&lt;/a&gt; can use the same open remote-execution protocol to run actions remotely or locally. In both cases, the build describes enough about an action to move it safely: the inputs, outputs, toolchain, settings, and environment it needs. That is what lets a worker execute a task without borrowing the state of the machine that requested it.&lt;/p&gt;

&lt;p&gt;Most native toolchains can parallelize compiler work on the machine they were started on, but do not offer this as the normal way to run a build. Remote caching can still cut the work dramatically, but cache misses remain limited by the processor cores in one environment. Remote execution is the extra mile: a way to turn a wide part of the graph into work that a fleet can absorb, provided those workers are close to the cache and ready when the work arrives.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for teams
&lt;/h2&gt;

&lt;p&gt;None of this makes optimizing the graph optional. Caching removes repeated work, and remote execution adds capacity for the work that remains. Neither can make a deep chain run in parallel or stop a small change from waking up much of the project. The graph still sets the shape of every build; the history of changes tells us whether developers pay the cost often enough to care. That is why insights over time matter. We need the graph alongside build traces and real changes, not one isolated run, to see what should be fixed before buying faster hardware or more infrastructure.&lt;/p&gt;

&lt;p&gt;Tools that keep incremental state in a directory can be given sticky volumes, and that can be useful. But it trades an ordinary cache question for a scheduling problem: someone must maintain the volumes, decide which history a job inherits, keep it near compatible machines, and prevent concurrent writers. Tools with &lt;strong&gt;incrementality by identity&lt;/strong&gt; can share results through a cache instead. Then latency, bandwidth, and the cost of moving artifacts are the variables to manage. The more granular the artifacts, the more that placement matters: one build can turn into thousands of small lookups and transfers rather than a few large ones.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Infrastructure is a multiplier, not a replacement for a graph that makes useful work available.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Xcode's compilation cache already brings identity-based reuse to native Xcode projects and can deliver cache gains close to the systems built around remote caching. What it does not provide today is distributed execution. That remains the broader gap for native toolchains: most were designed around a developer building on one machine and still have work to do before they can use the amount of parallel build work a modern organization produces. People often ask us whether Xcode's compilation cache will grow into that kind of system. We do not know. It is a toolchain decision, and we do not know when, or whether, Apple will prioritize it.&lt;/p&gt;

&lt;p&gt;That uncertainty matters more now that coding agents can produce changes faster than many projects can validate them. If build feedback is already the bottleneck, &lt;a href="https://bazel.build" rel="noopener noreferrer"&gt;Bazel&lt;/a&gt; and &lt;a href="https://buck2.build" rel="noopener noreferrer"&gt;Buck2&lt;/a&gt; are sensible options because remote caching and remote execution are part of the model today. The trade-off is adoption: both ask a project to describe its build in their rules. If that is more change than a team can take on, we are prototyping &lt;a href="https://github.com/tuist/once" rel="noopener noreferrer"&gt;Once&lt;/a&gt; as a different entry point. Once works with an existing project and the scripts it already has, so a team can describe its automation gradually and connect it to caching and execution without first replacing the build system underneath. It is early, and we are looking for teams that feel this pressure and want to try it. Email us at &lt;a href="mailto:contact@tuist.dev"&gt;contact@tuist.dev&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One condition sits underneath all of this. A remote cache or remote worker is only safe if a task's identity is real. &lt;strong&gt;Hermeticity&lt;/strong&gt; means accounting for every input a task needs. &lt;strong&gt;Deterministic hashing&lt;/strong&gt; means those declared inputs, together with the toolchain and settings, always produce the same cache key. We will look at both in a future post, along with the other constraints that make distributed builds fast without making them wrong.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://tuist.dev/blog/2026/08/25/the-physics-of-build-systems" rel="noopener noreferrer"&gt;Tuist blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>buildsystems</category>
      <category>performance</category>
      <category>devops</category>
      <category>ios</category>
    </item>
    <item>
      <title>Xcode and Gradle: Two worlds, one infrastructure</title>
      <dc:creator>Pedro Piñera Buendía</dc:creator>
      <pubDate>Wed, 07 Oct 2026 14:13:40 +0000</pubDate>
      <link>https://dev.to/tuist/xcode-and-gradle-two-worlds-one-infrastructure-3l09</link>
      <guid>https://dev.to/tuist/xcode-and-gradle-two-worlds-one-infrastructure-3l09</guid>
      <description>&lt;p&gt;At Tuist, we are building a virtual platform team. Not every organization can afford a dedicated team to optimize builds, manage flaky tests, track app size, or streamline developer workflows. Tuist fills that role by going deep into build systems, understanding how they structure and persist data, and plugging into them to optimize workflows like compilations and test runs. Whether developers work alone or alongside coding agents, we want the tooling layer to just work.&lt;/p&gt;

&lt;p&gt;We have been doing this for Xcode for years. Understanding activity logs, result bundles, compilation graphs, the entire landscape of how Apple's toolchain turns source files into apps. Being close to the mobile ecosystem, jumping to Android through Gradle was a natural next step. And we were genuinely struck by how the work turned out. We shipped remote build caching, build insights, test insights, flaky test detection, bundle analysis, and previews for Gradle in about three weeks. Part of that speed came from features like bundle analysis and test insights being largely build-system-agnostic: once the data is extracted, it boils down to the same structures regardless of whether it came from Xcode or Gradle.&lt;/p&gt;

&lt;p&gt;This post is a walkthrough of both build systems from our perspective. If you are an Xcode developer curious about how things work on the Gradle side, or a Gradle developer who has never touched Apple's toolchain, this is for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plugin model, or lack thereof
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://gradle.org/" rel="noopener noreferrer"&gt;Gradle&lt;/a&gt; is Android's build system, and more broadly, the dominant build system in the JVM ecosystem. If you have never used it: think of it as a programmable build system where builds are defined in Kotlin or Groovy scripts, and the system is designed from the ground up to be extended. It exposes a rich plugin API where you can hook into nearly every phase of the build lifecycle. You implement an interface, register your plugin, and Gradle calls you at the right time. Want to intercept cache operations? Implement &lt;a href="https://docs.gradle.org/current/javadoc/org/gradle/caching/BuildCacheService.html" rel="noopener noreferrer"&gt;&lt;code&gt;BuildCacheService&lt;/code&gt;&lt;/a&gt;. Want to observe every task as it finishes? Register an &lt;a href="https://docs.gradle.org/current/javadoc/org/gradle/tooling/events/OperationCompletionListener.html" rel="noopener noreferrer"&gt;&lt;code&gt;OperationCompletionListener&lt;/code&gt;&lt;/a&gt;. Want test results as they happen? Add a &lt;a href="https://docs.gradle.org/current/javadoc/org/gradle/api/tasks/testing/TestListener.html" rel="noopener noreferrer"&gt;&lt;code&gt;TestListener&lt;/code&gt;&lt;/a&gt;. The contracts are well-defined, documented, and stable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://developer.apple.com/xcode/" rel="noopener noreferrer"&gt;Xcode&lt;/a&gt; is Apple's IDE for iOS, macOS, and the rest of Apple's platforms. The underlying build system is &lt;a href="https://github.com/swiftlang/swift-build" rel="noopener noreferrer"&gt;Swift Build&lt;/a&gt;, and &lt;a href="https://developer.apple.com/library/archive/technotes/tn2339/_index.html" rel="noopener noreferrer"&gt;&lt;code&gt;xcodebuild&lt;/code&gt;&lt;/a&gt; is the command-line executable bundled with Xcode to interact with projects from the terminal, for example to build them. If you come from the Gradle world: think of it as a more closed system where Apple controls both the IDE and the build pipeline, and there is no plugin model to speak of. Apple does not expose a way to say "call me when a compilation finishes" or "let me handle cache storage." Swift Build is open source now, but it is strongly coupled to Xcode and its release cycles. The same applies to &lt;a href="https://github.com/swiftlang/swift-package-manager" rel="noopener noreferrer"&gt;SwiftPM&lt;/a&gt;, Apple's package manager: you can contribute improvements or fixes, but they will not reach developers until Apple ships the next Xcode version. In practice, you feed the build system a project in its intermediate format (PIF, or Project Interchange Format) and it gives you output in the terminal and artifacts on disk. &lt;strong&gt;There are no extension points in between.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This single architectural difference shaped everything about how we built our integrations. With Gradle, we wrote a &lt;a href="https://plugins.gradle.org/plugin/dev.tuist" rel="noopener noreferrer"&gt;plugin&lt;/a&gt; in Kotlin that hooks directly into the build lifecycle. With Xcode, we had to get creative. We built a CLI that wraps &lt;code&gt;xcodebuild&lt;/code&gt; (Apple's command-line build tool), parses its artifacts after the fact, hooks into scheme post-actions to trigger data collection, and in some cases acts as a proxy between the build system and external services. Every feature required finding the right phase in the process where the data we needed was accessible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build caching
&lt;/h2&gt;

&lt;p&gt;Build caching is about not repeating work that has already been done. If a module or a compilation unit has not changed since the last build, there is no reason to compile it again. And if someone else on your team or CI has already compiled it, there is no reason for you to compile it either. The idea is simple, but the integration looks completely different depending on the build system.&lt;/p&gt;

&lt;p&gt;For most of Xcode's history, there was no concept of remote build caching. So we built one ourselves.&lt;/p&gt;

&lt;p&gt;In the early days of Tuist, we invested heavily in project generation. For context: Xcode projects are stored as &lt;code&gt;.xcodeproj&lt;/code&gt; files, a format that is notoriously difficult to manage at scale and causes constant merge conflicts in teams. Tuist lets you define your project in Swift and generates the Xcode project from that definition. Later, drawing ideas from package managers like &lt;a href="https://github.com/Carthage/Carthage" rel="noopener noreferrer"&gt;Carthage&lt;/a&gt; (a dependency manager for Apple platforms that builds frameworks from source), we built &lt;a href="https://tuist.dev/en/docs/guides/features/cache/xcode-cache" rel="noopener noreferrer"&gt;module-level binary caching&lt;/a&gt; on top of it. You can see how this looks in practice on &lt;a href="https://tuist.dev/tuist/tuist/module-cache" rel="noopener noreferrer"&gt;our own project's cache dashboard&lt;/a&gt;. Because we understand the project graph, we can determine which modules have not changed, replace them with pre-compiled binaries, and generate a project that skips compiling them entirely. This happens before the build even starts. It requires deep knowledge of the dependency graph and control over how the project is generated, which is why it is tightly coupled to Tuist's project generation layer.&lt;/p&gt;

&lt;p&gt;Then Apple introduced compilation caching in &lt;a href="https://developer.apple.com/documentation/xcode-release-notes/xcode-26-release-notes" rel="noopener noreferrer"&gt;Xcode 26&lt;/a&gt; using &lt;a href="https://llvm.org/" rel="noopener noreferrer"&gt;LLVM&lt;/a&gt;'s Content Addressable Storage (CAS). For Gradle developers unfamiliar with this: CAS is a storage model where artifacts are stored and retrieved by the hash of their content, similar in spirit to how Git stores objects. The granularity is impressive, sub-function level, meaning Xcode can skip recompiling individual compilation units if their inputs have not changed. But the remote cache interface is a gRPC protocol with no public SDK. The only way to provide a remote cache is to run a service that speaks this protocol and tell Xcode where to find it via a Unix socket.&lt;/p&gt;

&lt;p&gt;So that is what we did. &lt;code&gt;tuist setup cache&lt;/code&gt; creates a &lt;a href="https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html" rel="noopener noreferrer"&gt;LaunchAgent&lt;/a&gt; daemon (a macOS background process managed by the system) that listens on a local socket. You pass the socket path to &lt;code&gt;xcodebuild&lt;/code&gt; via build settings. During compilation, Xcode makes thousands of gRPC calls to this socket, asking "do you have this artifact?" and "store this artifact." Our daemon proxies those calls to our remote infrastructure.&lt;/p&gt;

&lt;p&gt;Gradle's story could not be more different. It has a built-in concept of remote build caching that has been stable for years. You enable it in &lt;code&gt;gradle.properties&lt;/code&gt;, point it at a remote service, and Gradle handles the rest. The protocol is straightforward HTTP. Before executing a task (Gradle's unit of work, like compiling a set of source files or processing resources), Gradle checks the remote cache with a GET request using the task's cache key. If there is a hit, it downloads the artifact and skips execution. After executing a task, it uploads the outputs with a PUT.&lt;/p&gt;

&lt;p&gt;You might wonder why we built a plugin instead of just pointing Gradle at an existing cache server. The reason is that we need control over the client-side logic. Our authentication model is different from what a plain HTTP cache server expects, and we run client-side logic to resolve the lowest latency endpoint from our global cache network. That forced us to implement Gradle's &lt;code&gt;BuildCacheService&lt;/code&gt; interface ourselves, which turned out to be straightforward. We did not have to intercept anything, parse anything, or run a background daemon. We implemented the contract, handled auth and endpoint resolution on our side, and it worked. The plugin is &lt;a href="https://github.com/tuist/tuist/tree/main/gradle" rel="noopener noreferrer"&gt;open source&lt;/a&gt; if you want to see how it all fits together. The cache key computation, the decision of what is cacheable, the artifact packaging, all of that is Gradle's responsibility. We just provide the storage. You can see how this looks on &lt;a href="https://tuist.dev/tuist/android/gradle-cache" rel="noopener noreferrer"&gt;our Android project's cache dashboard&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;tuist&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;buildCache&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;push&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"CI"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The caching granularity is worth calling out. Gradle caches at the task level: an entire compilation task, a resource processing task, a code generation task. Xcode's compilation cache operates at the compilation unit level, and our module caching operates at the dependency graph level. This means Xcode's approach is more fine-grained but also more latency-sensitive. When your build system makes thousands of cache lookups per build, every millisecond of network latency matters. With Gradle, a large project might have hundreds of cache lookups per build. The tolerance for latency is still much higher.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;We believe caching is a capability that will become increasingly common across build systems. Gradle has had it for years, Xcode added it recently, and others will follow. But caching only works if the infrastructure behind it is fast enough to beat the ceiling of multi-core local compute. If pulling from the cache is slower than just rebuilding, nobody will use it. That is why we are investing in low-latency, globally distributed cache infrastructure that both small teams and large organizations can plug into without having to build or maintain it themselves.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Build insights
&lt;/h2&gt;

&lt;p&gt;The architecture of your project has deep implications in how effectively the build system uses the physical resources available. A poorly structured dependency graph can leave most of your CPU cores idle while one bottleneck module compiles sequentially. A test target that depends on half the project rebuilds far more than it needs to. &lt;strong&gt;But you can only improve what you can measure&lt;/strong&gt;, and most teams have no visibility into how their project interacts with their build system. Build insights exist to change that.&lt;/p&gt;

&lt;p&gt;Xcode gives you no structured way to observe a build as it happens. Instead, after a build finishes, Xcode writes an &lt;code&gt;.xcactivitylog&lt;/code&gt; file to your derived data directory. For Gradle developers: derived data is where Xcode stores all build outputs, indexes, and logs, similar to Gradle's &lt;code&gt;.gradle&lt;/code&gt; and &lt;code&gt;build&lt;/code&gt; directories. The activity log is a binary format that contains timing information, errors, warnings, and file-level compilation data. To extract &lt;a href="https://tuist.dev/en/docs/guides/features/build-insights/xcode" rel="noopener noreferrer"&gt;build insights&lt;/a&gt;, we parse this file after the build using &lt;code&gt;tuist inspect build&lt;/code&gt;, which runs as a scheme post-action (a script Xcode executes after a build or test scheme completes). You can see how this looks on &lt;a href="https://tuist.dev/tuist/tuist/builds" rel="noopener noreferrer"&gt;our own project's build dashboard&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The parsing itself is a pain. The activity log format is not publicly documented. Projects like &lt;a href="https://github.com/MobileNativeFoundation/XCLogParser" rel="noopener noreferrer"&gt;XCLogParser&lt;/a&gt; from the Mobile Native Foundation, which we are now active maintainers of, have done valuable work in this area, but the format changes with new Xcode versions, and every time Apple adds new features to the build system, there is reverse-engineering work needed to parse the new data. Coding agents have made that work easier, but it remains fragile. We extract targets, compiled files with their durations, errors and warnings with file locations, and for Xcode 26 builds, cache hit/miss data for individual compilation units.&lt;/p&gt;

&lt;p&gt;There is also a race condition that caught us off guard. Scheme post-actions run after the build finishes, but you cannot assume the &lt;code&gt;.xcactivitylog&lt;/code&gt; is ready at that point. Xcode generates those artifacts asynchronously, so we had to account for the file not being present when our post-action fires. It is one of those things that works fine in most cases and then fails silently in others.&lt;/p&gt;

&lt;p&gt;Gradle makes this dramatically simpler. When we wanted to give teams visibility into their &lt;a href="https://tuist.dev/en/docs/guides/features/build-insights/gradle" rel="noopener noreferrer"&gt;Gradle builds&lt;/a&gt; (here is &lt;a href="https://tuist.dev/tuist/android/builds" rel="noopener noreferrer"&gt;our Android project's build dashboard&lt;/a&gt;), the plugin API made it almost trivial. We register a listener that gets called as each task finishes. We collect the task path, its outcome (cache hit, executed, failed, skipped), the cache key, artifact size, and duration. At the end of the build, we bundle everything into a JSON payload and send it to our server.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Gradle calls us. We just collect.&lt;/span&gt;
&lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;finished&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buildOperation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;BuildOperationDescriptor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;finishEvent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OperationFinishEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Record task outcome, duration, cache behavior&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The data is clean because Gradle gives it to us in a structured form. We know exactly what happened to each task and why. &lt;strong&gt;No binary parsing, no race conditions, no reverse engineering.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Test insights, flaky tests, and quarantine
&lt;/h2&gt;

&lt;p&gt;Tests are one of the biggest sources of friction in any development workflow, regardless of platform. A slow test suite delays every pull request. A flaky test, one that passes sometimes and fails other times with the same code, erodes trust in the entire pipeline. And when a flaky test blocks a merge, someone has to investigate whether the failure is real or noise. Multiply that by dozens of developers and hundreds of test runs per day, and the cost adds up fast. &lt;a href="https://tuist.dev/en/docs/guides/features/test-insights/xcode" rel="noopener noreferrer"&gt;Test insights&lt;/a&gt;, &lt;a href="https://tuist.dev/en/docs/guides/features/test-insights/flaky-tests/xcode" rel="noopener noreferrer"&gt;flaky test detection&lt;/a&gt;, and &lt;a href="https://tuist.dev/en/docs/guides/features/test-insights/flaky-tests/xcode#quarantining" rel="noopener noreferrer"&gt;quarantine&lt;/a&gt; exist to give teams the data they need to manage this, and to automate the parts that should not require human attention.&lt;/p&gt;

&lt;p&gt;With Xcode, test results live in &lt;code&gt;.xcresult&lt;/code&gt; bundles. For Gradle developers: these are structured archive files that Xcode produces after a test run, containing test case hierarchies, timing data, failure messages, and attachments like screenshots and crash reports. After the test run, we parse the result bundle using &lt;code&gt;tuist inspect test&lt;/code&gt;, extract the structured data, and upload it. You can see how this looks on &lt;a href="https://tuist.dev/tuist/tuist/tests" rel="noopener noreferrer"&gt;our project's test dashboard&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The result bundle format is richer than what Gradle gives us. It can contain screenshots from UI tests, crash logs, performance metrics. But accessing that richness requires navigating a nested archive format that Apple controls and occasionally changes between Xcode versions.&lt;/p&gt;

&lt;p&gt;Once we have the data, we detect flaky tests by comparing results across runs. If a test passes in one CI run but fails in another on the same commit, we flag it. When a test is identified as flaky, you can quarantine it so it does not block CI while you work on a fix. With Xcode, quarantine works through &lt;code&gt;xcodebuild&lt;/code&gt;'s &lt;code&gt;-skip-testing&lt;/code&gt; flag. When you run &lt;code&gt;tuist test&lt;/code&gt;, we fetch the list of quarantined tests from our server and pass each one as a &lt;code&gt;-skip-testing&lt;/code&gt; argument. If you use &lt;code&gt;xcodebuild&lt;/code&gt; directly, you can get the formatted arguments with &lt;code&gt;tuist test case list --skip-testing&lt;/code&gt;. It works, but it means quarantine is tied to the command-line invocation rather than being embedded in the build system itself.&lt;/p&gt;

&lt;p&gt;In Gradle, we register a &lt;a href="https://docs.gradle.org/current/javadoc/org/gradle/api/tasks/testing/TestListener.html" rel="noopener noreferrer"&gt;&lt;code&gt;TestListener&lt;/code&gt;&lt;/a&gt; on every test task. As each test case finishes, Gradle calls us with the test descriptor (module, suite, name) and the result (pass, fail, skip, duration). We collect everything and upload it when the test task completes. You can see this on &lt;a href="https://tuist.dev/tuist/android/tests" rel="noopener noreferrer"&gt;our Android project's test dashboard&lt;/a&gt;. If teams use the &lt;a href="https://github.com/gradle/test-retry-gradle-plugin" rel="noopener noreferrer"&gt;test-retry plugin&lt;/a&gt; to rerun failed tests, we see each attempt and can detect flakiness by comparing results across retries. For quarantine, our plugin fetches the list of quarantined tests from our server before each test task runs and adds them to Gradle's &lt;code&gt;excludeTestsMatching&lt;/code&gt; filter. The test never runs. No file manipulation, no source control concerns, just a programmatic exclusion at runtime.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;tuist&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;testQuarantine&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The data is simpler than what Xcode gives us, but it is immediately available, stable, and designed for tool integration. The flaky detection and quarantine logic on the server side is identical for both ecosystems. The difference, again, is entirely in how we collect the data and how we act on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bundle analysis and previews
&lt;/h2&gt;

&lt;p&gt;Not every feature requires deep integration with the build system. &lt;a href="https://tuist.dev/en/docs/guides/features/bundle-size" rel="noopener noreferrer"&gt;Bundle analysis&lt;/a&gt; and &lt;a href="https://tuist.dev/en/docs/guides/features/previews" rel="noopener noreferrer"&gt;previews&lt;/a&gt; are both CLI features that operate on build artifacts after they are produced. The difference across platforms is just the object types they work with.&lt;/p&gt;

&lt;p&gt;Bundle analysis tracks app size over time. App size tends to grow silently, a new dependency here, an uncompressed asset there, and before you know it your app is 50% larger than it was six months ago. With Xcode, we analyze &lt;code&gt;.ipa&lt;/code&gt; (the archive format for iOS apps), &lt;code&gt;.xcarchive&lt;/code&gt;, and &lt;code&gt;.app&lt;/code&gt; bundles. You can see this on &lt;a href="https://tuist.dev/tuist/tuist/bundles" rel="noopener noreferrer"&gt;our project's bundle dashboard&lt;/a&gt;. With Gradle, we analyze &lt;code&gt;.aab&lt;/code&gt; (Android App Bundle, the format Google Play uses to generate device-specific APKs) and &lt;code&gt;.apk&lt;/code&gt; files. Here is &lt;a href="https://tuist.dev/tuist/android/bundles" rel="noopener noreferrer"&gt;our Android project's bundle dashboard&lt;/a&gt;. In both cases, &lt;code&gt;tuist inspect bundle&lt;/code&gt; breaks down the contents, tracks changes over time, and comments on pull requests with size deltas so the team catches regressions before they ship.&lt;/p&gt;

&lt;p&gt;Previews solve a different problem: getting a build into someone's hands without a full release pipeline. On Apple's side, the alternative is &lt;a href="https://developer.apple.com/testflight/" rel="noopener noreferrer"&gt;TestFlight&lt;/a&gt;, Apple's beta distribution service that requires provisioning profiles, App Store Connect processing, and often a multi-minute wait. On Android's side, it is generating a signed APK and sending it over Slack. With Tuist, you run &lt;code&gt;tuist share&lt;/code&gt; after building, we upload the artifact and give you a link. Anyone with access can run &lt;code&gt;tuist run {url}&lt;/code&gt; to launch it on a simulator or device. We support tracks (like &lt;code&gt;beta&lt;/code&gt; or &lt;code&gt;nightly&lt;/code&gt;), QR codes, and automatic PR comments with preview links.&lt;/p&gt;

&lt;p&gt;Both features required minimal platform-specific work. The CLI handles different file formats, but the server infrastructure for tracking, diffing, sharing, and access control is completely shared between Xcode and Gradle.&lt;/p&gt;

&lt;p&gt;One thing that expanding to Gradle made clear is that our CLI was too coupled to macOS. Gradle projects build on Linux and Windows, not just Mac. So we invested in loosening that dependency, starting with Linux support. We are not fully there yet, Windows is still on the roadmap, but the groundwork is laid. It was one of those changes that the Gradle work forced us to confront earlier than we would have otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  From CLI dependency to standalone plugin
&lt;/h2&gt;

&lt;p&gt;With Xcode, all Tuist functionality flows through the CLI. &lt;code&gt;tuist auth login&lt;/code&gt; handles the OAuth flow, stores credentials, and every command from &lt;code&gt;tuist inspect&lt;/code&gt; to &lt;code&gt;tuist setup&lt;/code&gt; uses those stored credentials. The CLI is the central orchestration point because there is no plugin system to embed this logic into.&lt;/p&gt;

&lt;p&gt;When we first built the Gradle plugin, we took the same approach. The plugin shelled out to the CLI for responsibilities like refreshing an OAuth2 session or fetching the cache endpoint with the lowest latency. But it quickly became clear that requiring developers to install the Tuist CLI just to use a Gradle plugin added unnecessary friction. It felt wrong for the Gradle ecosystem, where plugins are expected to be self-contained.&lt;/p&gt;

&lt;p&gt;So we decided to break that dependency. The scope of the logic was well-defined: token management, OAuth2 refresh, endpoint resolution. Coding agents turned out to be great at porting this kind of solution across languages, and since it is a piece of code we do not change often, we re-implemented all of it in Kotlin to run as part of the plugin itself.&lt;/p&gt;

&lt;p&gt;Thanks to that, the plugin is nearly standalone. You add it to &lt;code&gt;settings.gradle.kts&lt;/code&gt;, configure it, and we distribute it through the &lt;a href="https://plugins.gradle.org/plugin/dev.tuist" rel="noopener noreferrer"&gt;Gradle Plugin Portal&lt;/a&gt;, which is exactly where Gradle developers expect to find their tooling. You still need the Tuist CLI for the initial &lt;code&gt;tuist auth login&lt;/code&gt; to authenticate via OAuth, but we are considering shipping an auth task directly in the plugin to make it fully standalone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Looking back
&lt;/h2&gt;

&lt;p&gt;Three weeks from first line of Kotlin to a working plugin with remote caching, build insights, test insights, and flaky test detection. Our Xcode integration took months to reach the same feature set. Part of that is because we were building the infrastructural pieces, the server, the analytics pipeline, the dashboard, that we would later reuse with Gradle. But a large part of the time went into understanding Xcode internals and proprietary formats. Gradle was designed to be extended. Every feature we wanted to build had a corresponding interface or hook point in Gradle's API. We spent our time on our logic, not on figuring out how to extract data from a system that does not want to share it.&lt;/p&gt;

&lt;p&gt;It is empowering to see coding agents lowering the cost of expanding our value to new ecosystems. The work that used to take weeks of reverse engineering now takes days with the right agent and the right context. That changes the calculus of which ecosystems are worth investing in.&lt;/p&gt;

&lt;p&gt;It was also a great exercise in learning how another build system thinks. Understanding Gradle's design decisions, its plugin model, its approach to caching, puts us in a position to cross-pollinate 🐝 ideas between ecosystems. Some patterns from Gradle inform how we approach Xcode tooling, and some of the depth we have built in the Apple ecosystem gives us perspective that Gradle-only teams might lack. &lt;strong&gt;Being in both worlds makes us better at each one.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Gradle builds and Xcode builds land in the same analytics pipeline. The dashboard shows the same kinds of insights. The flaky test detection uses the same algorithms. That said, we deliberately chose not to unify all data structures or force a common denominator. The data that build toolchains work with is heterogeneous, and trying to flatten Gradle tasks and Xcode compilation units into the same model would lose the nuance that makes the insights useful. But the patterns, the pipelines, the algorithms, those are shared. The toolchain layer is where the ecosystem-specific work happens, and once you get past it, the problems become universal.&lt;/p&gt;

&lt;p&gt;If you are a Gradle developer, the extensibility of your build system is a genuine advantage that you might take for granted. The ecosystem of plugins, the clean lifecycle hooks, the first-class support for remote caching, these are design decisions that make the entire tooling ecosystem richer. If you are an Xcode developer, the lack of extensibility is a real limitation that shapes the tools available to you. Every tool that integrates with Xcode has to work around the same constraints we do. Parsing binary logs, reverse-engineering formats, running daemons as proxies. This is not a criticism of Apple, they have their reasons, but it is a reality that makes the ecosystem harder to improve from the outside.&lt;/p&gt;

&lt;p&gt;For us, the Gradle experience validated our architecture. Our infrastructure is build-system agnostic. The ecosystem-specific work is a plugin, an adapter, that translates between the build system's world and ours. Gradle's adapter was thin and fast to build. Xcode's was thick and took months. But both connect to the same infrastructure, and that is the point. We will keep going deeper into both ecosystems, and if you are interested in trying it out, our &lt;a href="https://plugins.gradle.org/plugin/dev.tuist" rel="noopener noreferrer"&gt;Gradle plugin&lt;/a&gt; and our &lt;a href="https://tuist.dev/en/docs/guides/install-tuist" rel="noopener noreferrer"&gt;Xcode integration&lt;/a&gt; are both available today.&lt;/p&gt;

</description>
      <category>xcode</category>
      <category>gradle</category>
      <category>android</category>
      <category>ios</category>
    </item>
  </channel>
</rss>
