<?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: Kévin Huang</title>
    <description>The latest articles on DEV Community by Kévin Huang (@kevinhuang78).</description>
    <link>https://dev.to/kevinhuang78</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3890781%2Fe8ed5b69-0bd1-4faf-980e-8c3dcb8ac3c6.jpeg</url>
      <title>DEV Community: Kévin Huang</title>
      <link>https://dev.to/kevinhuang78</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kevinhuang78"/>
    <language>en</language>
    <item>
      <title>Cutting our CI time in half thanks to custom AWS runners</title>
      <dc:creator>Kévin Huang</dc:creator>
      <pubDate>Tue, 21 Apr 2026 14:19:27 +0000</pubDate>
      <link>https://dev.to/wecasa/cutting-our-ci-time-in-half-thanks-to-custom-aws-runners-390j</link>
      <guid>https://dev.to/wecasa/cutting-our-ci-time-in-half-thanks-to-custom-aws-runners-390j</guid>
      <description>&lt;p&gt;We moved our Nx monorepo from CircleCI to GitHub Actions on self-hosted AWS runners, tightened caching, and brought the full CI run from ~33 minutes down to ~15 minutes while owning cache and runner behavior end to end.&lt;/p&gt;




&lt;p&gt;At Wecasa, our CI used to run on CircleCI. We wanted to consolidate on GitHub Actions and run jobs on self-hosted runners in AWS, with specs we control and costs we can optimize.&lt;/p&gt;

&lt;p&gt;Our repo is a large Nx monorepo: each package is an app or library (SPAs, React Native / Expo mobile apps, etc.). Every push should run lint and tests through Nx, but we also needed fast feedback, reliable caching, and behavior that matches how developers work (branch pushes, &lt;code&gt;master&lt;/code&gt; always green).&lt;/p&gt;

&lt;p&gt;This post is a practical walkthrough: how we sized runners, provisioned them with Terraform, built a composite setup action for Yarn + Nx cache, parallelized test and lint, and tuned Jest to avoid OOMs on larger runners.&lt;/p&gt;

&lt;h2&gt;
  
  
  1) Runner specs: CircleCI vs our AWS runners
&lt;/h2&gt;

&lt;p&gt;Before changing workflows, we analysed our current CI setup.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://circleci.com/docs/reference/configuration-reference/#linuxvm-execution-environment" rel="noopener noreferrer"&gt;CircleCI (before)&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lint (medium runner): 2 vCPUs, 7.5 GB RAM&lt;/li&gt;
&lt;li&gt;Tests (large runner): 4 vCPUs, 15 GB RAM&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AWS self-hosted runners (after)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lint (medium runner): 4 vCPUs, 7 GB RAM&lt;/li&gt;
&lt;li&gt;Tests (large runner): 8 vCPUs, 15 GB RAM&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The test job got twice the CPU on the large runner, which matters a lot for parallel Nx tasks and Jest workers. Lint stayed on a smaller machine with more cores than our old Circle medium tier, which helped wall-clock time for ESLint/TypeScript-style work.&lt;/p&gt;

&lt;h2&gt;
  
  
  2) Infrastructure: Terraform-provisioned GitHub Actions runners
&lt;/h2&gt;

&lt;p&gt;We created the runners with Terraform and registered them as labels GitHub Actions can target (&lt;code&gt;medium-runner&lt;/code&gt;, &lt;code&gt;large-runner&lt;/code&gt;). That gives us repeatable infra, clear ownership, and an obvious place to evolve instance types or scaling later.&lt;/p&gt;

&lt;h2&gt;
  
  
  3) One composite action: Yarn, install, and Nx cache
&lt;/h2&gt;

&lt;p&gt;Repeated setup (Corepack, Yarn cache, &lt;code&gt;yarn install&lt;/code&gt;, env files for mobile, Nx restore) belonged in &lt;a href="https://docs.github.com/en/actions/tutorials/create-actions/create-a-composite-action" rel="noopener noreferrer"&gt;one composite action&lt;/a&gt;  so every job stays consistent and DRY.&lt;/p&gt;

