If you have ever watched a browser automation run perfectly 9 times and then fail on the 10th with a random timeout, you already know the truth.
Most “flaky automation” is not flaky. It is impatient.
The most common cause is simple: we wait for time, not for state.
In this article, I will use a fictional but realistic example: an internal web app called ExpenseFlow, where employees submit expense claims. The UI is modern and dynamic; it has spinners, toasts, async validation, and occasional slow API calls. It is exactly the kind of app that makes naive automation crumble.
We will look at the waiting mistakes that create flakiness, and then we will fix them with Playwright in .NET using a small set of patterns you can reuse everywhere.
The Example App: ExpenseFlow
The flow we want to automate:
- Sign in
- Create a new expense claim
- Fill amount, category, date, description
- Upload a receipt file
- Submit
- Confirm the success toast and the claim number
Where it goes wrong:
- Sometimes, the category dropdown loads slowly
- The submit button is disabled until async validation finishes
- A spinner overlay blocks clicks
- A toast appears briefly, then disappears
- File upload triggers server-side processing that can take 1 to 8 seconds
If your automation uses sleeps, it will pass on your machine and fail in CI.
The classic anti-patterns that cause flakiness.
Anti-pattern 1: Sleeping after every action
await page.ClickAsync("#new-claim");
await Task.Delay(1000);
await page.FillAsync("#amount", "42.10");
await Task.Delay(1000);
await page.ClickAsync("#submit");
await Task.Delay(2000);
This “works” until it does not.
- If the UI is slower than your delay, you fail.
- If the UI is faster, you waste time.
- If the UI timing changes, you regress.
- If CI is slower, you fail more.
Anti-pattern 2: Waiting for the wrong thing
A common one is waiting for the load state on a single-page app:
await page.ClickAsync("#submit");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
Many SPAs never reach “network idle” in a meaningful way. Some keep polling. Some open websockets. Some trigger background requests.
So, you end up waiting for a state that has nothing to do with your next action.
Anti-pattern 3: Waiting for selectors that exist but are not usable
await page.WaitForSelectorAsync("#submit");
await page.ClickAsync("#submit");
The element can “exist” while:
- it is covered by a spinner overlay
- it is offscreen
- it is disabled
- it is mid animation
- it is detached and reattached
The Fix: Wait for state, not time.
You want to wait for meaningful UI states:
- A button is visible and enabled
- A spinner overlay is gone
- A toast is visible (and optionally contains text)
- A route change happened (URL, title, or key element)
- A form validation is completed (submit enabled)
Playwright already does a lot of waiting for you, but you still need to choose the right signals.
Step 1: Use locators that describe intent.
Avoid brittle CSS selectors. Prefer roles and labels.
using Microsoft.Playwright;
using Microsoft.Playwright.Assertions;
var browser = await Playwright.CreateAsync();
await using var chromium = await browser.Chromium.LaunchAsync(new() { Headless = true });
var page = await chromium.NewPageAsync();
await page.GotoAsync("https://expenseflow.example/login");
await page.GetByLabel("Email").FillAsync("[email protected]");
await page.GetByLabel("Password").FillAsync("correct-horse-battery-staple");
await page.GetByRole(AriaRole.Button, new() { Name = "Sign in" }).ClickAsync();
Why this helps:
- roles and labels survive minor HTML refactors
- locators auto-wait for actionability more reliably than raw selectors
- your code reads like a user journey
Step 2: Make “spinner is gone” a first-class wait.
Most flaky clicks are not bad selectors. They are clicks that happened while an overlay was blocking the page.
Create a helper that waits for the overlay to disappear:
static async Task WaitForOverlayToClearAsync(IPage page)
{
// Adjust these selectors to match your app.
// You want the overlay element that blocks input.
var overlay = page.Locator(".loading-overlay, .spinner-overlay, [data-testid='loading-overlay']");
if (await overlay.CountAsync() > 0)
{
await overlay.First.WaitForAsync(new() { State = WaitForSelectorState.Hidden });
}
}
Then call it before and after actions that trigger async work:
await WaitForOverlayToClearAsync(page);
await page.GetByRole(AriaRole.Button, new() { Name = "New claim" }).ClickAsync();
await WaitForOverlayToClearAsync(page);
This one change can remove a shocking amount of flakiness.
Step 3: Wait for “enabled” before clicking submit.
In ExpenseFlow, the submit button is disabled until server validation returns.
Instead of sleeping, wait for it to become enabled.
var submit = page.GetByRole(AriaRole.Button, new() { Name = "Submit claim" });
await Expect(submit).ToBeVisibleAsync();
await Expect(submit).ToBeEnabledAsync();
await submit.ClickAsync();
This is the core idea: wait for the thing that matters.
Step 4: Wait for a success signal that you can assert.
After submission, a toast appears: “Claim submitted: CLM-10492.”
Toasts are easy to miss if you wait too long or too loosely.
var toast = page.Locator("[role='status'], .toast-success, [data-testid='toast-success']");
await Expect(toast).ToBeVisibleAsync(new() { Timeout = 10_000 });
await Expect(toast).ToContainTextAsync("Claim submitted");
If you need the claim number:
var toastText = await toast.InnerTextAsync();
Console.WriteLine($"Success toast: {toastText}");
If the toast disappears quickly, use a locator that finds it fast, and assert early.
Step 5: Stop using “WaitForTimeout” as glue.
If you are thinking, “I only need 500ms here”, that is a smell.
Replace it with a real condition:
Instead of: “wait 1s for dropdown options.”
await page.ClickAsync("#category");
await Task.Delay(1000);
await page.ClickAsync("text=Travel");
Do: “wait for the option to exist and be clickable.”
var category = page.GetByLabel("Category");
await category.ClickAsync();
var travelOption = page.GetByRole(AriaRole.Option, new() { Name = "Travel" });
await Expect(travelOption).ToBeVisibleAsync();
await travelOption.ClickAsync();
A reusable “click safely” helper.
There are times you still hit weird timing edges. When you do, consolidate the fix into one helper, not 40 random sleeps.
static async Task ClickSafelyAsync(IPage page, ILocator locator, int timeoutMs = 10_000)
{
await Expect(locator).ToBeVisibleAsync(new() { Timeout = timeoutMs });
await Expect(locator).ToBeEnabledAsync(new() { Timeout = timeoutMs });
await WaitForOverlayToClearAsync(page);
// Trial click checks actionability without committing.
await locator.ClickAsync(new() { Trial = true, Timeout = timeoutMs });
await locator.ClickAsync(new() { Timeout = timeoutMs });
await WaitForOverlayToClearAsync(page);
}
Use it like:
await ClickSafelyAsync(page, page.GetByRole(AriaRole.Button, new() { Name = "New claim" }));
This makes your automation more consistent and easier to reason about.
A full happy-path example for ExpenseFlow.
This is a compact end-to-end flow that uses the patterns above.
using Microsoft.Playwright;
using Microsoft.Playwright.Assertions;
static async Task WaitForOverlayToClearAsync(IPage page)
{
var overlay = page.Locator(".loading-overlay, .spinner-overlay, [data-testid='loading-overlay']");
if (await overlay.CountAsync() > 0)
{
await overlay.First.WaitForAsync(new() { State = WaitForSelectorState.Hidden });
}
}
static async Task ClickSafelyAsync(IPage page, ILocator locator, int timeoutMs = 10_000)
{
await Expect(locator).ToBeVisibleAsync(new() { Timeout = timeoutMs });
await Expect(locator).ToBeEnabledAsync(new() { Timeout = timeoutMs });
await WaitForOverlayToClearAsync(page);
await locator.ClickAsync(new() { Trial = true, Timeout = timeoutMs });
await locator.ClickAsync(new() { Timeout = timeoutMs });
await WaitForOverlayToClearAsync(page);
}
public static async Task Main()
{
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync(new() { Headless = true });
var page = await browser.NewPageAsync();
await page.GotoAsync("https://expenseflow.example/login");
await page.GetByLabel("Email").FillAsync("[email protected]");
await page.GetByLabel("Password").FillAsync("correct-horse-battery-staple");
await ClickSafelyAsync(page, page.GetByRole(AriaRole.Button, new() { Name = "Sign in" }));
await Expect(page.GetByRole(AriaRole.Heading, new() { Name = "Dashboard" }))
.ToBeVisibleAsync(new() { Timeout = 15_000 });
await ClickSafelyAsync(page, page.GetByRole(AriaRole.Button, new() { Name = "New claim" }));
await page.GetByLabel("Amount").FillAsync("42.10");
await page.GetByLabel("Category").ClickAsync();
await ClickSafelyAsync(page, page.GetByRole(AriaRole.Option, new() { Name = "Travel" }));
await page.GetByLabel("Date").FillAsync("2026-01-05");
await page.GetByLabel("Description").FillAsync("Train to client site");
// Upload receipt
var fileInput = page.Locator("input[type='file'][name='receipt']");
await fileInput.SetInputFilesAsync("receipt.png");
// Wait for the app to finish processing the upload
await WaitForOverlayToClearAsync(page);
var submit = page.GetByRole(AriaRole.Button, new() { Name = "Submit claim" });
await Expect(submit).ToBeEnabledAsync(new() { Timeout = 15_000 });
await ClickSafelyAsync(page, submit);
// Assert success
var toast = page.Locator("[role='status'], .toast-success, [data-testid='toast-success']");
await Expect(toast).ToBeVisibleAsync(new() { Timeout = 15_000 });
await Expect(toast).ToContainTextAsync("Claim submitted");
}
This is not “sleep and hope.” It is “observe the UI and proceed when it is ready.”
When you still get flakiness: add traces, not sleeps
If a test fails once a week, you do not need bigger delays. You need evidence.
Playwright tracing is perfect for this. Turn it on around the scenario you are debugging:
await page.Context.Tracing.StartAsync(new()
{
Screenshots = true,
Snapshots = true,
Sources = true
});
// run your flow...
await page.Context.Tracing.StopAsync(new() { Path = "trace.zip" });
Then open the trace in the Playwright Trace Viewer, and you will usually see the exact moment your automation got ahead of the UI.
A simple rule that keeps you sane.
When you feel the urge to add Task.Delay, ask:
“What state am I actually waiting for?”
Then wait for that.
- Not time
- Not vibes
- Not “network idle”
- The actual condition that makes the next action valid
