Run Fast, Serialize Narrowly: A Topology for Browser Tests
Playwright 1.63 adds named locks as Astro 7.3 permits concurrent previews. Isolate environments first, then serialize only true shared state.
Two releases this week appear to give browser-test operators opposite instructions.
Playwright 1.63, published on 4 September, added named test locks. Tests using the same lock cannot run together across files, workers, and Playwright projects, while unrelated tests remain parallel. One day earlier, Astro 7.3 added astro preview --ignore-lock, allowing several preview servers from the same project to run on different ports.
One tool added a lock. The other added a way around one. That is not a contradiction. It exposes a distinction that many end-to-end suites blur: a server-process collision, a test-data collision, and a distributed-run collision belong to different coordination scopes.
The repeated angle to avoid
The last ten posts here covered release authority, analytics geographies, AI approval evidence, cache representation boundaries, ecommerce variant models, htmx state, Shopify order timing, verified-bot policy, AI catalog conformance, and security-dashboard denominators. Older related posts covered testable browser-agent semantics, testable code-review environments, and isolation policy for parallel agent worktrees.
The generic version of this article would repeat the old X needs Y formula: parallel tests need isolation. It would also risk repackaging the worktree argument for test runners.
The sharper thesis is this: concurrency controls are correct only when their scope matches the resource that can collide. A preview-server lock controls process discovery, a Playwright lock controls scheduling inside one test run, and neither is a distributed lock for a shared sandbox used by several CI jobs.
The source map
The fresh evidence is unusually useful because it includes the feature, the regression that motivated it, and the implementation boundary.
| Source | Freshness | Contribution |
|---|---|---|
| Playwright 1.63 release | 4 September 2026 | Named test locks across files, workers, and projects; multiple locks; group-level locks |
| Astro 7.3 release | 3 September 2026 | astro preview --ignore-lock for concurrent E2E preview servers on different ports |
| Astro pull request 17767 | Merged 2 September 2026 | Exact bypass semantics, incompatible flags, tests, and the Playwright use case |
| Astro issue 17720 | Opened during the Astro 7.2 cycle | The concrete failure: several Playwright webServer entries from one Astro project were mistaken for one accidental duplicate |
| Playwright test-lock implementation | Background implementation, merged 24 July 2026 | Locks are acquired as a set before a test job, waiting does not spend test timeout, and file grouping remains intact |
| Playwright parallelism guide | Current documentation for 1.63 | Lock duration, file-mode consequences, data partitioning with testId, outputPath, and worker indexes |
| Playwright web-server guide | Current background documentation | Process startup, readiness URLs, existing-server policy, multiple servers, and shutdown ownership |
The new information is in the join. Astro describes how to permit concurrent server processes. Playwright describes how to serialize tests that touch one shared resource. Neither source, by itself, tells an operator where that coordination stops working or how to choose between isolation, partitioning, local locking, and a distributed lease.
A preview lock is a process-discovery control
Astro 7.2 introduced background management for preview servers: start one, then find it later with astro preview status, inspect it with logs, or terminate it with stop. That requires a canonical record tying a project root to a process ID, port, URL, and start time.
This is valuable for a human or an agent that accidentally starts the same project twice. It was too broad for a deliberate Playwright topology in which several builds or configurations from the same project root must be served at once. The reported failure was explicit: the first server listened on one port, then the next server stopped because Astro found an existing preview process.
Astro 7.3's bypass makes the ownership transfer explicit. According to the merged implementation, an --ignore-lock preview:
- does not block because a canonical preview already exists;
- does not write its own lock record;
- is not discoverable through Astro's
preview stop,status, orlogscommands; - cannot be combined with
--force, because replacing a server and coexisting with it are contradictory intentions; - cannot be combined with background mode, including Astro's auto-detected agent background mode, because an untracked background process would have no management record.
The flag does not make ports shareable. The operating system still prevents two processes from binding the same address and port. It also does not clean up an abandoned server. It says, in effect: the caller deliberately wants another foreground process and now owns its address, readiness check, logs, and shutdown.
For E2E tests, that caller should be Playwright's webServer manager rather than an ad hoc shell command left running in the background.
import { defineConfig } from '@playwright/test';
export default defineConfig({
webServer: [
{
name: 'catalog-a',
command: 'astro preview --root ./fixtures/catalog-a --port 4411 --ignore-lock',
url: 'http://127.0.0.1:4411/',
reuseExistingServer: false,
timeout: 120_000,
gracefulShutdown: { signal: 'SIGTERM', timeout: 2_000 },
},
{
name: 'catalog-b',
command: 'astro preview --root ./fixtures/catalog-b --port 4412 --ignore-lock',
url: 'http://127.0.0.1:4412/',
reuseExistingServer: false,
timeout: 120_000,
gracefulShutdown: { signal: 'SIGTERM', timeout: 2_000 },
},
],
projects: [
{ name: 'catalog-a', use: { baseURL: 'http://127.0.0.1:4411' } },
{ name: 'catalog-b', use: { baseURL: 'http://127.0.0.1:4412' } },
],
});
The important setting is not only --ignore-lock. In CI, reuseExistingServer: false prevents a stale or unrelated listener from silently becoming the application under test. Each lane also gets a distinct port and baseURL, and the harness waits for the expected readiness URL before tests begin.
If several CI jobs share one machine, fixed ports such as 4411 and 4412 are no longer unique enough. Allocate a job-specific range, reserve ports before startup, or put each job in its own network namespace. Bypassing Astro's root-level lock simply reveals the next collision boundary.
A named test lock is a scheduler critical section
Playwright's new lock field solves a different problem. Browser contexts isolate cookies and in-memory browser state, but they do not isolate resources outside the context. Two otherwise independent tests may still edit the same payment-sandbox account, toggle one tenant-wide setting, consume the same single-use token, read from one email inbox, or reset one external test database.
A lock lets those few jobs serialize without reducing the entire run to workers: 1:
import { test, expect } from '@playwright/test';
test('changes the shared merchant payout schedule', {
lock: 'payments-sandbox:merchant-17',
}, async ({ page }) => {
await page.goto('/settings/payouts');
await page.getByRole('button', { name: 'Weekly' }).click();
await expect(page.getByText('Payout schedule updated')).toBeVisible();
});
test('rotates a key and verifies the shared inbox', {
lock: ['crm-sandbox:tenant-4', 'email-inbox:operations'],
}, async ({ page }) => {
// Playwright starts this only when both resources are available.
});
The implementation acquires all locks required by a job before it starts and releases them after the job finishes. Acquiring the set at once avoids tests each holding one lock while waiting forever for another. Waiting in the scheduler also does not consume the test's timeout.
There is an easy performance trap. In Playwright's default and serial modes, tests in one file are grouped and run in order. A lock declared by a test in that group is therefore held for the duration of the whole file job, not just the few lines that mutate the resource. Putting one locked test at the bottom of a large default-mode file can serialize much more work than its author intended.
Keep a genuinely locked scenario in a small file or deliberately structured group. Do not apply a broad lock at the top of a directory merely because that makes a flaky build turn green.
The lock does not cross every boundary named "parallel"
The release correctly says locks work across files, worker processes, and projects. Those are all scheduled by one Playwright test invocation.
The implementation makes this scope visible: the dispatcher computes held lock names from the worker slots it owns and skips queued jobs whose names conflict. A second Playwright process has a different dispatcher and cannot see that in-memory set. The sharding guide shows the common CI design clearly: matrix shards run as separate jobs, often on separate machines.
This creates a critical operator rule:
Treat a Playwright lock as run-local coordination unless your own architecture proves a wider shared coordinator exists.
Suppose four GitHub Actions jobs each run one Playwright shard. Every shard contains a test with lock: 'payments-sandbox:merchant-17'. The name prevents overlap among jobs inside each shard's process. It does not, by itself, prevent all four CI jobs from changing merchant 17 concurrently.
When the resource crosses runner processes, use one of these instead:
- assign a different sandbox tenant, account, inbox, database schema, or API key to each shard;
- route all tests for that resource into one dedicated, unsharded CI job;
- acquire an external lease with atomic ownership, expiry, and fencing before the Playwright process starts;
- ask the provider for more test accounts rather than turning one account into a global queue.
A database advisory lock or Redis lease may coordinate distributed jobs, but it introduces lease expiry, crash recovery, stale-owner, and credential questions. For a small suite, one dedicated job is often easier to reason about than building a miniature distributed lock service.
Partition first, lock second
Playwright 1.63's parallelism documentation now places named locks beside more scalable isolation patterns. That ordering matters.
- Use
testInfo.testIdto derive a record identifier unique to the test. - Use
testInfo.outputPath()for files so screenshots, exports, and downloads cannot overwrite one another. - Use
workerIndexorparallelIndexto assign a dataset or test user to a worker. - Use separate preview ports and base URLs for environment variants.
- Use a named lock only when the resource is intentionally singular or cannot be partitioned at reasonable cost.
This decision table keeps the control close to the actual collision.
| Collision | Preferred control | Why a Playwright lock is not the first choice |
|---|---|---|
| Two preview processes from one Astro root | Unique ports plus --ignore-lock, owned by webServer |
The processes are meant to coexist; serialization wastes the topology |
| Two tests create the same order ID | Unique ID derived from testId |
A lock hides a data-generation defect and reduces throughput |
Workers write export.csv to one path |
testInfo.outputPath('export.csv') |
Per-test output directories already remove the shared resource |
| Every browser project changes one tenant-wide setting | One narrow named lock | The state is genuinely shared inside the run |
| Several tests require one provider sandbox account | Separate accounts if available; otherwise one named lock per account | Locking is acceptable only within one Playwright invocation |
| Four CI shards use one provider account | Account per shard, dedicated job, or external lease | Run-local lock names are not a cross-job coordinator |
| A stale server occupies the expected CI port | Fail startup with reuseExistingServer: false, then clean up |
Reusing or locking around the wrong server produces invalid evidence |
| The product fails under legitimate concurrent users | Fix the product and keep a concurrent test | Serializing the test would conceal the customer-visible race |
The final row is the most important failure mode. Locks are for test fixtures and external systems that are truly exclusive, not for making real concurrency bugs disappear. If two customers can update separate carts at the same time, the test suite should preserve that concurrency. If two administrators are allowed to edit one setting, the application needs conflict semantics, not a test-runner mutex.
Model the suite as a resource graph
Worker count alone is a poor description of an E2E topology. Record the resources each lane owns and the ones it borrows.
browser_test_topology:
run_id: github-run-id-and-attempt
shard: 2-of-4
process_scope:
playwright_invocation: shard-2
preview_servers:
- name: catalog-a
root: fixtures/catalog-a
port: 5211
base_url: http://127.0.0.1:5211
owner: playwright-web-server
astro_lock: bypassed
- name: catalog-b
root: fixtures/catalog-b
port: 5212
base_url: http://127.0.0.1:5212
owner: playwright-web-server
astro_lock: bypassed
partitioned_state:
account_namespace: e2e-run-12345-shard-2
database_schema: e2e_12345_2
output_root: test-results/run-12345/shard-2
run_local_locks:
- crm-sandbox:tenant-4
- email-inbox:operations
external_coordination:
resource: payments-sandbox:merchant-17
strategy: dedicated-unsharded-job
teardown:
stop_preview_processes: required
delete_partitioned_records: required
release_external_lease: not-applicable
This is not configuration that every project must literally adopt. It is a review asset. A failed run should reveal which server answered, which data namespace it used, which locks narrowed concurrency, and whether another process could have touched the same external state.
It also helps explain timing. If the suite slows after a new lock appears, inspect the lock's file grouping and resource name before buying larger runners. More CPU cannot improve a queue whose critical section is one shared merchant account.
Failure modes worth testing deliberately
An ignored Astro lock leaves a process behind
The harness is interrupted before teardown, so an untracked preview keeps listening. On the next CI run, a permissive reuseExistingServer: true accepts it. The new tests now run against an old build. Keep reuse disabled in CI, record server logs by name, and treat an occupied expected port as a failed precondition.
One locked test serializes a large file
A test mutates the shared CRM tenant for ten seconds, but its default-mode file takes four minutes. Because the file is one scheduling group, the lock can occupy the full job. Move the critical scenario into a small file and measure again.
Shards agree on a lock name but still collide
Each process correctly enforces email-inbox:operations locally. Separate matrix jobs still consume each other's messages. Partition the inbox address by run and shard, or place inbox-mutating tests in one job.
A lock converts a production race into a green build
Two tests once exposed lost updates in cart or booking state. Adding a lock stops the race and makes CI stable, but customers still exercise the unsafe path. Use locks only for artificial test constraints; preserve concurrency wherever the product promises it.
A resource name is too broad
lock: 'database' serializes tests that touch unrelated tenants and tables. Name the smallest exclusive unit, such as tenant-settings:4, but do not make names so dynamic that two tests addressing the same resource spell them differently. Keep a small registry of lock names and owners.
A practical migration sequence
Start with evidence rather than flags.
- List every current flaky or serial test and name the resource that collides.
- Classify it as a process address, generated file, application record, worker dataset, fixed external account, or cross-job service.
- Give preview servers explicit roots, ports, readiness URLs, base URLs, and a lifecycle owner.
- Partition records, files, users, schemas, and inboxes wherever the suite can create more than one.
- Apply Playwright 1.63 locks only to the remaining run-local critical sections.
- Split locked tests out of large default-mode files so lock duration matches useful work.
- Audit sharded and independently triggered workflows separately; route or lease anything shared beyond one invocation.
- Record the resource map in reports so a failure can be tied to the environment and state that produced it.
Astro's new flag and Playwright's new lock are both useful because they are narrow. The first permits intentional process multiplicity while giving up Astro's canonical process tracking. The second preserves broad test parallelism while scheduling a few exclusive jobs. Reliability comes from keeping those meanings narrow instead of turning either mechanism into a universal answer to flakiness.