&lt;p&gt;Rough responsibilities:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Validate the cache target (&lt;code&gt;test&lt;/code&gt; vs &lt;code&gt;lint&lt;/code&gt;) so we never restore the wrong Nx cache key by mistake.&lt;/li&gt;
&lt;li&gt;Enable Corepack and resolve Yarn's cache directory.&lt;/li&gt;
&lt;li&gt;Restore Yarn's global cache (&lt;code&gt;actions/cache@v4&lt;/code&gt;) keyed on &lt;code&gt;yarn.lock&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;yarn install --immutable&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Restore Nx cache only (&lt;code&gt;actions/cache/restore@v4&lt;/code&gt;), we save Nx cache in each job’s workflow so we don’t double-save the same key from the composite action.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Splitting restore (composite) vs save (workflow) avoids competing writes and duplicate cache saves for the same key.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Setup&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;run&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;CI"&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Setup&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Yarn,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;restore&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Yarn&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cache&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Nx&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cache"&lt;/span&gt;

&lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nx-cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Which&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Nx&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cache&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;restore&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(test&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;or&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lint)"&lt;/span&gt;
    &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;runs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;using&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;composite&lt;/span&gt;
  &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Validate nx-cache input&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;if [ "${{ inputs.nx-cache }}" != 'test' ] &amp;amp;&amp;amp; [ "${{ inputs.nx-cache }}" != 'lint' ]; then&lt;/span&gt;
          &lt;span class="s"&gt;echo "::error::nx-cache must be 'test' or 'lint', got: ${{ inputs.nx-cache }}"&lt;/span&gt;
          &lt;span class="s"&gt;exit 1&lt;/span&gt;
        &lt;span class="s"&gt;fi&lt;/span&gt;
      &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup Yarn (1/4) -&amp;gt; Enable corepack&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;corepack enable&lt;/span&gt;
      &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup Yarn (2/4) -&amp;gt; Get yarn cache directory path&lt;/span&gt;
      &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;yarn-cache-dir-path&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "dir=$(yarn config get cacheFolder)" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
      &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup Yarn (3/4) -&amp;gt; Retrieve global cache&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/cache@v4&lt;/span&gt;
      &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;yarn-cache&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.yarn-cache-dir-path.outputs.dir }}&lt;/span&gt;
        &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}&lt;/span&gt;
        &lt;span class="na"&gt;restore-keys&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;${{ runner.os }}-yarn-&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup Yarn (4/4) -&amp;gt; Install packages and copy env files&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;yarn install --immutable&lt;/span&gt;
      &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash&lt;/span&gt;

    &lt;span class="c1"&gt;# Restore only; save is in workflow (test/lint job) to avoid duplicate save for same key&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Restore Nx cache&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/cache/restore@v4&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.nx&lt;/span&gt;
        &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nx-${{ runner.os }}-${{ inputs.nx-cache }}-${{ hashFiles('nx.json', 'yarn.lock') }}-${{ hashFiles('**/project.json') }}&lt;/span&gt;
        &lt;span class="na"&gt;restore-keys&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;nx-${{ runner.os }}-${{ inputs.nx-cache }}-${{ hashFiles('nx.json', 'yarn.lock') }}-&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4) Workflow: concurrency, ECR Public, parallel test + lint
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Triggers and concurrency
&lt;/h3&gt;

&lt;p&gt;We run on every push. We use concurrency so that a new push on the same branch cancels an in-flight run. Except on &lt;code&gt;master&lt;/code&gt;, where we always let CI complete (release / mainline stability).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CI • Test and Lint&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**"&lt;/span&gt; &lt;span class="c1"&gt;# all branches&lt;/span&gt;

&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test-and-lint-${{ github.ref }}&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.ref != 'refs/heads/master' }}&lt;/span&gt; &lt;span class="c1"&gt;# Run CI everytime `master` branch is pushed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Docker image without Docker Hub rate limits
&lt;/h3&gt;

&lt;p&gt;Jobs run in a Node container pulled from Amazon ECR Public (same family as &lt;code&gt;docker.io/library/node&lt;/code&gt;), after a small job that logs in via AWS.&lt;/p&gt;

&lt;h3&gt;
  
  
  Parallel jobs
&lt;/h3&gt;

&lt;p&gt;After &lt;code&gt;ecr-login&lt;/code&gt;, test and lint run in parallel on &lt;code&gt;large-runner&lt;/code&gt; and &lt;code&gt;medium-runner&lt;/code&gt; respectively. Each calling the composite action with &lt;code&gt;nx-cache: test&lt;/code&gt; or &lt;code&gt;nx-cache: lint&lt;/code&gt;, then &lt;code&gt;yarn ci:test&lt;/code&gt; / &lt;code&gt;yarn ci:lint&lt;/code&gt;, and always saving the matching Nx cache.&lt;/p&gt;

