How to Wait for a Custom Element Before Capturing a Page in C#
Wait for a custom element to register, render its data, and become screenshot-ready with reliable Playwright and Selenium C# patterns.
Direct answer: wait in layers. First locate the custom-element host, then require it to be attached (or visible), wait for customElements.whenDefined(), and finally wait for an application-owned readiness signal such as data-ready="true", a populated shadow-root node, or a loading marker disappearing. DOMContentLoaded alone does not prove that a Web Component has finished rendering.
The final condition must come from the component’s contract. A tag can exist before its class is registered, and a registered component can still be fetching data, loading images, or updating its shadow DOM.
1. Define what “ready” means
Before writing the screenshot code, identify a signal that means the component is complete:
data-ready="true"or another explicit state attribute.- A loading attribute or spinner is removed.
- A required shadow-DOM element exists and contains content.
- A public component state changes to a documented loaded value.
Do not use visibility as the only readiness check. A visible host may still contain a skeleton, an empty shadow root, or stale asynchronous data.
2. Playwright for .NET
Playwright provides locator states such as Attached, Visible, Hidden, and Detached. Its custom-condition wait retries the locator and can await a JavaScript promise. See the Playwright .NET locator documentation.
Complete example with an explicit ready attribute
using Microsoft.Playwright;
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true
});
var page = await browser.NewPageAsync(new BrowserNewPageOptions
{
ViewportSize = new ViewportSize { Width = 1440, Height = 1000 }
});
const string url = "https://example.com/dashboard";
await page.GotoAsync(url, new PageGotoOptions
{
WaitUntil = WaitUntilState.DOMContentLoaded,
Timeout = 30_000
});
var component = page.Locator("my-element");
await component.WaitForAsync(new LocatorWaitForOptions
{
State = WaitForSelectorState.Attached,
Timeout = 30_000
});
await component.WaitForFunctionAsync(@"async el => {
await customElements.whenDefined('my-element');
return el.getAttribute('data-ready') === 'true';
}", null, new LocatorWaitForFunctionOptions
{
Timeout = 30_000
});
await page.ScreenshotAsync(new PageScreenshotOptions
{
Path = "page.png",
FullPage = true
});
The first wait distinguishes “the host is in the DOM” from “the host exists.” The predicate then waits for the element definition and checks the component’s own readiness state.
Visible host instead of merely attached
await component.WaitForAsync(new LocatorWaitForOptions
{
State = WaitForSelectorState.Visible,
Timeout = 30_000
});
Use Visible when the component must be displayed for its layout, lazy loading, or intersection-based rendering to run. Use Attached when it can render while hidden or when visibility is not part of the contract.
When there is no ready attribute
Tie the wait to a public behavior. This example waits for a non-empty shadow-root result:
await component.WaitForFunctionAsync(@"async el => {
await customElements.whenDefined('my-element');
const content = el.shadowRoot?.querySelector('[data-content]');
return content !== null && content.textContent?.trim().length > 0;
}", null, new LocatorWaitForFunctionOptions
{
Timeout = 30_000
});
If the component exposes a loading marker, wait for it to disappear instead:
await component.WaitForFunctionAsync(@"async el => {
await customElements.whenDefined('my-element');
return el.shadowRoot?.querySelector('[data-loading]') === null;
}", null, new LocatorWaitForFunctionOptions
{
Timeout = 30_000
});
Capture a specific element
await component.ScreenshotAsync(new LocatorScreenshotOptions
{
Path = "component.png"
});
Use a locator screenshot when the page contains unrelated content or when the component itself is the deliverable. Use FullPage = true on Page.ScreenshotAsync for the complete document.
3. Selenium for .NET
Selenium’s WebDriverWait accepts an arbitrary condition, which makes it suitable for combining element lookup, custom-element registration, and an application readiness signal. See Selenium’s official waits guidance.
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using OpenQA.Selenium.Support.UI;
using var driver = new ChromeDriver(new ChromeOptions
{
PageLoadStrategy = PageLoadStrategy.Normal
});
driver.Manage().Timeouts().PageLoad = TimeSpan.FromSeconds(30);
driver.Navigate().GoToUrl("https://example.com/dashboard");
var wait = new WebDriverWait(driver, TimeSpan.FromSeconds(30));
wait.Until(d => ((IJavaScriptExecutor)d).ExecuteScript(@"
const el = document.querySelector('my-element');
if (!el) return false;
return customElements.whenDefined('my-element').then(() =>
el.getAttribute('data-ready') === 'true');
"));
((ITakesScreenshot)driver)
.GetScreenshot()
.SaveAsFile("page.png");
The JavaScript promise must resolve to a truthy value. Adapt the final expression to the component’s actual contract. If the host can be removed and recreated, query it inside every retry, as shown, instead of keeping a stale element reference.
4. Why fixed sleeps fail
Task.Delay, Thread.Sleep, and browser timeout waits guess how long rendering will take. They either capture too early on a slow run or waste time on a fast run. Playwright explicitly advises: “Never wait for timeout in production.” Use selector states, web assertions, and component-owned signals instead.
5. Timeouts and diagnostics
Every wait needs a finite timeout. When it expires, report the URL, tag name, and condition that failed. Separate these cases:
| Symptom | Likely cause | What to inspect |
|---|---|---|
| Host never appears | Wrong selector, route, or conditional rendering | DOM, URL, console errors |
Host appears but whenDefined never resolves |
Component JavaScript failed or the tag name is wrong | Network and console logs; customElements.get('my-element') |
| Definition resolves but readiness times out | Data request failed or the ready signal is never set | API responses, loading state, application logs |
| Element disappears during the wait | Framework replaced the host | Re-query the locator on each retry |
| Screenshot is clipped or blank | Capture happened before layout, fonts, or images settled | Use the component signal, then verify viewport and full-page options |
Playwright timeout report
try
{
await component.WaitForFunctionAsync(@"async el => {
await customElements.whenDefined('my-element');
return el.getAttribute('data-ready') === 'true';
}", null, new LocatorWaitForFunctionOptions { Timeout = 30_000 });
}
catch (TimeoutException ex)
{
throw new InvalidOperationException(
$"Custom element my-element was not ready at {url}. " +
"Expected data-ready= true.", ex);
}
6. Reliability checklist
- Navigate with an explicit page-load timeout.
- Wait for
AttachedorVisibleaccording to the component’s behavior. - Await
customElements.whenDefined(). - Use an application-owned ready signal.
- Re-query hosts that frameworks may replace.
- Capture only after required data, fonts, and images are ready.
- Save diagnostic HTML, console errors, and network failures when a wait times out.
- Keep the timeout finite and include the condition in the error.
7. Performance and cost considerations
Condition-based waits usually reduce capture time because they finish as soon as the component is ready. Keep the readiness predicate cheap: inspect an attribute or one shadow-DOM node rather than repeatedly traversing a large tree. Reuse a browser process for multiple URLs when possible, while creating an isolated page or context for each job. Set realistic timeouts for the slowest expected API call and avoid adding a large fixed delay “just in case.”
For high-volume capture, record wait duration separately from navigation time. A rising wait duration often indicates a slow backend or a component regression rather than a screenshot problem.
8. Or skip the browser setup
ScreenshotNeo provides a website screenshot API and MCP server. A GET request returns PNG, JPEG, WebP, or PDF. The API accepts the URL directly, so there is no Selenium or Playwright process to maintain:
cURL
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
Node.js
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));
See the ScreenshotNeo documentation for request options. Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers identify the page verdict and billing result. Its MCP server provides take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 screenshots.
Create a free ScreenshotNeo account and start with 1,000 screenshots per month at no charge.
9. FAQ
Does DOMContentLoaded wait for a custom element?
No. JavaScript can define or update the element after the document reaches that state.
Should I always wait for visibility?
No. Choose visibility when layout or intersection behavior requires it; otherwise attached may be sufficient.
What if the component has no documented ready state?
Use a stable public behavior, such as a required shadow-root node becoming populated or a loading marker disappearing. Avoid guessing from elapsed time.
Can a custom element be registered but still incomplete?
Yes. Registration only means its class is defined. Data fetching and rendering can continue afterward.
How long should the timeout be?
Set it above the slowest legitimate data load for your environment, keep it finite, and report the failed condition when it expires.


