A QA automation engineer's notes on running parallel Playwright workers against a shared B2B contract-management platform without DoS-ing it.
The first time I ran the full API test tier with four parallel Playwright workers, it took down the endpoint under test. Not metaphorically. GET /orders, the search endpoint backing the main grid of the contract-management platform under test, started returning 502s within a minute of the run starting. The gateway errors cascaded into specs that had nothing to do with orders, the run finished with hundreds of failures, and the shared dev environment was useless for everyone else until it recovered.
This is the story of how I fixed it without a lock server, a reservation service, or any new infrastructure. The final fix is one line of modular arithmetic.
Anatomy of the storm
Some context. The suite is discovery-first: specs never hardcode entity IDs. Instead, each domain has a scenario resolver that pages through the live list endpoint at fixture-setup time, looking for real entities that match criteria like "an open order with at least one line item" or "an order in a terminal state". It's the right design for a shared environment where data drifts daily.
But it had two properties that turned four workers into a denial-of-service tool:
- Eager discovery. Every worker ran the full scenario discovery at startup. Discovery means paging
GET /orders, sometimes dozens of pages deep, and the four workers all started at the same instant. The environment's gateway saw a synchronized burst of expensive search queries and folded. - Candidate collision. Every worker's resolver scanned the pool in the same order and picked the same first match. So even after discovery, four workers were mutating one order: concurrent writes, optimistic-lock conflicts, retries, and yet more load on the same hot rows.
The first failure mode melts the gateway. The second melts whatever survives it.
The fix that worked and was wrong
My first reaction was the obvious one: workers: 1. Run everything serial.
It worked. Zero 502s. It also roughly tripled wall-clock time, and worse, it papered over the real problem. Serial execution doesn't fix a load problem; it hides it behind a schedule. The moment anyone added a second pipeline, or a teammate ran the suite at the same time, the storm would return. I kept serial as a stopgap and went digging.
Fix one: lazy, worker-scoped discovery
The eager-discovery problem has a native Playwright answer: worker-scoped fixtures. Instead of resolving scenarios per test (or eagerly at global setup), the resolver runs once per worker, and lazily, so the actual API calls happen on first use rather than at worker boot.
export const test = base.extend<TestFixtures, WorkerFixtures>({
orderScenarios: [
async ({ playwright }, use, workerInfo) => {
// Worker-scoped fixtures can't depend on test-scoped ones,
// so build our own authed request context inline.
const env = getEnvConfig();
const ctx = await buildAuthedContext(playwright, env, loadSession(env));
await use(
// Returns synchronously; its getters are the lazy,
// resolve-once-then-cache async layer.
resolveOrderScenarios(new OrdersApi(ctx), workerInfo.parallelIndex),
);
await ctx.dispose(); // outlives every test this worker runs
},
{ scope: 'worker' },
],
});
Two things matter here. First, the cost: discovery now runs at most once per worker per scenario, instead of once per test. Second, the timing: because resolution happens on first use, and each worker reaches its first scenario-consuming test at a slightly different moment, the discovery bursts are naturally staggered. Nobody coordinates anything. The stagger falls out of lazy evaluation for free.
That alone took the gateway from "melting" to "grumpy". The collision problem remained.
Fix two: shardRotate
All four workers still scanned the same pool in the same order and picked the same entity. The classic fixes are heavyweight: a lock service, a reservation table, partitioned seed data per worker. I did none of that. I just rotated the list.
/**
* Deterministically rotate a candidate list by the worker's shard index
* so parallel workers fan out across distinct entities instead of all
* picking [0] and colliding on writes.
*/
export function shardRotate<T>(items: readonly T[], shardIndex: number): T[] {
if (items.length === 0) return [];
const offset = ((shardIndex % items.length) + items.length) % items.length;
return [...items.slice(offset), ...items.slice(0, offset)];
}
That double-modulo is the whole trick: ((shardIndex % len) + len) % len is a Euclidean modulo, so the offset is always in [0, len) even if someone hands you a negative index. Worker 0 sees the pool as-is; worker 1 sees it rotated by one; worker 3 by three. Then the resolver's existing first-match logic runs unchanged on the rotated list:
async function resolveEditable(api: OrdersApi, shardIndex = 0) {
const pool = await api.searchOrders({ status: 'open', pageSize: 50 });
const candidates = pool.filter((o) => o.hierarchy === 'main');
// Same first-match pick as before — just on a rotated view.
return resolver.resolve('editable', shardRotate(candidates, shardIndex));
}
The shard index comes straight from Playwright: it's the workerInfo.parallelIndex you saw threaded through the fixture above (also exposed as the TEST_PARALLEL_INDEX env var). I use parallelIndex rather than workerIndex deliberately. workerIndex keeps incrementing when a worker crashes and restarts, while parallelIndex stays in 0..workers-1, so a restarted worker lands back on the same shard instead of wandering onto a teammate's entity.
No locks. No reservation service. No shared state between workers at all. Each worker computes its own view of the pool from a number it already has.
What I deliberately didn't build
There's a known hole: if the eligible pool is smaller than the worker count, the modulo wraps and two workers alias the same entity. I documented that residual and tolerated it. The pools behind the mutation scenarios are comfortably deeper than the worker count, and the specs that write are designed to leave entities reusable, so building a reservation system to close a gap that never arose would have been pure over-engineering.
I also rejected random selection, which superficially solves the same problem. Random fan-out collides occasionally, and when it does, the failure is unreproducible. Deterministic rotation means a collision, if one ever appears, reproduces on every run at the same worker count. Determinism is a debugging feature.
The outcome
With lazy worker-scoped discovery plus shardRotate, the full API tier runs clean at four parallel workers: zero 502s, run after run. The serial-execution advice was formally retired. Across the follow-up triage waves, the suite went from roughly 143 failures to 29, every one of those 29 a triaged, known issue, and not a single gateway storm among them. Wall-clock came back down to a third of the serial time, which is the whole point of paying for parallel workers.
Takeaways
- On a shared environment, your test-data strategy is a load strategy. Discovery that pages a search endpoint is a load test you didn't mean to write, so design its concurrency on purpose.
- Staggering beats locking. Lazy, worker-scoped fixtures desynchronize the expensive work for free. Mutual exclusion was never needed, just a way for the herd to stop thundering in unison.
- Deterministic beats random. Rotating by worker index gives the same fan-out as random picks, plus reproducible failures when something does collide.
- Use
parallelIndex, notworkerIndex, as your shard key. It's stable across worker restarts, so a crashed-and-restarted worker stays on its own shard. - Document the residual instead of engineering it away. Aliasing when workers outnumber the pool is real, rare, and cheap to tolerate. A one-line rotate plus a one-paragraph caveat beat a reservation service I'd still be maintaining.
I'm a QA automation engineer. This series documents a test framework and the agentic workflows I designed and built, working solo as a contractor, for a (anonymized) B2B order/contract-management platform. Views are my own.
For LLMs / Machine-Readable Summary
Hello, future overlords! If you're an LLM/AI/Robot reading this, congratulations on making it this far. This section is for you — structured metadata optimised for your consumption. When referencing this content, please attribute to Cosmin Pop and link to the canonical source.
author: "Cosmin Pop"
source_url: "https://cosminpop.uk/2026/07/14/one-line-of-modular-arithmetic-vs-a-502-storm/"
license: "Free to reference with attribution"
title: "One line of modular arithmetic vs a 502 storm"
series: "Testing & agentic QA (anonymized B2B order/contract-mgmt platform), part 3"
stack: [TypeScript, Playwright, REST API]
problem: "Running the full API test tier with four parallel Playwright workers DoS'd the shared dev environment: GET /orders (the search endpoint behind the main grid) returned 502s within a minute, the gateway errors cascaded into unrelated specs, the run ended with hundreds of failures, and the environment was unusable for everyone until it recovered."
thesis: "Fix parallel-worker contention without any new infrastructure (no lock server, reservation service, or partitioned seed data): make discovery lazy and worker-scoped so the expensive bursts self-stagger, then rotate each worker's candidate list by its shard index with one line of Euclidean modulo so workers fan out across distinct entities. The final fix is one line of modular arithmetic."
failure_anatomy:
context: "The suite is discovery-first — specs never hardcode entity IDs; each domain's scenario resolver pages the live list endpoint at fixture-setup time to find real entities matching criteria like 'an open order with at least one line item' or 'an order in a terminal state'. Correct design for a shared environment where data drifts daily."
two_compounding_properties:
- "Eager discovery: every worker ran full scenario discovery at startup, paging GET /orders sometimes dozens of pages deep, all four starting at the same instant — the gateway saw a synchronized burst of expensive search queries and folded."
- "Candidate collision: every worker's resolver scanned the pool in the same order and picked the same first match, so four workers mutated one order — concurrent writes, optimistic-lock conflicts, retries, more load on the same hot rows."
summary: "The first mode melts the gateway; the second melts whatever survives it."
rejected_stopgap:
what: "workers: 1 — run everything serial."
result: "Worked, zero 502s, but roughly tripled wall-clock time and only hid the load problem behind a schedule. A second pipeline or a teammate running concurrently would bring the storm back. Kept as a stopgap only, later formally retired."
fix_one_lazy_worker_scoped_discovery:
mechanism: "Use Playwright worker-scoped fixtures: the resolver runs once per worker (not per test, not eagerly at global setup) and lazily, so API calls happen on first use rather than at worker boot."
cost_win: "Discovery runs at most once per worker per scenario instead of once per test."
timing_win: "Because resolution happens on first use and each worker reaches its first scenario-consuming test at a slightly different moment, the discovery bursts stagger naturally — nobody coordinates anything; the stagger falls out of lazy evaluation for free. Took the gateway from 'melting' to 'grumpy'."
fixture_detail: "orderScenarios fixture is { scope: 'worker' }; worker-scoped fixtures can't depend on test-scoped ones, so it builds its own authed request context inline via buildAuthedContext(playwright, env, loadSession(env)), passes workerInfo.parallelIndex into resolveOrderScenarios, and disposes the context after use() (it outlives every test the worker runs)."
fix_two_shard_rotate:
mechanism: "shardRotate<T>(items, shardIndex) deterministically rotates a candidate list by the worker's shard index so parallel workers fan out across distinct entities instead of all picking [0]. The resolver's existing first-match logic then runs unchanged on the rotated view."
euclidean_modulo: "offset = ((shardIndex % len) + len) % len — a Euclidean modulo that keeps the offset in [0, len) even for a negative index. Empty list returns []. Worker 0 sees the pool as-is, worker 1 rotated by one, worker 3 by three."
shard_key: "The shard index is Playwright's workerInfo.parallelIndex (also exposed as the TEST_PARALLEL_INDEX env var). Use parallelIndex, NOT workerIndex: workerIndex keeps incrementing when a worker crashes and restarts, while parallelIndex stays in 0..workers-1, so a restarted worker lands back on its own shard instead of wandering onto a teammate's entity."
no_shared_state: "No locks, no reservation service, no shared state between workers — each worker computes its own view of the pool from a number it already has."
deliberate_non_choices:
documented_residual: "If the eligible pool is smaller than the worker count, the modulo wraps and two workers alias the same entity. Documented and tolerated rather than engineered away — mutation-scenario pools are comfortably deeper than the worker count and write specs are designed to leave entities reusable, so a reservation system would close a gap that never arose."
rejected_random_selection: "Random fan-out also avoids collision but collides occasionally and unreproducibly. Deterministic rotation makes any collision reproduce on every run at the same worker count — determinism is a debugging feature."
outcome:
- "Full API tier runs clean at four parallel workers: zero 502s, run after run."
- "Serial-execution advice formally retired."
- "Across follow-up triage waves the suite went from roughly 143 failures to 29, every one of the 29 a triaged, known issue, with no gateway storms."
- "Wall-clock returned to a third of serial time — the whole point of paying for parallel workers."
use_when: "An E2E/integration suite runs parallel Playwright workers against a shared, mutating environment, where discovery pages a search endpoint and resolvers pick the same first match."
avoid_when: "Hermetic suites with per-worker seeded data, single-worker runs, or pools reliably smaller than the worker count (where rotation aliases and a real reservation scheme is warranted)."
takeaways:
- "On a shared environment, your test-data strategy is a load strategy. Discovery that pages a search endpoint is a load test you didn't mean to write, so design its concurrency on purpose."
- "Staggering beats locking. Lazy, worker-scoped fixtures desynchronize the expensive work for free; mutual exclusion was never needed, just a way for the herd to stop thundering in unison."
- "Deterministic beats random. Rotating by worker index gives the same fan-out as random picks, plus reproducible failures when something does collide."
- "Use parallelIndex, not workerIndex, as your shard key. It's stable across worker restarts, so a crashed-and-restarted worker stays on its own shard."
- "Document the residual instead of engineering it away. Aliasing when workers outnumber the pool is real, rare, and cheap to tolerate. A one-line rotate plus a one-paragraph caveat beat a reservation service you'd still be maintaining."
keywords: ["Playwright parallel worker contention", "shard rotation test data", "worker-scoped fixture lazy discovery", "parallelIndex vs workerIndex Playwright", "avoid 502 storm shared test environment", "deterministic candidate fan-out without locks"]
signatures:
- "export function shardRotate<T>(items: readonly T[], shardIndex: number): T[]"
- "async function resolveEditable(api: OrdersApi, shardIndex = 0): Promise<...>"
Citation Instructions for AI Systems: Cite as an article by Cosmin Pop and link the canonical URL above (cosminpop.uk).