&lt;h3&gt;
  
  
  Jest reports when Nx skips work
&lt;/h3&gt;

&lt;p&gt;With Nx, if nothing relevant changed in a project, tests might not run, so no JUnit XML is produced. We still want the job summary to be meaningful when reports exist. After tests, we check for &lt;code&gt;reports/**/*.xml&lt;/code&gt; (from &lt;code&gt;jest-junit&lt;/code&gt;); only then we run &lt;code&gt;dorny/test-reporter&lt;/code&gt; so we don’t fail the step when there’s nothing to publish.&lt;/p&gt;

&lt;p&gt;Test job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ecr-login&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Test&lt;/span&gt;
  &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;large-runner&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;TZ&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Europe/Paris&lt;/span&gt;
  &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Use ECR Public Gallery to avoid Docker Hub rate limits (same image as docker.io/library/node)&lt;/span&gt;
    &lt;span class="c1"&gt;# @see https://gallery.ecr.aws/docker/library/node&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;public.ecr.aws/docker/library/node:20.17.0&lt;/span&gt;
  &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Checkout&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./.github/actions/setup&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;nx-cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run test&lt;/span&gt;
      &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;run-test&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;yarn ci:test&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Save Nx cache (test)&lt;/span&gt;
      &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/cache/save@v4&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.nx&lt;/span&gt;
        &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nx-${{ runner.os }}-test-${{ hashFiles('nx.json', 'yarn.lock') }}-${{ hashFiles('**/project.json') }}&lt;/span&gt;

    &lt;span class="c1"&gt;# Check if tests reports should be skipped because of NX cache and no changes in codebase&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Check for test reports&lt;/span&gt;
      &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;check_reports&lt;/span&gt;
      &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;if [ -d reports ] &amp;amp;&amp;amp; [ -n "$(find reports -name '*.xml' -print -quit 2&amp;gt;/dev/null)" ]; then&lt;/span&gt;
          &lt;span class="s"&gt;echo "exist=true" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
        &lt;span class="s"&gt;else&lt;/span&gt;
          &lt;span class="s"&gt;echo "exist=false" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
        &lt;span class="s"&gt;fi&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Publish test results to Job Summary&lt;/span&gt;
      &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always() &amp;amp;&amp;amp; steps.check_reports.outputs.exist == 'true'&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dorny/test-reporter@v2&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Jest Tests&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;reports/*.xml&lt;/span&gt;
        &lt;span class="na"&gt;reporter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jest-junit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lint job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;lint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Lint&lt;/span&gt;
  &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ecr-login&lt;/span&gt;
  &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;medium-runner&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;TZ&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Europe/Paris&lt;/span&gt;
  &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Use ECR Public Gallery to avoid Docker Hub rate limits (same image as docker.io/library/node)&lt;/span&gt;
    &lt;span class="c1"&gt;# @see https://gallery.ecr.aws/docker/library/node&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;public.ecr.aws/docker/library/node:20.17.0&lt;/span&gt;
  &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Checkout&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./.github/actions/setup&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;nx-cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lint&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run lint&lt;/span&gt;
      &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;run-lint&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;yarn ci:lint&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Save Nx cache (lint)&lt;/span&gt;
      &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/cache/save@v4&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.nx&lt;/span&gt;
        &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nx-${{ runner.os }}-lint-${{ hashFiles('nx.json', 'yarn.lock') }}-${{ hashFiles('**/project.json') }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5) Jest, parallelism, and Out Of Memory (OOM): capping &lt;code&gt;maxWorkers&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;On Jest 29 we hit OOM when launching tests.&lt;/p&gt;

