Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Wait for the state your screenshot actually needs—not merely for the browser’s readyState. In Ruby, use a Capybara matcher or Selenium explicit wait that observes an application-level signal such as data-ready="true", expected text, or a component-defined completion event. Then capture the page. If you only need the browser to register a custom-element class, JavaScript’s customElements.whenDefined() is appropriate, but it does not prove that the component has finished rendering or loading data.

Why navigation completion is not custom-element readiness

WebDriver navigation can finish while JavaScript is still fetching data, upgrading elements, inserting shadow DOM, loading images, or running animations. The Selenium Project describes this distinction: readyState covers assets declared in the HTML, while loaded JavaScript can continue changing the page afterward.

A custom element has several different milestones:

  • Element present: the tag appears in the DOM.
  • Definition registered: the browser has associated the tag name with a class.
  • Connected: the element’s lifecycle callbacks can run.
  • Application ready: data, child content, images, and visual state required by your screenshot are complete.

Only the last milestone is a reliable screenshot boundary, and its signal is application-specific. A selector such as my-widget[data-ready="true"] below is illustrative; use a real contract exposed by the page.

Choose a readiness signal you can observe

Preferred: a semantic ready attribute

Ask the component to expose a stable attribute, for example <my-widget data-ready="true">. This is easy for Capybara and Selenium to test and remains meaningful if the component’s internal implementation changes.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
#1 Best Overall

Expected text or child content

If you cannot change the component, wait for text that only appears after the data request succeeds, or for a child selector that is inserted as the final rendering step. Avoid waiting for a generic tag presence when the tag is created before its content arrives.

Definition registration

customElements.whenDefined("my-widget") resolves when the registry defines the name. It is useful when registration itself is the requirement, but it does not wait for asynchronous fetching, images, animations, or component-specific rendering.

Completion event

A component can dispatch a custom event such as widget-ready. Waiting for that event is precise when the component author documents that it fires only after all screenshot-critical work is complete.

Capybara: wait with a retrying matcher

Capybara automatically retries asynchronous finders and matchers until its configured wait period expires. The documented default for Capybara.default_max_wait_time is two seconds, but a project can change it.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  1. Visit the page with the JavaScript-capable driver configured by your test suite.
  2. Assert the application’s ready condition with a waiting matcher.
  3. Save the screenshot only after the matcher succeeds.
require "capybara/dsl"

Capybara.default_max_wait_time = 10

visit("https://example.test/dashboard")

# Replace this selector with the page's documented readiness signal.
expect(page).to have_css('my-widget[data-ready="true"]')

page.save_screenshot("dashboard.png", full: true)

The matcher retries until the element exists in the required state or the wait expires. A successful have_css("my-widget") only proves that the host element is present; it is not evidence that its data or visual output is complete.

Waiting for absence

For a loading mask or skeleton that must disappear, use Capybara’s waiting negative matcher rather than negating an immediate predicate:

expect(page).to have_no_css("my-widget .loading-skeleton")
expect(page).to have_css('my-widget[data-ready="true"]')
page.save_screenshot("dashboard.png")

Using both a positive and negative condition can prevent a screenshot from being taken during a transition where the ready marker appears before the old loading UI is removed.

Configure the timeout narrowly

Set a timeout based on the slowest legitimate response in your environment, not a universal number. You can configure the global default or pass a matcher-specific wait option where supported by your Capybara version. Keep the wait finite so a broken endpoint fails the test instead of hanging indefinitely.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Selenium WebDriver from Ruby: an explicit condition

Selenium’s Ruby binding exposes explicit waits, but exact method signatures can vary by installed selenium-webdriver version. Check the API for the version in your Gemfile. The principle is stable: create a wait, poll an observable condition, and capture after it returns true.

require "selenium-webdriver"

options = Selenium::WebDriver::Chrome::Options.new
options.add_argument("--headless=new")

driver = Selenium::WebDriver.for(:chrome, options: options)
begin
  driver.navigate.to("https://example.test/dashboard")

  wait = Selenium::WebDriver::Wait.new(timeout: 15, interval: 0.2)
  wait.until do
    element = driver.find_element(css: 'my-widget[data-ready="true"]')
    element.displayed?
  rescue Selenium::WebDriver::Error::NoSuchElementError,
         Selenium::WebDriver::Error::StaleElementReferenceError
    false
  end

  # Optional second guard for a loading layer that must be gone.
  wait.until do
    driver.find_elements(css: "my-widget .loading-skeleton").none?(&:displayed?)
  end

  driver.save_screenshot("dashboard.png")
ensure
  driver.quit
end

The rescue is important because a framework may replace the element while rendering; that transient stale reference should cause another poll, not an immediate failure. If your driver or Selenium version uses different wait APIs, retain the same condition and polling behavior while adjusting syntax to that version.

Waiting for text

wait.until do
  text = driver.find_element(css: "my-widget").text
  text.include?("Account loaded")
rescue Selenium::WebDriver::Error::NoSuchElementError,
       Selenium::WebDriver::Error::StaleElementReferenceError
  false
end

Text is a practical fallback, but choose a phrase that cannot appear in a placeholder, error state, or previous result.

Use whenDefined when registration is the actual requirement

Run this JavaScript in the page when you need to know that the browser has registered one custom-element name:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
await customElements.whenDefined("my-widget");

The promise resolves at definition time. Because a component can continue work in connectedCallback() and later callbacks, follow it with an application condition before capturing. For example, a Selenium script can execute:

driver.execute_async_script(<<~JS)
  const done = arguments[arguments.length - 1];
  customElements.whenDefined("my-widget").then(() => done(true));
JS

wait.until do
  driver.find_element(css: 'my-widget[data-ready="true"]')
  true
rescue Selenium::WebDriver::Error::NoSuchElementError
  false
end

driver.save_screenshot("dashboard.png")

If a container can contain several undefined custom-element tags, collect distinct local names and await all definitions:

const host = document.querySelector("#app");
const names = [...host.querySelectorAll("*")]
  .map(el => el.localName)
  .filter(name => name.includes("-"));
await Promise.all(
  [...new Set(names)].map(name => customElements.whenDefined(name))
);

This waits for registration only. Add the component’s ready marker, content check, or completion event afterward.

Capturing components with shadow DOM

A host selector may be present while the useful content lives in an open shadow root. If your readiness marker is inside that root, evaluate it directly:

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
wait.until do
  driver.execute_script(<<~JS)
    const host = document.querySelector("my-widget");
    host && host.shadowRoot &&
      host.shadowRoot.querySelector("[data-ready='true']") !== null
  JS
end

Closed shadow roots cannot be inspected through ordinary page JavaScript. In that case, wait for a host-level attribute or event provided by the component rather than trying to pierce the boundary.

Make the screenshot deterministic

  • Disable or finish animations when the page supports a test mode; otherwise two captures can differ even after data is ready.
  • Wait for images that affect the composition, not merely the custom element. A ready flag should be set after those images have loaded if they are part of the contract.
  • Use a stable viewport, device scale factor, locale, timezone, and test data so layout changes do not masquerade as timing failures.
  • Capture once per readiness condition. Repeated screenshots do not make an incorrect condition more reliable.
  • Log the elapsed wait and the condition that succeeded. This helps distinguish a slow but healthy page from a component that never signals readiness.

Troubleshooting common failures

Timeout while the element is visible

The selector may target a state the application never sets, or the page may be using a different attribute value. Inspect the DOM after timeout, verify spelling and case, and confirm that the test driver actually runs JavaScript.

Screenshot shows a skeleton or empty card

You waited for host presence rather than content readiness. Replace the presence check with a ready attribute, final text, child selector, or documented completion event.

whenDefined resolves but the screenshot is still incomplete

That is expected: registration and rendering are separate milestones. Keep whenDefined as a preliminary wait and add an application-specific condition.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Intermittent stale-element errors

Framework re-rendering replaced the node between polls. Locate the element inside the wait block each time and retry stale references, as in the Selenium example.

Negative assertion passes too early

An immediate negated predicate can succeed before the loading element has ever been inserted. Use Capybara’s waiting have_no_css or an explicit Selenium condition that observes the intended transition.

Headless and headed captures differ

Compare viewport dimensions, device scale, fonts, browser version, and reduced-motion settings. A timing fix cannot correct a different rendering environment.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

ScreenshotNeo provides a single HTTP request for a URL and supports waits for a selector, delay, or network idle, along with custom JavaScript when your page exposes a readiness condition. It can remove cookie/consent banners, newsletter popups, and chat widgets before capture. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed; the response reports the result in X-Page-Verdict and X-Billed headers. An MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
curl -G "https://api.screenshotneo.com/v1/shot" 
  -d access_key=YOUR_API_KEY 
  --data-urlencode url=https://example.test/dashboard 
  -o dashboard.webp

See the ScreenshotNeo documentation for the wait and custom-script parameters. The same request in Python:

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://example.test/dashboard"},
    timeout=90,
)
r.raise_for_status()
open("dashboard.webp", "wb").write(r.content)

And Node.js:

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.test/dashboard' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('dashboard.webp', Buffer.from(await res.arrayBuffer()));

ScreenshotNeo has a free plan with 1,000 screenshots per month and no card; paid plans start at $5 for 3,000 screenshots. Create a free ScreenshotNeo account to try the API.

Cost, reliability, and maintenance decisions

Local Capybara or Selenium gives maximum control and is appropriate when the screenshot is part of an end-to-end test running beside your application. It also requires browser binaries, driver maintenance, fonts, network access, and a repeatable rendering environment.

An API is useful for scheduled captures, documentation, previews, and CI jobs that should not manage browsers. Use a readiness selector or script that is stable across deployments, set a finite request timeout, and inspect response headers so a failed or blocked page is distinguishable from a valid capture. Caching can reduce repeated work when the page is unchanged; disable or shorten the cache TTL when validating live state.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Ruby checklist

  1. Define what “ready” means for this component.
  2. Prefer a semantic attribute, final content, or documented event over tag presence.
  3. Use Capybara’s retrying matcher or a Selenium explicit wait.
  4. Use whenDefined only for definition registration, then apply the app-level check.
  5. Account for shadow DOM, images, loading masks, and animations.
  6. Capture immediately after the condition succeeds and retain timeout diagnostics.

Frequently Asked Questions

Can I wait for every custom element on a page with one generic Ruby command?

No. You can await registration for known tag names with JavaScript, but there is no universal signal that every component has finished fetching data and rendering. Each application needs a readiness contract.

Does increasing Capybara’s timeout guarantee a correct screenshot?

No. A longer timeout only gives the existing condition more time to succeed. If the condition checks host presence instead of completed content, the screenshot can still be early.

Which approach is better for CI: Capybara or Selenium?

Use the framework already driving your tests. Capybara offers concise retrying matchers and screenshot support; Selenium exposes lower-level explicit conditions. The readiness signal matters more than the choice.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.