Monorepo & Parallel Test Sharding

A single-application accessibility scan is a solved problem: boot the app, walk a dozen routes, fail on serious and above. A monorepo with twelve packages and 480 routes is a scheduling problem wearing the same clothes, and the serial version of that job takes thirty-eight minutes — long enough that developers stop waiting for it, reviewers approve around it, and the gate quietly becomes advisory. This guide is part of CI/CD Integration & Automated Quality Gating, and it covers the three ways to get that scan back inside a pull-request budget without narrowing what gets scanned: split the work across parallel jobs, select only the packages the diff can actually affect, and replay cached results for everything unchanged.

Problem Statement

The specific failure is a time budget, not a coverage gap. On a repository with 480 routes spread across four applications and eight shared packages, a serial run on a ubuntu-24.04 runner measures 38 minutes 20 seconds end to end. Teams respond by cutting the route list to a “representative sample” of thirty pages, which is the worst available answer: it keeps the job green, keeps the runtime acceptable, and stops finding the regressions the gate exists to find. Six months later the sampled thirty pages are perfect and the other 450 have accumulated a backlog nobody measured.

Worse, the growth is not linear, so the sampling decision gets made earlier than expected. Per-route cost rises as the run gets longer: the browser process accumulates heap and starts paying GC pauses, the preview server compiles more modules on demand and its module cache stops fitting in memory, and the probability that at least one route flakes approaches one — each retry re-running a whole spec. Measured on the same repository, the same hardware and the same commit:

Routes scanned Serial wall clock Mean per route Slowest route Spec retries
24 1 m 08 s 2.8 s 6.1 s 0
96 5 m 12 s 3.3 s 9.4 s 1
240 15 m 40 s 3.9 s 14.2 s 3
480 38 m 20 s 4.8 s 22.6 s 9

Extrapolating linearly from the 24-route figure predicts 22 minutes 40 seconds at 480 routes. The measured number is 69% higher. That gap is the whole reason shard counts have to come from measurements rather than from arithmetic on a per-route average someone remembers.

Serial scan time grows faster than route count Paired columns at four route counts. The projection from the 24-route measurement predicts 1360 seconds at 480 routes while the measured value is 2300 seconds, because mean per-route cost rises from 2.8 to 4.8 seconds as the run lengthens. Serial scan wall clock: linear projection vs measured linear projection from 24 routes measured 800 s 1600 s 2400 s 68 s 68 s 272 s 312 s 680 s 940 s 1360 s 2300 s 24 routes 96 routes 240 routes 480 routes mean per-route cost rises from 2.8 s to 4.8 s as the run gets longer
The projection and the measurement agree at 24 routes and diverge by sixteen minutes at 480, which is why a shard plan built on an assumed average under-provisions.

Key implementation targets:

  • A per-route cost file with a p95 figure per route, refreshed on a schedule rather than guessed once and forgotten.
  • A shard count derived from a stated wall-clock budget, the measured total, and the fixed per-job overhead — not a round number like 4 or 10.
  • A balanced route partition where the slowest shard is within a few seconds of the mean, so no straggler defines the wall clock.
  • A CI matrix that runs every shard to completion even when one fails, with one artifact per shard.
  • One merged report that deduplicates cross-route findings and proves every expected shard reported.
  • Exactly one required status check whose name never changes when the shard count does.

Prerequisites

Why a Full Scan Grows Faster Than the Route List

Per-route cost decomposes into four parts, and only one of them is the accessibility check. Measured over three runs of each route on the same repository:

Phase Median Worst route What drives it
Navigation and hydration wait 1,180 ms 8,900 ms framework hydration, data fetch, font load
axe-core source injection 118 ms 145 ms evaluating a 560 KB script per page context
axe.run() 640 ms 11,400 ms DOM node count and colour-contrast sampling
Context teardown and report write 96 ms 210 ms serialising nodes, closing the browser context

Two of those four are fixed costs paid once per route regardless of page complexity, which sets a hard floor: 480 routes cannot cost less than about 480 × 1.3 s = 10 minutes of browser time no matter how simple the pages are. Reusing one browser context across routes cuts the floor but reintroduces state leakage between pages — a modal left open, a service worker still installed, a stale aria-live region — which produces violations attributed to the wrong route. A fresh context per route is the correct default and the cost of correctness.