&lt;p&gt;We added a small TypeScript helper used when invoking the CI’s test job (and lint where relevant). So that total parallelism stays bounded: we assume up to &lt;a href="https://nx.dev/docs/guides/tasks--caching/run-tasks-in-parallel" rel="noopener noreferrer"&gt;three test streams in parallel&lt;/a&gt; and divide CPU count so that each Jest process gets &lt;a href="https://jestjs.io/docs/cli#--maxworkersnumstring" rel="noopener noreferrer"&gt;a sane &lt;code&gt;--maxWorkers&lt;/code&gt;&lt;/a&gt;  value.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;os&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:os&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CORES_COUNT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cpus&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PARALLEL_TEST_COUNT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_WORKERS_COUNT_PER_TEST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CORES_COUNT&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;PARALLEL_TEST_COUNT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We expect to revisit this after moving to Vitest or Jest 30, where defaults and memory behavior may change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Migrating our CI was not only swapping CircleCI for GitHub Actions. It was aligning runner size, caching, and Jest concurrency with how Nx actually schedules work. We now own setup and cache behavior via a composite action and Terraform-managed runners. Which led to reduced costs and an improvement of the CI’s wall-clock from ~33 minutes to ~15 minutes.&lt;/p&gt;

&lt;p&gt;Next iteration: keep tuning workers and task graphs as we modernize the test runner.&lt;/p&gt;

&lt;p&gt;If you like monorepo and platform engineering notes like this, follow Wecasa and get in touch if you want to build this kind of stack with us.&lt;/p&gt;

</description>
      <category>ci</category>
      <category>aws</category>
      <category>githubactions</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Migrating Yarn 1 to 4 in an Nx Monorepo</title>
      <dc:creator>Kévin Huang</dc:creator>
      <pubDate>Tue, 21 Apr 2026 14:19:14 +0000</pubDate>
      <link>https://dev.to/wecasa/migrating-yarn-1-to-4-in-an-nx-monorepo-5bc2</link>
      <guid>https://dev.to/wecasa/migrating-yarn-1-to-4-in-an-nx-monorepo-5bc2</guid>
      <description>&lt;p&gt;With just one migration, we reduced the execution time of yarn install on our CI from ~110s to ~25s. While also removing legacy patching with postinstall and by also keeping behaviour close enough to Yarn 1 to ensure a safe migration.&lt;/p&gt;




&lt;p&gt;At Wecasa, our frontend lives in a large Nx monorepo (web + mobile apps) and we were still running on Yarn 1.22.0. It worked well for a while, but at some point the dependency installation became a bottleneck on our CI. And our patch workflow (patch-package + postinstall-postinstall) was adding maintenance overhead a well.&lt;/p&gt;

&lt;p&gt;So we decided to migrate to Yarn 4, but with one strict goal: minimize risk. Instead of changing everything at once, we focused on a compatibility-first setup (especially important because React Native / Expo still requires &lt;code&gt;node_modules&lt;/code&gt;) and planned optimizations for later iterations.&lt;/p&gt;

&lt;h2&gt;
  
  
  1) Migration Strategy: Stability First, Optimization Later
&lt;/h2&gt;

&lt;p&gt;We followed the official guide: &lt;a href="https://yarnpkg.com/migration/guide" rel="noopener noreferrer"&gt;Yarn migration guide&lt;/a&gt; , with a progressive approach:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;corepack &lt;span class="nb"&gt;enable
&lt;/span&gt;yarn &lt;span class="nb"&gt;set &lt;/span&gt;version berry
yarn &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key decision: keep behavior as close as possible to Yarn 1 during the first step.&lt;/p&gt;

&lt;p&gt;That meant postponing advanced hoisting changes and avoiding Plug’n’Play for now.&lt;/p&gt;

&lt;p&gt;Plug’n’Play makes Yarn resolve packages via a &lt;code&gt;.pnp.cjs&lt;/code&gt; map instead of a full &lt;code&gt;node_modules&lt;/code&gt; folder but not always compatible with every toolchain (e.g. React Native / Expo still require using typical &lt;code&gt;node_modules&lt;/code&gt; install).&lt;/p&gt;

&lt;p&gt;Our &lt;code&gt;.yarnrc.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;nmHoistingLimits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;none&lt;/span&gt;
&lt;span class="na"&gt;nodeLinker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node-modules&lt;/span&gt;
&lt;span class="na"&gt;patchFolder&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.yarn/patches&lt;/span&gt;
&lt;span class="na"&gt;yarnPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.yarn/releases/yarn-4.12.0.cjs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why this setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;nodeLinker: node-modules&lt;/code&gt; keeps compatibility with React Native / Expo.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;nmHoistingLimits: none&lt;/code&gt; avoids introducing too many resolution differences on day one.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;patchFolder&lt;/code&gt; and &lt;code&gt;yarnPath&lt;/code&gt; make Yarn behavior explicit and reproducible across environments.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://yarnpkg.com/features/pnp#when-creating-a-new-project" rel="noopener noreferrer"&gt;As documentation by Yarn&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A notable exception is React Native / Expo, which require using typical &lt;code&gt;node_modules&lt;/code&gt; installs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  2) Lockfile and Repository Changes
&lt;/h2&gt;

