BackstopJS Configuration Guide: Scenarios, Viewports, and Advanced Options

BackstopJS Configuration Guide: Scenarios, Viewports, and Advanced Options

A working BackstopJS setup is mostly a well-crafted backstop.json. The default generated by backstop init gets you started, but real applications need more: authenticated pages, dynamic content you want to hide, interactions before capture, and engine tuning. This guide covers every significant configuration option with practical examples.

The Top-Level Structure

{
  "id": "my_project",
  "viewports": [...],
  "scenarios": [...],
  "paths": {...},
  "report": ["browser", "CI"],
  "engine": "puppeteer",
  "engineOptions": {...},
  "asyncCaptureLimit": 5,
  "asyncCompareLimit": 50,
  "debug": false
}

The id field is critical — it is prepended to every reference bitmap filename. Changing id after you have baselines effectively discards all existing references, because the new filenames will not match the old ones. Treat it like a database name: set it once per environment and leave it.

Viewports

Define every meaningful breakpoint your app supports:

"viewports": [
  { "label": "mobile_sm", "width": 375,  "height": 812 },
  { "label": "mobile_lg", "width": 428,  "height": 926 },
  { "label": "tablet",    "width": 768,  "height": 1024 },
  { "label": "desktop",   "width": 1280, "height": 800 },
  { "label": "wide",      "width": 1920, "height": 1080 }
]

Every scenario is tested at every viewport unless the scenario overrides the list. Five viewports × twenty scenarios = one hundred screenshots per run. Keep your scenario count lean and use --filter during development.

Per-Scenario Viewport Override

If a specific scenario only matters on mobile:

{
  "label": "Mobile Nav Menu",
  "url": "https://example.com/",
  "viewports": [
    { "label": "mobile", "width": 375, "height": 812 }
  ]
}

This replaces the global viewport list for that one scenario.

Scenario Fields

Basic URL and Label

{
  "label": "Pricing Page",
  "url": "https://example.com/pricing"
}

label appears in the HTML report and is used by --filter. Use clear, unique names.

referenceUrl

Test against one environment but use a different URL for the reference baseline:

{
  "label": "Homepage",
  "url": "https://staging.example.com/",
  "referenceUrl": "https://production.example.com/"
}

Useful for visual diff between staging and production before a release.

delay

Wait a fixed number of milliseconds after page load before capturing:

{ "delay": 1000 }

Use this as a last resort. Prefer readySelector or waitForSelector (via onReadyScript) for more reliable waits. Fixed delays make your test suite slow and still fail when the app is under load.

misMatchThreshold

The maximum percentage of pixels allowed to differ before the scenario fails:

{ "misMatchThreshold": 0.5 }

Default is 0.1 (0.1%). For pages with user avatars, ads, or timestamps you cannot hide, you may need to raise this or use hideSelectors instead.

selector

Capture only a specific element instead of the full page:

{
  "label": "Header Component",
  "url": "https://example.com/",
  "selector": "#site-header"
}

Component-level tests are faster and produce smaller diffs. When a layout regression is isolated to the header, you want the test that catches it to be about the header — not a full-page diff where the change is hard to spot.

hideSelectors

CSS selectors for elements that should be hidden (set to visibility: hidden) before capture. Use this for content that changes on every render: timestamps, user-specific greetings, ads, cookie banners:

{
  "hideSelectors": [
    ".cookie-banner",
    "[data-testid='last-updated']",
    "#ad-slot-top",
    ".user-avatar"
  ]
}

The elements remain in the DOM and still affect layout — they just become invisible. This is the right choice when you care about layout but not content.

removeSelectors

Similar to hideSelectors but removes the element from the DOM entirely with display: none. Use this when the element affects layout and you want it gone:

{
  "removeSelectors": [
    ".live-chat-widget",
    "#intercom-container"
  ]
}

clickSelector

Click an element before capturing. Used to open dropdowns, modals, or tooltips:

{
  "label": "Dropdown Open",
  "url": "https://example.com/dashboard",
  "clickSelector": "#user-menu-trigger"
}

BackstopJS waits for the click to complete, then captures. For more complex interactions (multiple clicks, form fills, keyboard input), use onReadyScript.

hoverSelector

Hover over an element to capture hover states:

{
  "label": "Button Hover",
  "url": "https://example.com/",
  "hoverSelector": ".cta-button"
}

scrollToSelector

Scroll to an element before capturing. Useful for below-the-fold content:

{
  "label": "Footer",
  "url": "https://example.com/",
  "scrollToSelector": "footer"
}

onReadyScript

The most powerful scenario option. Points to a JavaScript file that runs inside the Puppeteer/Playwright context after the page loads, before the screenshot is taken:

{
  "label": "Dashboard (Authenticated)",
  "url": "https://example.com/dashboard",
  "onReadyScript": "puppet/loginAndWait.js"
}

The script receives a page object (Puppeteer Page):