The super-linear part comes from three compounding effects. Heap growth in a long-lived browser makes the four-hundredth axe.run() measurably slower than the fourth. The preview server compiles routes on demand, so an early route benefits from a warm module graph while a late route in a package nobody has touched pays a cold compile. And retries multiply: at a 1.5% per-route flake rate, a 480-route serial run expects seven retried specs, each of which pays the navigation and hydration cost again. None of that shows up in a 24-route pilot, which is exactly why pilots under-predict.

The Three Axes of Reduction

There are only three levers, and they compose. Shard the work divides the same total across N runners, so wall clock approaches total / N plus a fixed overhead. Reduce the work removes routes the diff cannot possibly affect, using the workspace dependency graph rather than a guess. Cache the unchanged replays a stored result for a package whose inputs are bit-identical to a previous successful run, so the scan does not execute at all.

Sharding alone has a floor. Every additional job pays the full per-job overhead — checkout, npm ci, browser restore, preview-server boot — which measures 95 seconds on this repository even with a warm dependency cache. With a total of 1,180 seconds of selected route work, four shards predict 95 + 295 + 25 = 415 seconds; sixteen shards predict 95 + 74 + 25 = 194 seconds but burn four times the billed minutes for a 3.6× wall-clock gain, and every one of those sixteen jobs is another chance for an infrastructure flake to fail the gate. Pick the smallest N that fits the budget, not the largest N the fleet allows.

Three ways to fit the scan inside the budget The 38-minute serial scan on the left passes through three parallel strategies in the middle: shard the work across four jobs, reduce the work to affected packages, and cache unchanged task results. All three converge on a six-minute pull-request gate emitting a single merged verdict. Three ways to fit the scan inside the budget full serial scan 480 routes · 38 min 1 Shard the work 4 parallel jobs 2 Reduce the work affected packages only 3 Cache the unchanged replay task results PR gate: 6 min one merged verdict fixed per-job overhead of 95 s is paid by every shard, so more shards is not always faster
The three levers are independent: selection decides which routes exist for the plan, caching removes packages before the plan is built, and sharding divides whatever is left.

The second axis is where a monorepo differs from a large single application, and it is covered in depth in scanning only affected packages in a Turborepo monorepo: a pull request touching apps/docs cannot change the rendered DOM of apps/checkout, but a pull request touching packages/ui can change all four applications, and only the workspace graph knows which case applies. The third axis is task-level caching, which is the same mechanism and the same configuration file, with a hazard worth stating up front: a cached “pass” that was computed against a previous version of a shared dependency is a false pass.

1. Measuring Per-Route Scan Cost

The plan is only as good as its inputs, so the first artifact to build is a cost file. Run the measurement out of band — nightly on main, not on pull requests — because it needs three samples per route to produce a usable p95, and because a measurement contaminated by a pull request’s own changes is worse than no measurement.

// a11y/scripts/measure-route-cost.mjs
// Nightly on main: node a11y/scripts/measure-route-cost.mjs
// Writes a11y/route-cost.json, the only input to the shard planner.
import { readFileSync, writeFileSync } from 'node:fs';
import { chromium } from 'playwright';
import { source as axeSource } from 'axe-core';

const BASE = process.env.A11Y_BASE_URL ?? 'http://127.0.0.1:4173';
const RUNS = Number(process.env.A11Y_COST_RUNS ?? 3); // 3 samples: p95 == max
const routes = JSON.parse(readFileSync('a11y/routes.json', 'utf8'));

const browser = await chromium.launch();
const results = [];