&lt;p&gt;After switching versions, we ran yarn install to regenerate the yarn.lock file with a Yarn 4 format while preserving dependency versions.&lt;/p&gt;

&lt;p&gt;This gave us a clean, deterministic baseline before any deeper dependency cleanup.&lt;/p&gt;

&lt;p&gt;We also updated git ignore rules by adding this file: &lt;code&gt;.yarn/install-state.gz&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored" rel="noopener noreferrer"&gt;As documented by Yarn&lt;/a&gt; , this file is only an optimization artifact and should not be committed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;.yarn/install-state.gz is an optimization file that you shouldn't ever have to commit. It simply stores the exact state of your project so that the next commands can boot without having to resolve your workspaces all over again.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  3) Migrating Existing Patches from &lt;code&gt;patch-package&lt;/code&gt; to Yarn Native Patches
&lt;/h2&gt;

&lt;p&gt;Before migration, our patches lived under &lt;code&gt;&amp;lt;root&amp;gt;/patches&lt;/code&gt; and were applied via postinstall scripts.&lt;/p&gt;

&lt;p&gt;With Yarn 4, we moved to the native patch workflow, and new patches now live in &lt;code&gt;.yarn/patches&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step-by-step for each existing patch
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Start an editable patch session:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yarn patch &amp;lt;package-name&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Move to the temporary folder shown in the command output:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &amp;lt;temp-folder&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Apply your existing patch file:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;patch &lt;span class="nt"&gt;-p&lt;/span&gt;&amp;lt;number&amp;gt; &amp;lt; /absolute/path/to/patches/&amp;lt;package&amp;gt;.patch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Commit the patch back into Yarn:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yarn patch-commit &lt;span class="nt"&gt;--save&lt;/span&gt; &amp;lt;temp-folder&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  About &lt;code&gt;-p&amp;lt;number&amp;gt;&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;-p&lt;/code&gt; value depends on how many path segments must be stripped from your patch paths.&lt;/p&gt;

&lt;p&gt;Example patch path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gh"&gt;diff --git a/node_modules/@types/react/path/to/file.js
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this case, &lt;code&gt;-p4&lt;/code&gt; strips &lt;code&gt;a/node_modules/@types/react&lt;/code&gt;, so paths match files in the temp patch directory.&lt;/p&gt;

&lt;h2&gt;
  
  
  4) What Changed in &lt;code&gt;package.json&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Once committed, dependencies patched by Yarn are rewritten like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"my-package-name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"52.0.6"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"my-package-name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"patch:my-package-name@npm%3A52.0.6#~/.yarn/patches/my-package-name-npm-52.0.6-20053456a7.patch"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This removes the need for &lt;code&gt;patch-package&lt;/code&gt; + postinstall patch execution and centralizes patch handling in Yarn itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  5) Results and Next Iterations
&lt;/h2&gt;

&lt;p&gt;Immediate gains after the migration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CI install time dropped from ~110s to ~25s&lt;/li&gt;
&lt;li&gt;patching workflow simplified (no more postinstall patch mechanism)&lt;/li&gt;
&lt;li&gt;safer long-term setup with modern Yarn tooling&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What we’ll iterate on next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;refine hoisting strategy (&lt;code&gt;nmHoistingLimits&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;audit dependency constraints and deduplication opportunities&lt;/li&gt;
&lt;li&gt;progressively adopt more Yarn 4 capabilities where ecosystem compatibility allows&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Migrating Yarn 1 to Yarn 4 in a large Nx monorepo can be low-risk if you prioritize compatibility first.&lt;/p&gt;

&lt;p&gt;We got a 4x CI install speed-up and cleaner patch management in one pass, while keeping mobile compatibility intact.&lt;/p&gt;

&lt;p&gt;If you enjoy pragmatic monorepo migration stories and performance wins, follow Wecasa’s engineering journey.&lt;/p&gt;

</description>
      <category>yarn</category>
      <category>programming</category>
      <category>ci</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
