DEV Community

Cover image for How to Retry Playwright Tasks Without Duplicating Side Effects
web4browser
web4browser

Posted on

How to Retry Playwright Tasks Without Duplicating Side Effects

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Suppose the task creates an invoice for ORDER-9201.

This key is too broad:

invoice:order-9201:create
Enter fullscreen mode Exit fullscreen mode

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(":");
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
    };
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

The distinction is:

Safe:
retry the read-only verification

Unsafe:
repeat the side-effecting action without proof
Enter fullscreen mode Exit fullscreen mode

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",
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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(),
  };
}
Enter fullscreen mode Exit fullscreen mode

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}`,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
      });
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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, and unavailable.
  • 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)