for (const route of routes) {
  const samples = [];
  for (let run = 0; run < RUNS; run += 1) {
    // A fresh context per sample, matching how the real scan runs: shared
    // contexts leak modals and service workers into the next route's report.
    const context = await browser.newContext();
    const page = await context.newPage();
    const t0 = performance.now();
    await page.goto(`${BASE}${route}`, { waitUntil: 'domcontentloaded' });
    await page.getByRole('main').waitFor();
    const tNav = performance.now();
    await page.addScriptTag({ content: axeSource }); // injection is not free
    const tInject = performance.now();
    const nodes = await page.evaluate(() => document.querySelectorAll('*').length);
    await page.evaluate(() => window.axe.run(document, {
      resultTypes: ['violations'], // skip building the passes array while timing
    }));
    const tRun = performance.now();
    await context.close();
    samples.push({ nav: tNav - t0, inject: tInject - tNav, run: tRun - tInject, nodes });
  }
  const totals = samples.map((s) => s.nav + s.inject + s.run).sort((a, b) => a - b);
  const mean = (pick) => Math.round(samples.reduce((sum, s) => sum + pick(s), 0) / RUNS);
  results.push({
    route,
    nodeCount: samples[0].nodes,
    meanMs: Math.round(totals.reduce((a, b) => a + b, 0) / totals.length),
    p95Ms: Math.round(totals[totals.length - 1]),
    breakdownMs: {
      nav: mean((s) => s.nav),
      inject: mean((s) => s.inject),
      run: mean((s) => s.run),
    },
  });
  console.error(`${route} ${results.at(-1).meanMs} ms`); // progress on stderr only
}

await browser.close();
writeFileSync('a11y/route-cost.json', `${JSON.stringify(results, null, 2)}\n`);

Plan against p95Ms, never meanMs. A route whose mean is 3.1 s and whose p95 is 14 s is a route that will occasionally blow a shard’s budget on its own, and planning on the mean guarantees one shard that finishes four minutes after the others. The nodeCount field is worth keeping even though the planner ignores it: when a route’s cost jumps 40% between weekly measurements, node count tells you immediately whether the page got bigger or the runner got slower.

2. Deriving the Shard Plan

With a cost file, the shard count is arithmetic rather than opinion. Given a wall-clock budget B, per-job overhead O, merge-job cost M and total selected route seconds T, the smallest shard count that fits is N = ceil(T / (B - O - M)). On this repository with B = 480 s, O = 95 s, M = 25 s and T = 1,180 s after package selection, N = ceil(1180 / 360) = 4.

Then the routes have to be distributed so that no shard is materially heavier than the others. Sorting by cost descending and repeatedly assigning the next route to the least-loaded shard — longest-processing-time-first — lands within 4/3 of the theoretical optimum and runs in O(n log n), which is more than enough for a few hundred routes.

// a11y/scripts/plan-shards.mjs
// Usage: node a11y/scripts/plan-shards.mjs a11y/route-cost.json 480 > shard-plan.json
import { readFileSync } from 'node:fs';

const [costPath, budgetArg] = process.argv.slice(2);
const BUDGET_S = Number(budgetArg);   // wall-clock budget for the whole gate
const JOB_OVERHEAD_S = 95;            // checkout + install + browsers + server boot
const MERGE_S = 25;                   // artifact download + merge + step summary
const MAX_SHARDS = 12;                // hard ceiling: one PR must not eat the fleet

const costs = JSON.parse(readFileSync(costPath, 'utf8'));
const byPath = (a, b) => (a < b ? -1 : a > b ? 1 : 0); // codepoint order, no locale
const routes = costs
  .map((entry) => ({ route: entry.route, cost: entry.p95Ms / 1000 }))
  // Total order: cost descending, then path ascending. Ties must never depend
  // on input order, or a retried shard gets a different route set.
  .sort((a, b) => b.cost - a.cost || byPath(a.route, b.route));

const total = routes.reduce((sum, entry) => sum + entry.cost, 0);
const perShard = BUDGET_S - JOB_OVERHEAD_S - MERGE_S;
if (perShard <= 0) throw new Error(`budget ${BUDGET_S}s is below fixed overhead`);

const shardCount = Math.min(MAX_SHARDS, Math.max(1, Math.ceil(total / perShard)));
const bins = Array.from({ length: shardCount }, (_, index) => ({
  index, costSeconds: 0, routes: [],
}));

for (const entry of routes) {
  const lightest = bins.reduce((a, b) => (b.costSeconds < a.costSeconds ? b : a));
  lightest.routes.push(entry.route);
  lightest.costSeconds = Math.round((lightest.costSeconds + entry.cost) * 10) / 10;
}

