Back to all posts
Visual Regression Testing Across Regions

Visual Regression Testing Across Regions

October 19, 2026

Why cross‑region visual checks matter

Modern web applications serve users from dozens of countries. CDN edge nodes, localized assets, A/B experiments, and region‑specific feature flags can all change the rendered UI. A screenshot taken from a single static IP rarely reveals layout shifts caused by:

  • Different font‑fallback stacks served from regional CDNs
  • Locale‑dependent text length (e.g., German vs. Japanese)
  • Geo‑targeted advertising or consent banners
  • Edge‑side CSS/JS bundles that differ per POP

Running visual regression only from your CI server's static IP gives a false sense of safety. Rotating residential proxies let you exercise the exact network path a real user follows, capturing the final painted pixels after all edge logic has run.

Architecture overview

A scheduler picks a target country, hands a fresh residential IP to a headless browser, captures a full‑page screenshot, and diffs it against a versioned baseline stored in object storage.

flowchart LR
    CI[CI Pipeline] -->|trigger| Scheduler
    Scheduler -->|pick region| ProxyPool[(Rotating Residential Proxy Pool)]
    ProxyPool -->|assign IP| Browser[Headless Browser (Playwright)]
    Browser -->|navigate + screenshot| Storage[(Artifact Store)]
    Storage -->|compare| DiffTool[Pixelmatch]
    DiffTool -->|report| CI

The key design choices:

  1. Sticky sessions – request one IP per region per test run so you pay for reuse rather than churn.
  2. Network idle waits – ensure third‑party scripts finish before snapshotting.
  3. Dynamic content suppression – hide timestamps, A/B badges, and consent dialogs so they don't corrupt baselines.
  4. Versioned baselines – store each baseline under baselines/<git-sha>/ so history is auditable.

Choosing the right proxy type

Proxy type Pros Cons Best fit
Rotating residential Real ISP ASN, high trust, low block rate Higher cost, variable latency ✅ Primary – mimics genuine user traffic
Static ISP Consistent IP, lower latency Limited geo diversity ✅ Good for stable baseline runs in one region
Datacenter Cheap, fast Frequently blocked, obvious ASN ❌ Not recommended
Mobile Carrier‑grade IPs Expensive, limited pool ✅ Optional for mobile viewports

Recommendation: Build a tiered pool — a small set of static ISP proxies for stable baseline captures, plus a larger rotating residential pool for daily multi‑region sweeps.

Implementation with Playwright (Node.js)

1. Install dependencies

npm i -D playwright @scrap-ice/proxy-rotator pixelmatch pngjs

@scrap-ice/proxy-rotator is an open‑source library that fetches rotating residential proxy URLs by country, which keeps this post free of vendor‑specific SDK references.

2. Proxy helper module

// lib/proxy.js
const proxyRotator = require('@scrap-ice/proxy-rotator');

/**
 * Returns a proxy URL for the given country code.
 * Sticky mode keeps the same IP for the whole test run.
 */
async function getProxy(country) {
  const url = await proxyRotator.get({
    type: 'residential',
    country,
    sticky: true,
    ttlSeconds: 300,
  });
  return url;
}

module.exports = { getProxy };

3. Screenshot capture with dynamic‑content suppression

// tests/visual.spec.js
const { test, expect } = require('@playwright/test');
const fs = require('fs');
const path = require('path');
const { PNG } = require('pngjs');
const pixelmatch = require('pixelmatch');
const { getProxy } = require('../lib/proxy');

const REGIONS = ['us', 'de', 'jp', 'br', 'au'];
const BASELINE_DIR = path.resolve(__dirname, '../baselines');
const ARTIFACT_NL = path.resolve(__dirname, '../artifacts');

async function compareImages(baselinePath, currentPath) {
  const [baseImg, curImg] = await Promise.all([
    PNG.sync.read(fs.readFileSync(baselinePath)),
    PNG.sync.read(fs.readFileSync(currentPath)),
  ]);
  const diff = new PNG({ width: baseImg.width, height: baseImg.height });
  const mismatched = pixelmatch(
    baseImg.data,
    curImg.data,
    diff.data,
    baseImg.width,
    baseImg.height,
    { threshold: 0.01, includeAA: true }
  );
  return mismatched;
}

