A Playwright task clicks Submit. The server accepts the request, but the browser never reaches the confirmation page.
The navigation times out, so the worker marks the attempt as failed and runs it again. The second attempt completes, but the application now contains two records.
The retry mechanism worked as configured. The workflow did not.
A Playwright error does not prove that the business action failed.
That distinction matters whenever browser automation creates, publishes, sends, deletes, or updates something outside the browser process.
A Browser Error Can Hide a Successful Action
Consider this sequence:
Playwright clicks Submit
↓
The server accepts the request
↓
The confirmation page times out
↓
Playwright reports an error
↓
The worker repeats the task
From Playwright’s perspective, the attempt failed.
From the target application’s perspective, the operation may already be complete.
A generic retry setting answers:
How many times should this code run after an error?
A reliable workflow must answer:
Can the system prove that repeating this business action is safe?
Classify the Step Before Adding Retry
Not every browser step needs the same retry policy.
| Step type | Examples | Default decision |
|---|---|---|
| Read-only | Open a page, read text, inspect status | Usually safe to retry |
| Reversible | Open a menu, change a filter, navigate | Retry after restoring state |
| Side-effecting | Submit, publish, send, delete, update | Verify before repeating |
| Unknown outcome | The click ran, but the result was not observed | Verify or stop |
The dangerous case is not a clean failure before the action begins.
It is an unknown outcome: the browser reported an error after the workflow may have crossed a side-effect boundary.
Give the Business Effect a Stable Key
Each attempt can receive a new runId and attempt number.
The underlying business operation must retain the same identity.
type OperationStatus =
| "pending"
| "effect_requested"
| "effect_confirmed"
| "outcome_unknown"
| "needs_review";
interface BrowserOperation {
tenantId: string;
accountId: string;
taskId: string;
runId: string;
stepId: string;
// Stable across every attempt of the same business operation.
effectKey: string;
attempt: number;
status: OperationStatus;
resultId?: string;
error?: string;
updatedAt: string;
}
Suppose the task creates an invoice for ORDER-9201.
This key is too broad:
invoice:order-9201:create
Different tenants or accounts may use the same external reference.
A safer key includes the business scope:
function buildInvoiceEffectKey(
tenantId: string,
accountId: string,
externalReference: string
): string {
return [
"tenant",
tenantId,
"account",
accountId,
"invoice",
externalReference,
"create",
].join(":");
}
The key now identifies:
- the tenant;
- the account;
- the business object;
- the operation type;
- the intended effect.
A retry receives a new runId, but retains the same effectKey.
A Stable Key Is Not Enough
This sequence still allows duplicates:
const previous = await store.getCurrent(operation.effectKey);
if (!previous) {
await store.put(operation);
await performEffect();
}
Two workers may both observe that no record exists:
Worker A: getCurrent() → no record
Worker B: getCurrent() → no record
Worker A: put()
Worker B: put()
Worker A: performEffect()
Worker B: performEffect()
The check and write must be one atomic operation.
The storage layer needs an atomic claim:
interface ClaimResult {
acquired: boolean;
current?: BrowserOperation;
}
interface OperationEvent {
effectKey: string;
runId: string;
attempt: number;
type:
| "claim_acquired"
| "claim_rejected"
| "effect_confirmed"
| "verification_unavailable"
| "outcome_unknown"
| "review_required";
message?: string;
timestamp: string;
}
interface OperationStore {
getCurrent(
effectKey: string
): Promise<BrowserOperation | null>;
// Atomically inserts effect_requested when no record exists.
claimEffect(
operation: BrowserOperation
): Promise<ClaimResult>;
updateCurrent(
operation: BrowserOperation
): Promise<void>;
// Atomically inserts or updates a confirmed result.
recordConfirmed(
operation: BrowserOperation
): Promise<void>;
appendEvent(
event: OperationEvent
): Promise<void>;
}
claimEffect() can be implemented with:
- a PostgreSQL unique constraint and
INSERT ... ON CONFLICT DO NOTHING; - Redis
SET key value NX; - compare-and-swap;
- a database transaction with an appropriate lock.
For PostgreSQL, the underlying claim can look like this:
INSERT INTO browser_effects (
effect_key,
status,
run_id,
attempt,
updated_at
)
VALUES (
$1,
'effect_requested',
$2,
$3,
NOW()
)
ON CONFLICT (effect_key) DO NOTHING
RETURNING effect_key;
If no row is returned, another worker already owns or completed the effect.
A normal get() followed by put() cannot provide that guarantee.
Record Existing Results With an Upsert
The target result may already exist even when the local operation record does not.
This can happen when:
- a person completed the action manually;
- an older system created the result;
- a worker completed the action but failed before saving local state;
- local state was removed while the business result remained.
Calling a normal update in this situation may affect zero rows.
Use a separate idempotent upsert:
INSERT INTO browser_effects (
effect_key,
status,
run_id,
attempt,
result_id,
updated_at
)
VALUES (
$1,
'effect_confirmed',
$2,
$3,
$4,
NOW()
)
ON CONFLICT (effect_key)
DO UPDATE SET
status = 'effect_confirmed',
run_id = EXCLUDED.run_id,
attempt = EXCLUDED.attempt,
result_id = EXCLUDED.result_id,
updated_at = NOW();
That is the responsibility of recordConfirmed().
Verification Needs Three States
A boolean result is not enough for a safety decision.
This type is dangerous:
interface VerificationResult {
confirmed: boolean;
}
confirmed: false could mean either:
- the query succeeded and the result is absent;
- the query timed out;
- the page failed to load;
- the verification system was unavailable.
Those cases must not lead to the same decision.
Use three states instead:
type VerificationResult =
| {
state: "confirmed";
resultId?: string;
}
| {
state: "absent";
}
| {
state: "unavailable";
error: string;
};
Their meanings are different:
| Verification state | Meaning | May the side effect run? |
|---|---|---|
confirmed |
The expected result exists | No |
absent |
The query succeeded and the result does not exist | Possibly |
unavailable |
The result could not be checked reliably | No |
Only a reliable absent result may continue toward the claim.
Retry Verification, Not the Side Effect
A result may not become visible immediately.
The request is accepted
↓
The result is still being indexed
↓
The first search returns nothing
↓
The result appears two seconds later
It can be safe to retry a read-only verification query.
It is not automatically safe to repeat the original submit action.
async function sleep(milliseconds: number): Promise<void> {
await new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function verifyWithBackoff(
verify: () => Promise<VerificationResult>,
attempts = 4
): Promise<VerificationResult> {
let lastResult: VerificationResult = {
state: "unavailable",
error: "Verification was not completed.",
};
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
lastResult = await verify();
if (lastResult.state === "confirmed") {
return lastResult;
}
} catch (error: unknown) {
lastResult = {
state: "unavailable",
error: getErrorMessage(error),
};
}
if (attempt < attempts) {
await sleep(attempt * 750);
}
}
return lastResult;
}
The distinction is:
Safe:
retry the read-only verification
Unsafe:
repeat the side-effecting action without proof
Write the Claim Before the Click
This pattern leaves a dangerous gap:
await page
.getByRole("button", { name: "Create invoice" })
.click();
await store.updateCurrent({
...operation,
status: "effect_confirmed",
});
The server may complete the operation while the browser times out before the state update runs.
Use this order instead:
1. Verify whether the result already exists
2. Validate the business precondition
3. Atomically claim the effect
4. Verify once more after the claim
5. Perform the browser action
6. Retry read-only result verification
7. Confirm the effect or mark the outcome unknown
The claim is stored before the browser crosses the side-effect boundary.
If the claim cannot be written, the browser action must not run.
A Conservative TypeScript Implementation
The workflow needs three application-specific functions:
-
checkPrecondition()determines whether the action is still allowed. -
performEffect()performs the browser action. -
verifyPostcondition()checks whether the expected business result exists.
type RunResult =
| {
outcome: "completed" | "skipped";
resultId?: string;
}
| {
outcome: "needs_review";
reason: string;
};
interface SafeEffectOptions {
store: OperationStore;
operation: BrowserOperation;
checkPrecondition: () => Promise<boolean>;
performEffect: () => Promise<void>;
verifyPostcondition: () => Promise<VerificationResult>;
captureEvidence: (label: string) => Promise<void>;
}
function updated(
operation: BrowserOperation,
changes: Partial<BrowserOperation>
): BrowserOperation {
return {
...operation,
...changes,
updatedAt: new Date().toISOString(),
};
}
function event(
operation: BrowserOperation,
type: OperationEvent["type"],
message?: string
): OperationEvent {
return {
effectKey: operation.effectKey,
runId: operation.runId,
attempt: operation.attempt,
type,
message,
timestamp: new Date().toISOString(),
};
}
The runner verifies the target system before claiming anything.
export async function runSideEffect(
options: SafeEffectOptions
): Promise<RunResult> {
const {
store,
operation,
checkPrecondition,
performEffect,
verifyPostcondition,
captureEvidence,
} = options;
const previous = await store.getCurrent(operation.effectKey);
if (previous?.status === "effect_confirmed") {
return {
outcome: "skipped",
resultId: previous.resultId,
};
}
const existingResult = await verifyWithBackoff(
verifyPostcondition,
2
);
if (existingResult.state === "confirmed") {
const confirmed = updated(operation, {
status: "effect_confirmed",
resultId: existingResult.resultId,
});
await store.recordConfirmed(confirmed);
await store.appendEvent(
event(
confirmed,
"effect_confirmed",
"The result already existed before execution."
)
);
return {
outcome: "skipped",
resultId: existingResult.resultId,
};
}
if (existingResult.state === "unavailable") {
await store.appendEvent(
event(
operation,
"verification_unavailable",
existingResult.error
)
);
return {
outcome: "needs_review",
reason:
`The existing result could not be verified: ` +
existingResult.error,
};
}
// Only a reliable absent result may continue.
if (
previous?.status === "effect_requested" ||
previous?.status === "outcome_unknown" ||
previous?.status === "needs_review"
) {
await store.appendEvent(
event(
operation,
"review_required",
"A previous attempt crossed the side-effect boundary."
)
);
return {
outcome: "needs_review",
reason:
"A previous attempt may have performed the business action.",
};
}
const preconditionIsValid = await checkPrecondition();
if (!preconditionIsValid) {
await store.appendEvent(
event(
operation,
"review_required",
"The business precondition is no longer valid."
)
);
return {
outcome: "needs_review",
reason: "The business precondition is no longer valid.",
};
}
const requestedOperation = updated(operation, {
status: "effect_requested",
});
const claim = await store.claimEffect(requestedOperation);
if (!claim.acquired) {
await store.appendEvent(
event(
operation,
"claim_rejected",
"Another worker already claimed this business effect."
)
);
if (claim.current?.status === "effect_confirmed") {
return {
outcome: "skipped",
resultId: claim.current.resultId,
};
}
const resultAfterConflict = await verifyWithBackoff(
verifyPostcondition,
3
);
if (resultAfterConflict.state === "confirmed") {
const confirmed = updated(operation, {
status: "effect_confirmed",
resultId: resultAfterConflict.resultId,
});
await store.recordConfirmed(confirmed);
await store.appendEvent(
event(
confirmed,
"effect_confirmed",
"The result was confirmed after a claim conflict."
)
);
return {
outcome: "skipped",
resultId: resultAfterConflict.resultId,
};
}
const conflictReason =
resultAfterConflict.state === "unavailable"
? `Verification was unavailable: ${resultAfterConflict.error}`
: "The result is not yet visible.";
return {
outcome: "needs_review",
reason:
"Another worker owns the effect. " + conflictReason,
};
}
await store.appendEvent(
event(requestedOperation, "claim_acquired")
);
// Narrow the window between the first verification and execution.
// This also catches actions completed by an external actor.
const resultAfterClaim = await verifyWithBackoff(
verifyPostcondition,
2
);
if (resultAfterClaim.state === "confirmed") {
const confirmed = updated(requestedOperation, {
status: "effect_confirmed",
resultId: resultAfterClaim.resultId,
});
await store.recordConfirmed(confirmed);
await store.appendEvent(
event(
confirmed,
"effect_confirmed",
"The result appeared after the claim and before execution."
)
);
return {
outcome: "skipped",
resultId: resultAfterClaim.resultId,
};
}
if (resultAfterClaim.state === "unavailable") {
const review = updated(requestedOperation, {
status: "needs_review",
error: resultAfterClaim.error,
});
await store.updateCurrent(review);
await store.appendEvent(
event(
review,
"verification_unavailable",
resultAfterClaim.error
)
);
return {
outcome: "needs_review",
reason:
`Verification became unavailable after the claim: ` +
resultAfterClaim.error,
};
}
// The result is reliably absent. The claimed worker may proceed.
try {
await performEffect();
const result = await verifyWithBackoff(
verifyPostcondition,
4
);
if (result.state === "confirmed") {
const confirmed = updated(requestedOperation, {
status: "effect_confirmed",
resultId: result.resultId,
});
await store.recordConfirmed(confirmed);
await store.appendEvent(
event(confirmed, "effect_confirmed")
);
return {
outcome: "completed",
resultId: result.resultId,
};
}
const verificationError =
result.state === "unavailable"
? result.error
: "The result remained absent after verification retries.";
await captureEvidence(
"postcondition-not-confirmed"
).catch(() => undefined);
const unknown = updated(requestedOperation, {
status: "outcome_unknown",
error: verificationError,
});
await store.updateCurrent(unknown);
await store.appendEvent(
event(
unknown,
"outcome_unknown",
verificationError
)
);
return {
outcome: "needs_review",
reason:
"The action ran, but its business result could not be confirmed.",
};
} catch (error: unknown) {
const message = getErrorMessage(error);
await captureEvidence(
"effect-outcome-unknown"
).catch(() => undefined);
const unknown = updated(requestedOperation, {
status: "outcome_unknown",
error: message,
});
await store.updateCurrent(unknown);
await store.appendEvent(
event(unknown, "outcome_unknown", message)
);
return {
outcome: "needs_review",
reason: `The effect may have happened: ${message}`,
};
}
}
This implementation is deliberately conservative.
Before the atomic claim succeeds, transient read or precondition errors can be handled by an outer retry policy.
After the claim succeeds, the workflow never automatically repeats the business action.
It retries only read-only verification.
Do Not Automatically Reuse a Stale Claim
Atomic claims introduce another failure mode:
Worker claims the effect
↓
Worker crashes before or after clicking
↓
effect_requested remains in storage
A stale claim should not automatically become permission to execute again.
A simple TTL is not enough because the original worker may have crossed the side-effect boundary before it crashed.
First verify the external business result.
Reclaim the operation only when the system can prove that the original attempt did not perform the effect. Otherwise, keep it in needs_review.
Stale claim + confirmed result
→ mark effect_confirmed
Stale claim + proof that no action started
→ controlled reclaim may be allowed
Stale claim + unavailable verification
→ human review
Stale claim + uncertain outcome
→ human review
A stale claim is only one part of a larger recovery decision. For the wider workflow model, see a recovery plan that separates retry, rollback, and human handoff.
Use Business Checks, Not Only Selectors
A selector can prove that a button exists.
It cannot prove that repeating the button’s business operation is safe.
For an invoice-creation workflow, the checks might be:
Precondition
- No invoice exists for the external reference.
- The current account can create invoices.
- The source order is still eligible for invoicing.
- The form is in an editable state.
Side effect
- Fill the form.
- Click Create invoice.
Postcondition
- An invoice exists for the external reference.
- The result has a stable invoice ID.
- The invoice belongs to the expected account.
- The invoice contains the expected source order.
Here is a simplified Playwright usage example:
import { mkdir } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import type { Page } from "@playwright/test";
interface InvoiceContext {
tenantId: string;
accountId: string;
externalReference: string;
}
function safeFilePart(value: string): string {
return value.replace(/[^a-zA-Z0-9._-]/g, "_");
}
async function createInvoiceSafely(
page: Page,
store: OperationStore,
context: InvoiceContext
): Promise<RunResult> {
const {
tenantId,
accountId,
externalReference,
} = context;
const effectKey = buildInvoiceEffectKey(
tenantId,
accountId,
externalReference
);
const operation: BrowserOperation = {
tenantId,
accountId,
taskId: externalReference,
runId: randomUUID(),
stepId: "create-invoice",
effectKey,
attempt: 1,
status: "pending",
updatedAt: new Date().toISOString(),
};
return runSideEffect({
store,
operation,
checkPrecondition: async () => {
await page.goto(
`/invoices?reference=${encodeURIComponent(
externalReference
)}`
);
const matchingRows = page
.getByTestId("invoice-row")
.filter({ hasText: externalReference });
return (await matchingRows.count()) === 0;
},
performEffect: async () => {
await page.goto("/invoices/new");
await page
.getByLabel("External reference")
.fill(externalReference);
await page
.getByRole("button", {
name: "Create invoice",
})
.click();
},
verifyPostcondition: async (): Promise<VerificationResult> => {
try {
await page.goto(
`/invoices?reference=${encodeURIComponent(
externalReference
)}`
);
const row = page
.getByTestId("invoice-row")
.filter({ hasText: externalReference })
.first();
const visible = await row.isVisible();
if (!visible) {
return {
state: "absent",
};
}
const resultId =
(await row.getAttribute("data-invoice-id")) ??
undefined;
return {
state: "confirmed",
resultId,
};
} catch (error: unknown) {
return {
state: "unavailable",
error: getErrorMessage(error),
};
}
},
captureEvidence: async (label: string) => {
await mkdir("artifacts", { recursive: true });
const fileReference = safeFilePart(
externalReference
);
await page.screenshot({
path: `artifacts/${fileReference}-${label}.png`,
fullPage: true,
});
},
});
}
In a Playwright test, testInfo.outputPath() can also generate an isolated artifact path for the current run.
In production, the postcondition should preferably use an independent source of truth.
An API query, database lookup, audit endpoint, or stable application search is usually more reliable than waiting for a confirmation message in the same page session.
Prefer Native Idempotency When Available
Browser-side claims and verification reduce duplicate execution risk.
They do not replace native idempotency support in the target system.
If the application provides an idempotency key, request identifier, unique external reference, or deduplicated API endpoint, use it.
Server-side protection is stronger because it rejects duplicates at the business boundary.
Browser-side claim
→ prevents two workers from intentionally starting the same effect
Server-side idempotency
→ deduplicates repeated requests that reach the application
Do not describe this design as exactly-once execution.
With browser automation, external systems, network failures, and process crashes, exactly-once guarantees are usually unrealistic.
A more accurate objective is:
Atomically claim the intended effect, avoid automatic duplicate attempts, and verify the external result before making the next decision.
Keep Current State and Attempt History Separately
The current operation record answers:
What should the worker do now?
The event history answers:
How did the workflow reach this state?
A production implementation should usually maintain both:
Current operation state
+
Append-only attempt events
The in-memory operation begins as pending.
The persisted current record begins as effect_requested only after the atomic claim succeeds. It can then move to effect_confirmed, outcome_unknown, or needs_review.
The event log should preserve:
- every run ID;
- every attempt;
- claim conflicts;
- verification failures;
- evidence references;
- review decisions;
- timestamps.
Overwriting one row is not enough for debugging or audit.
When this pattern expands across scripts, workers, and operators, the state model should belong to the workflow rather than to an isolated retry helper.
Web4 Browser applies this approach through reusable and traceable browser workflows, where repeated operations, execution logs, exceptions, and result summaries can remain attached to the same process.
Retry Decision Table
| Observed state | Result verification | Decision |
|---|---|---|
| No effect has been claimed | Confirmed | Record confirmed and skip |
| No effect has been claimed | Absent | Validate, claim, and execute |
| No effect has been claimed | Unavailable | Do not execute |
| Effect already confirmed | Confirmed | Skip |
| Effect requested | Confirmed | Record confirmed and skip |
| Effect requested | Temporarily absent | Retry verification only |
| Effect requested | Unavailable | Mark unknown or review |
| Effect requested | Still absent after execution | Mark unknown and review |
| Another worker owns the claim | Confirmed | Record confirmed and skip |
| Another worker owns the claim | Absent | Do not execute |
| Another worker owns the claim | Unavailable | Do not execute |
| Precondition no longer holds | Absent | Stop |
| Checkpoint write fails | Not applicable | Do not perform the action |
| Claim appears stale | Unavailable or uncertain | Do not reclaim automatically |
Safe Retry Checklist
Before enabling retries for a side-effecting browser step, confirm that:
- The operation has a scoped and stable
effectKey. - The in-memory initial state is
pending. - Verification distinguishes
confirmed,absent, andunavailable. - Verification failures are never treated as confirmed absence.
- The effect is claimed with an atomic storage operation.
- A unique constraint or equivalent mechanism prevents competing claims.
- Existing results are recorded with an idempotent upsert.
- The target result is checked before execution.
- The result is checked again after the claim.
- Preconditions and postconditions are defined in business terms.
- Read-only verification can be retried separately.
- The side-effecting action is not automatically repeated after claim.
- Stale claims are verified before any reclaim decision.
- Unknown outcomes enter an explicit review state.
- Current state and append-only attempt history are preserved.
- Native server-side idempotency is used when available.
Reliable browser automation does not retry every error.
It first determines whether the result can be verified, who owns the business effect, and whether repeating the action can create a duplicate.
Top comments (0)