const slowest = Math.max(...bins.map((bin) => bin.costSeconds));
process.stdout.write(`${JSON.stringify({
  commit: process.env.GITHUB_SHA ?? 'local',
  budgetSeconds: BUDGET_S,
  totalRouteSeconds: Math.round(total),
  shardCount,
  predictedWallSeconds: Math.round(JOB_OVERHEAD_S + slowest + MERGE_S),
  shards: bins,
}, null, 2)}\n`);
From measured cost to four balanced shards The cost file and the budget formula produce a shard count of four, then longest-processing-time-first packing fills four bins whose totals are 298, 302, 291 and 289 seconds. Predicted wall clock is 95 seconds of overhead plus the slowest bin plus 25 seconds of merge. From measured cost to four balanced shards route-cost.json p95 per route n = 1180 / 360 480 − 95 − 25 s pack heaviest first least-loaded bin wins Packed bins — one CI job each 96 s 88 s 74 s 298 s 140 s 92 s 70 s 302 s 118 s 104 s 69 s 291 s 132 s 88 s 69 s 289 s shard 0 shard 1 shard 2 shard 3 predicted wall clock: 95 s overhead + 302 s slowest shard + 25 s merge = 7 min 02 s
Each stacked segment is one route's p95 cost; the planner's only goal is to make the tallest bar as short as possible, because the tallest bar is the gate's wall clock.

Publish predictedWallSeconds in the plan job’s step summary and compare it to the actual duration afterwards. When prediction and reality diverge by more than 20% for a week, the cost file is stale and the nightly measurement is either failing or measuring a different build than the pull requests do.

3. Running Shards as a CI Matrix

The plan job computes the matrix; the scan job consumes it. Emitting the shard indexes as a JSON array from a job output and expanding them with fromJSON keeps the shard count dynamic, so a growing route list changes a number in an artifact rather than a hand-edited matrix in YAML.

name: a11y-sharded-scan
on:
  pull_request:
    branches: [main]
permissions:
  contents: read
concurrency:
  group: a11y-sharded-scan-${{ github.head_ref }}
  cancel-in-progress: true