test.describe('Visual Regression', () => {
  for (const region of REGIONS) {
    test(`visual regression – ${region.toUpperCase()}`, async ({ page }) => {
      const proxyUrl = await getProxy(region);
      await page.setProxy({ server: proxyUrl });

      // Suppress flaky dynamic elements
      await page.addStyleTag({
        content: `
          [data-testid="dynamic-timestamp"] { visibility: hidden; }
          .ab-variant-badge { display: none; }
        `,
      });

      await page.goto('https://example.com/pricing', {
        waitUntil: 'networkidle',
        timeout: 60_000,
      });

      await page.waitForFunction(() => document.fonts.ready.then(() => true));

      const currentPath = path.join(ARTIFACT_NL, `pricing-${region}.png`);
      const baselinePath = path.join(BASELINE_DIR, `pricing-${region}.png`);

      await page.screenshot({ path: currentPath, fullPage: true });

      if (!fs.existsSync(baselinePath)) {
        fs.copyFileSync(currentPath, baselinePath);
        console.log(`Baseline created for ${region}`);
        return;
      }

      const diffPixels = await compareImages(baselinePath, currentPath);
      expect(diffPixels).toBeLessThanOrEqual(200);
    });
  }
});

4. CI integration with GitHub Actions

# .github/workflows/visual-regression.yml
name: Visual Regression
on:
  push:
    branches: [main]
  schedule:
    - cron: '0 3 * * *'
jobs:
  visual:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test tests/visual.spec.js
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: artifacts-${{ github.run_id }}
          path: artifacts/**
          retention-days: 7

Handling flaky dynamic content

Source Mitigation
Timestamp / date widgets Hide via CSS (visibility: hidden) or mock the clock with page.clock.install({ now: new Date('2024-01-01') }).
A/B test banners Add a test‑only query param (?ab=control) or send a feature‑flag header (x-ab-variant: control).
Third‑party ads / consent dialogs Block request domains via page.route('**/*.doubleclick.net/**', route => route.abort()).
Font loading races await page.waitForLoadState('networkidle') + await page.waitForFunction(() => document.fonts.ready.then(() => true)).
Lazy‑loaded images Scroll to bottom before screenshot: await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)).

Baseline promotion workflow

  1. Versioned baselines stored under baselines/<git-sha>/.
  2. When a PR passes on all regions, a GitHub Action copies the new screenshots to the main baseline folder.
  3. For large diffs, surface a side‑by‑side view in the PR using the pixelmatch output rendered as HTML.

Cost‑control tips

  • Session reuse – use sticky sessions so you pay for one IP per region instead of one per page.
  • Parallelism limit – run at most 3 concurrent regions to stay within typical residential‑proxy bandwidth caps.
  • Cache baselines – they never change unless you intentionally promote them, so no extra proxy traffic.
  • Monitor success rates – pause regions whose success rate dips below 95% and investigate the cause before rerunning.

Extending to mobile viewports

const DEVICES = [
  { name: 'desktop', viewport: { width: 1366, height: 768 } },
  { name: 'mobile', viewport: { width: 390, height: 844 }, userAgent: 'iPhone Safari' },
];

for (const region of REGIONS) {
  for (const device of DEVICES) {
    test(`visual – ${region}/${device.name}`, async ({ page }) => {
      const proxyUrl = await getProxy(region);
      await page.setProxy({ server: proxyUrl });
      await page.setViewportSize(device.viewport);
      if (device.userAgent) await page.setUserAgent(device.userAgent);
      // … same capture/compare logic …
    });
  }
}

Troubleshooting checklist

  1. Proxy auth failures (407) – verify username:password encoding; special characters must be URL‑encoded.
  2. IP not matching target country – confirm ASN and geo before the run, then rerun.
  3. Screenshot timeout – increase navigationTimeout and ensure waitUntil: 'networkidle' isn't stuck on a long‑polling endpoint; abort those routes explicitly.
  4. Diff noise above threshold – run the test locally with headless: false to visually inspect the captured page.
  5. Rate‑limited by target site – swap to a fresh proxy when you receive 429/5xx responses.

TL;DR

You walk away with a repeatable, CI‑ready pipeline that captures full‑page screenshots from real residential IPs in any country. The setup reuses sticky sessions, suppresses flaky UI elements, and produces pixel‑level diffs against versioned baselines. Start small — two regions, one critical page, nightly schedule — then expand the matrix and add mobile viewports once the baseline is stable.