// backstop_data/engine_scripts/puppet/loginAndWait.js
module.exports = async (page, scenario, vp) => {
  // Fill login form
  await page.goto('https://example.com/login');
  await page.type('#email', 'test@example.com');
  await page.type('#password', 'testpassword');
  await page.click('[type="submit"]');
  await page.waitForNavigation();

  // Navigate to the scenario URL
  await page.goto(scenario.url);

  // Wait for a key element to confirm page is ready
  await page.waitForSelector('.dashboard-widget', { timeout: 5000 });
};

For Playwright engine, the signature is (page, scenario, vp) with Playwright's Page API.

onBeforeScript

Runs before the page is loaded. Used for setting cookies, localStorage values, or auth tokens:

"onBeforeScript": "puppet/setAuthCookie.js"
// backstop_data/engine_scripts/puppet/setAuthCookie.js
module.exports = async (page, scenario, vp) => {
  await page.setCookie({
    name: 'auth_token',
    value: process.env.TEST_AUTH_TOKEN,
    domain: 'example.com'
  });
};

Using environment variables for credentials keeps them out of your committed config.

Engine Options

Puppeteer (default)

"engineOptions": {
  "args": [
    "--no-sandbox",
    "--disable-setuid-sandbox",
    "--disable-dev-shm-usage"
  ],
  "executablePath": "/usr/bin/chromium-browser"
}

--no-sandbox is required in most CI and Docker environments. --disable-dev-shm-usage prevents crashes when /dev/shm is too small (common in Docker).

Playwright

"engine": "playwright",
"engineOptions": {
  "browser": "firefox",
  "args": []
}

Supported values for browser: chromium, firefox, webkit. Multi-browser testing is one of the main reasons to switch from Puppeteer to Playwright.

asyncCaptureLimit and asyncCompareLimit

"asyncCaptureLimit": 5,
"asyncCompareLimit": 50

asyncCaptureLimit controls how many browser instances run in parallel during screenshot capture. Higher values are faster but consume more memory. On a 2-CPU CI runner with 4GB RAM, 3–5 is safe. On a developer machine, 8–10 is fine.

asyncCompareLimit controls parallel image comparisons. These are CPU-bound. Setting it to the number of CPU cores is a good starting point.

Paths

"paths": {
  "bitmaps_reference": "backstop_data/bitmaps_reference",
  "bitmaps_test":      "backstop_data/bitmaps_test",
  "engine_scripts":    "backstop_data/engine_scripts",
  "html_report":       "backstop_data/html_report",
  "ci_report":         "backstop_data/ci_report"
}

You can move these anywhere. A common pattern is putting bitmaps_reference under version control (committed) and bitmaps_test in .gitignore since test output is ephemeral.

Practical Config for a Real App

Combining all the above into a production-ready configuration:

{
  "id": "acme_app_staging",
  "viewports": [
    { "label": "mobile",  "width": 375,  "height": 812 },
    { "label": "desktop", "width": 1280, "height": 800 }
  ],
  "scenarios": [
    {
      "label": "Homepage",
      "url": "https://staging.acme.com/",
      "hideSelectors": [".cookie-notice", ".live-chat"],
      "delay": 500,
      "misMatchThreshold": 0.2
    },
    {
      "label": "Pricing",
      "url": "https://staging.acme.com/pricing",
      "hideSelectors": [".cookie-notice"],
      "misMatchThreshold": 0.1
    },
    {
      "label": "Dashboard",
      "url": "https://staging.acme.com/dashboard",
      "onBeforeScript": "puppet/setAuthCookie.js",
      "onReadyScript": "puppet/waitForDashboard.js",
      "hideSelectors": ["[data-testid='last-login']"],
      "misMatchThreshold": 0.3
    }
  ],
  "paths": {
    "bitmaps_reference": "backstop_data/bitmaps_reference",
    "bitmaps_test":      "backstop_data/bitmaps_test",
    "engine_scripts":    "backstop_data/engine_scripts",
    "html_report":       "backstop_data/html_report",
    "ci_report":         "backstop_data/ci_report"
  },
  "report": ["CI"],
  "engine": "puppeteer",
  "engineOptions": {
    "args": ["--no-sandbox", "--disable-dev-shm-usage"]
  },
  "asyncCaptureLimit": 5,
  "asyncCompareLimit": 50,
  "debug": false
}

Note "report": ["CI"] — skipping the browser report in CI avoids opening a browser window and keeps output clean for log parsers.

Keeping Configuration Maintainable

As your suite grows, the scenario list in backstop.json becomes hard to manage. A common pattern is generating the config programmatically:

// backstop.config.js
const scenarios = require('./backstop_scenarios');

module.exports = {
  id: 'acme_app',
  viewports: [...],
  scenarios: scenarios.map(s => ({
    misMatchThreshold: 0.1,
    hideSelectors: ['.cookie-notice'],
    ...s
  })),
  // ...rest of config
};

Run with: npx backstop test --config=backstop.config.js

This lets you apply defaults across all scenarios and split scenario definitions into separate files per page section.

Visual regression is one layer of your quality safety net. BackstopJS tells you when something looks different; HelpMeTest covers whether it still works — clicks that should navigate, forms that should submit, states that should persist. The two tools answer different questions and are strongest when run together in your CI pipeline.

Read more

Start now free