jobs:
  plan:
    runs-on: ubuntu-24.04
    timeout-minutes: 8
    outputs:
      shards: ${{ steps.emit.outputs.shards }}
      shard_count: ${{ steps.emit.outputs.shard_count }}
      scan_needed: ${{ steps.emit.outputs.scan_needed }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0 # package selection diffs against the merge base
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: npm
      - run: npm ci
      - name: Build once and share the output with every shard
        run: npm run build
      - uses: actions/cache/restore@v4
        with:
          path: a11y/route-cost.json
          key: a11y-route-cost-v1 # written by the nightly measurement workflow
      - name: Fall back to a flat cost model when no measurement was restored
        if: hashFiles('a11y/route-cost.json') == ''
        run: |
          node -e 'const fs=require("fs");
          const r=JSON.parse(fs.readFileSync("a11y/routes.json","utf8"));
          fs.writeFileSync("a11y/route-cost.json",
            JSON.stringify(r.map(route=>({route,meanMs:4800,p95Ms:6200}))));'
      - name: Derive the shard plan from the budget
        id: emit
        run: |
          node a11y/scripts/plan-shards.mjs a11y/route-cost.json 480 > shard-plan.json
          COUNT=$(jq -r '.shardCount' shard-plan.json)
          echo "shards=$(jq -c '[.shards[].index]' shard-plan.json)" >> "$GITHUB_OUTPUT"
          echo "shard_count=$COUNT" >> "$GITHUB_OUTPUT"
          # An empty matrix array is a workflow error, so gate the job instead.
          echo "scan_needed=$([ "$COUNT" -gt 0 ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
          jq -r '"plan: \(.shardCount) shards, predicted \(.predictedWallSeconds)s"' \
            shard-plan.json >> "$GITHUB_STEP_SUMMARY"
      - uses: actions/upload-artifact@v4
        with:
          name: a11y-shard-plan
          path: shard-plan.json
          retention-days: 7
      - uses: actions/upload-artifact@v4
        with:
          name: a11y-build-output
          path: dist/
          retention-days: 1 # shards download this instead of rebuilding
  scan:
    needs: plan
    if: needs.plan.outputs.scan_needed == 'true'
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    strategy:
      fail-fast: false # one failing shard must not cancel the others
      max-parallel: 6
      matrix:
        shard: ${{ fromJSON(needs.plan.outputs.shards) }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: npm
      - run: npm ci
      - uses: actions/download-artifact@v4
        with:
          name: a11y-shard-plan
      - uses: actions/download-artifact@v4
        with:
          name: a11y-build-output
          path: dist/
      - run: npx playwright install --with-deps chromium
      - name: Scan this shard's routes
        env:
          SHARD_INDEX: ${{ matrix.shard }}
          SHARD_TOTAL: ${{ needs.plan.outputs.shard_count }}
        run: npx playwright test tests/a11y/sharded.spec.ts
      - uses: actions/upload-artifact@v4
        if: always() # a crashed shard still uploads what it managed to scan
        with:
          name: a11y-shard-${{ matrix.shard }}
          path: a11y/out/shard-${{ matrix.shard }}.json
          retention-days: 7

Three details in that file do real work. fail-fast: false is the difference between one report and four when a shard finds a blocking violation — the default cancels every sibling job the moment one fails, which throws away the findings a reviewer needs to fix everything in one pass. if: always() on the upload means a shard that crashes at route 40 of 62 still hands over a partial report, which is what lets the merge distinguish “crashed midway” from “never ran”. And building once in the plan job, then downloading dist/ per shard, removes four cold production builds from the critical path. Partitioning strategy — round-robin against contiguous slicing, and how to keep a retried shard scanning the same routes — is worked through in sharding axe-core scans across parallel CI jobs.

4. Merging Shard Artifacts into One Report

Four shards produce four JSON files, and a gate cannot read four files and produce one answer without deciding three things: how to count a finding that appears on many routes, what to do with results that were incomplete in one shard and a violation in another, and how to notice that a shard is missing entirely. The merge step owns all three decisions and writes exactly one file that everything downstream reads.

  merge:
    needs: [plan, scan]
    if: always() && needs.plan.outputs.scan_needed == 'true'
    runs-on: ubuntu-24.04
    timeout-minutes: 6
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - uses: actions/download-artifact@v4
        with:
          name: a11y-shard-plan
      - uses: actions/download-artifact@v4
        with:
          pattern: a11y-shard-* # every shard artifact, one directory each
          path: a11y/in/
      - name: Merge shard reports into one verdict document
        run: |
          node a11y/scripts/merge-reports.mjs \
            --plan shard-plan.json \
            --input a11y/in \
            --out a11y/merged.json \
            --summary "$GITHUB_STEP_SUMMARY"
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: a11y-merged-report
          path: a11y/merged.json
          retention-days: 30

The merged document is a stable contract, not an implementation detail, because the verdict job, the dashboard exporter and the pull-request annotator all read it. It carries a run object (shardsExpected, shardsSeen, routesPlanned, routesScanned, complete) and a findings array where each entry is one deduplicated problem with an occurrences list naming every route it appeared on. A shared header with insufficient contrast on forty routes is one finding with forty occurrences, which is both true and actionable; forty separate findings is neither. The fingerprinting, incomplete reconciliation and missing-shard logic are the subject of merging sharded accessibility reports into one artifact, and the merged file is also the natural export boundary for the trend charts described in reporting dashboards and violation tracking.

5. The Single Required Status Check

Branch protection matches required checks by name. A matrix produces check runs named scan (0), scan (1) and so on, which means the list of required checks changes every time the shard count changes — and a name that no longer exists is a check that silently stops being required. The fix is one terminal job with a fixed name whose only responsibility is to turn the merged document into an exit code.

// a11y/scripts/verdict.mjs
// Usage: node a11y/scripts/verdict.mjs a11y/merged.json
// Exit 0 = pass, 1 = blocking violations, 2 = the run itself was incomplete.
import { readFileSync } from 'node:fs';

const BLOCKING = new Set(['critical', 'serious']);
const merged = JSON.parse(readFileSync(process.argv[2], 'utf8'));
const { run, findings } = merged;

if (!run.complete) {
  console.error(
    `incomplete run: ${run.shardsSeen}/${run.shardsExpected} shards, ` +
    `${run.routesScanned}/${run.routesPlanned} routes`,
  );
  process.exit(2); // never report a pass from a partial run
}

const blocking = findings.filter((finding) => BLOCKING.has(finding.impact));
const occurrences = blocking.reduce((sum, f) => sum + f.occurrences.length, 0);
for (const finding of blocking.slice(0, 10)) {
  console.error(`${finding.impact} ${finding.ruleId} on ${finding.occurrences.length} route(s)`);
}
console.error(`${blocking.length} blocking finding(s), ${occurrences} occurrence(s)`);
process.exit(blocking.length === 0 ? 0 : 1);
  a11y-verdict: # the only name in branch protection; never rename it
    needs: [plan, scan, merge]
    if: always()
    runs-on: ubuntu-24.04
    timeout-minutes: 4
    steps:
      - name: Fail when any shard did not succeed
        if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
        run: |
          echo "a shard or the merge job did not complete" >> "$GITHUB_STEP_SUMMARY"
          exit 1
      - name: Pass explicitly when the diff selected no packages
        if: needs.plan.outputs.scan_needed != 'true'
        run: echo "no affected packages: nothing to scan" >> "$GITHUB_STEP_SUMMARY"
      - uses: actions/checkout@v4
        if: needs.plan.outputs.scan_needed == 'true'
      - uses: actions/download-artifact@v4
        if: needs.plan.outputs.scan_needed == 'true'
        with:
          name: a11y-merged-report
          path: a11y/
      - name: Read the merged verdict
        if: needs.plan.outputs.scan_needed == 'true'
        run: node a11y/scripts/verdict.mjs a11y/merged.json

The if: always() plus explicit needs.*.result inspection is load-bearing. Without it, a cancelled shard leaves the verdict job skipped, and a skipped required check is treated as neutral by branch protection on some configurations — a gate that disappears exactly when something went wrong. Registering the name itself in the ruleset is covered in requiring accessibility status checks in branch protection.

N shard artifacts, one required status check Shards zero, one and three upload reports while shard two uploads nothing. The merge script deduplicates findings by fingerprint and compares shards seen against the plan's expected count, producing exit code zero only when all four are present and exit code two otherwise. N shard artifacts, one required status check a11y-shard-0 62 routes · 3 found a11y-shard-1 58 routes · 0 found a11y-shard-2 no artifact a11y-shard-3 61 routes · 7 found merge-reports.mjs dedupe by fingerprint expected 4, saw 3 exit 0 · check passes only if 4 of 4 seen exit 2 · incomplete never a false pass a11y-verdict is the only check name branch protection ever sees
The expected shard count comes from the plan artifact rather than from counting downloaded files, which is what makes a silently skipped shard a failure instead of a pass.

Pipeline Integration

Three exit codes carry three different meanings, and keeping them distinct is what makes the gate diagnosable from the checks tab alone. 0 means the merged report is complete and contains no serious or critical findings. 1 means the run was complete and found blocking violations — a content or code problem, owned by the pull request’s author. 2 means the run itself was not trustworthy: a missing shard, a route count mismatch, a corrupt report. Code 2 is an infrastructure problem and should page whoever owns the pipeline, not the developer whose pull request happened to be open.

Artifacts follow the same discipline. Each shard uploads a11y-shard-<index> with a seven-day retention; the merged report uploads as a11y-merged-report with thirty days, because that is the file trend analysis reads long after the shard details stop mattering. Do not upload Playwright traces from every shard by default — four shards × 62 routes of trace zips will exceed a repository’s artifact storage inside a week. Attach traces only on failure, with trace: 'retain-on-failure' in the Playwright config.

For pull-request feedback, the annotator reads a11y/merged.json rather than any shard file, so a comment says “3 findings across 12 routes” instead of posting four comments that each know a quarter of the truth. Container-based runners change the overhead constant rather than the design: a prebuilt image with browsers and dependencies baked in drops the 95-second per-job overhead to around 30 seconds, which lowers the shard count the planner picks for the same budget — the trade-offs are in Docker-based pipeline execution.

Troubleshooting and Flaky-Test Mitigation

One shard finishes four minutes after the others. The cost file is stale, or it was built from meanMs. Re-run the nightly measurement and confirm the planner is reading p95Ms. If a single route dominates its shard, the route itself is the problem: a 22-second route usually means an unbounded data fetch or an animation that never idles, and it should be fixed rather than planned around.

A retried shard reports different routes than its first attempt. The partition is being recomputed inside the shard job instead of being read from the plan artifact, so any nondeterminism in route discovery — filesystem ordering, an unstable sort comparator, a route list built from a fresh crawl — changes the slice. Compute the partition once, upload it, and have every shard read it.

The matrix job errors before any step runs. fromJSON received an empty array. GitHub requires at least one matrix vector, so the plan job must gate the scan with a scan_needed output rather than emitting []. This happens on the first pull request that touches only documentation.

Every shard fails with a connection refused on the preview server. The shards downloaded dist/ but nothing started serving it, or the server bound to localhost inside a container while the test used 127.0.0.1. Start the server through Playwright’s webServer with url set to exactly the baseURL the specs use, and let Playwright do the readiness polling.

Findings appear and disappear between runs on the same commit. Hydration timing, not sharding. A shard that runs 62 routes on a busy runner hydrates slower than the same route did during measurement, so a scan that fires on domcontentloaded sometimes sees pre-hydration DOM. Wait for an application-asserted signal — a settled attribute, a resolved promise, a period of DOM quiet — before injecting axe, and keep retries: 1 in the Playwright config so one flake does not fail a shard.

Two shards upload the same artifact name. Artifact names are immutable in upload-artifact@v4 and a duplicate name fails the step. This shows up after someone hardcodes name: a11y-shard while debugging; interpolate ${{ matrix.shard }} every time.

The merge job is skipped and the gate goes green. if: always() is missing on the merge job, or the verdict job does not inspect needs.*.result. Both are required: the verdict job must be reachable when a shard fails and must fail loudly when it is.

Common Pitfalls

  • Choosing a shard count because it is a round number, then discovering that eight shards cost twice the billed minutes of four for a ninety-second wall-clock gain.
  • Planning against mean per-route cost, so the one route with a 14-second p95 lands in a shard that was already full.
  • Listing individual matrix check runs in branch protection, which silently stops requiring anything the moment the shard count changes.
  • Leaving fail-fast at its default, so the first shard to find a violation cancels the other three and the pull request author fixes one quarter of the problem per CI round trip.
  • Rebuilding the application inside every shard, paying a 40-second production build four times when one plan-job build and a dist/ artifact would do.
  • Counting downloaded artifacts to decide how many shards there should have been, which makes a skipped shard invisible.
  • Reusing one browser context across a shard’s routes for speed, then chasing violations that were actually caused by the previous route’s leftover modal.
  • Deduplicating findings by exact CSS selector, so :nth-child(4) and :nth-child(5) count as two problems when they are one component rendered twice.
  • Treating exit code 2 the same as 1, which trains the team to re-run the job on infrastructure failures instead of fixing them.

FAQ

How many shards is too many? Stop when the marginal shard saves less wall clock than the per-job overhead it adds, which on a 95-second overhead means stop once each shard is doing less than about two minutes of route work. Past that point the run is mostly setup, billed minutes climb linearly, and the chance of an infrastructure flake in at least one job starts to dominate the failure rate. Four to six shards covers most repositories under a thousand routes.

Should the shard plan be committed to the repository or computed per run? Compute it per run from a committed or cached cost file. Committing the plan itself means every route added to the router needs a plan regeneration commit, and a plan that drifts from the route manifest produces shards that scan routes which no longer exist. Computing it per run from measured costs keeps one source of truth — routes.json plus route-cost.json — and makes the plan an artifact that documents what that specific run intended to do.

Does sharding change what axe-core reports for a given route? No. Each route is scanned in its own browser context against the same build with the same rule configuration, so the per-route result is identical to what a serial run produces. What changes is aggregation: the same finding now arrives from several shards and must be deduplicated, and per-run counters like “total violations” become a property of the merged report rather than of any single process.

How does this interact with scanning only the pages a pull request changed? They are the same lever applied at different granularities and they stack. Package selection removes whole applications from the route list before the planner sees it, and diff-aware page selection narrows further within a package, as described in gating only changed pages with a diff-aware scan. Run the full unfiltered scan on a schedule anyway, because selection is an optimisation with a failure mode — a change that alters the DOM without changing any selected package’s files.

What happens when the route count grows past the point where even the maximum shard count fits the budget? The budget has to change or the work has to shrink; more parallelism stops helping once MAX_SHARDS is reached. In practice the answer is to move the full-route scan to a merge-queue or nightly job and keep the pull-request gate on affected packages only, so the fast path stays fast and total coverage is still proven at least once a day. Publish both results to the same dashboard so nobody mistakes the fast gate for full coverage.

In This Section