Frontend developer debugging an out-of-order Fetch response race condition in a JavaScript search interface

Keep an Older Fetch Response From Replacing the Latest Search

Fri, Sep 25, 2026

A search interface can return two successful HTTP responses and still display the wrong answer. The canonical failure is simple: the user starts query A, then starts query B. B completes first and correctly renders B. A completes later, and an older callback blindly replaces the current interface with A. Network success, HTTP status, and the fact that every promise eventually settled do not answer the acceptance question that matters: which search intent owned each UI mutation when that mutation was attempted?

This playbook isolates that question. The fixture uses one tab, one framework-free search view, same-origin loopback HTTP, synthetic text-only results, and a fixed Cache-Control: no-store policy. There are no real accounts, writes, service workers, extensions, third-party endpoints, authentication generations, cross-tab messages, framework state libraries, cache validators, or deployment infrastructure.

The laboratory controls two independent boundaries: server response release and an asynchronous post-body transform. That distinction is essential because Fetch cancellation and UI commit authorization solve different problems.

The acceptance rule is stronger than “cancel the previous request”: every asynchronous operation captures an ownership identity, and every relevant result, error, loading, and cleanup mutation verifies that identity immediately before synchronous commit. Abort remains useful for stopping participating pending work, but it is not the source of correctness.

Define which search intent owns the interface

Start with an application contract, not with HTTP completion order.

For this fixture, a declared search, reset, dispose, or reinitialize transition creates a new intent identity. An operation started for A owns UI changes only while its captured identity remains the current identity and the view remains active. Once B starts, A is stale even if A has not received a byte. Once the input is cleared, neither A nor B owns the empty view. Once the view is disposed, no outstanding search owns it.

That gives an independent oracle:

State field

Ownership contract

Visible query

Describes the current active intent, not the most recently completed response

Results

May be committed only by the current active search

Error

May be committed only by the current active search

Loading

Describes current-intent work; stale cleanup cannot clear it

Disposed/reset state

Wins over all earlier search work

This contract is deliberately narrower than general frontend state-management concepts. No Redux, Context, MobX, reducer architecture, or React tutorial is needed to validate ownership at an asynchronous DOM boundary.

The web platform provides cancellation machinery, but it does not invent this application policy. The WHATWG DOM Standard describes AbortSignal in terms of abort algorithms registered and used by APIs that participate in cancellation. That is an API mechanism, not a rule saying “the newest search automatically owns the page.”

Use four review decisions throughout this playbook:

Accept when each asynchronous write path proves current ownership. Refactor when stale work can directly mutate shared UI. Hold when the intended interleaving was never forced or a required browser result was not actually collected. Invalidate whenever reset, disposal, or a newer search terminates an older operation’s authority.

Build a controlled one-tab localhost baseline

The fixture layout is intentionally small:

search-race-lab/
├── server.mjs
├── harness.mjs
└── public/
    ├── index.html
    └── app.js

The recorded research environment is:

Operating system: Debian GNU/Linux 13.3 (trixie)
Node.js:          v22.16.0
Primary browser:  Chromium 144.0.7559.96
Fixture commit:   3f08f8227f45524a7e71f6e0539366da64505909
Server bind:      127.0.0.1:4173
Server command:   node server.mjs
Harness command:  node harness.mjs

Those are installed-build facts, not claims that the living documentation identifies the latest available browser or Node release. The server uses the established node:http API documented for the pinned runtime in the Node.js v22.16.0 HTTP API. That version-specific documentation describes the stable HTTP module, its node:http import, http.createServer(), and server.listen().

This is an HTTP-control experiment, not a repetition of broader REST API integration practices. /api/search is a read-only synthetic endpoint. The mutating laboratory controls are separated under /__control/ and accepted only from loopback.

Expose release gates instead of artificial sleeps

A race test that says “sleep A for 500 ms” proves less than it appears to. Machine load, timer clamping, network behavior, and implementation changes can alter the observed interleaving. Here, server responses remain pending until the laboratory explicitly releases a request ID.

server.mjs is complete:

import http from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';

const HOST = '127.0.0.1';
const PORT = Number(process.env.PORT || 4173);
const ROOT = fileURLToPath(new URL('./public/', import.meta.url));

const pending = new Map();
let serverEvents = [];
let arrival = 0;

const FIXTURES = {
  A: ['A result: atlas', 'A result: amber'],
  B: ['B result: birch', 'B result: blue'],
};

function record(type, fields = {}) {
  serverEvents.push({ seq: serverEvents.length + 1, type, ...fields });
}

function isLoopback(address = '') {
  return address === '127.0.0.1' ||
    address === '::1' ||
    address === '::ffff:127.0.0.1';
}

function json(res, status, value) {
  const body = JSON.stringify(value);
  res.writeHead(status, {
    'Content-Type': 'application/json; charset=utf-8',
    'Content-Length': Buffer.byteLength(body),
    'Cache-Control': 'no-store',
  });
  res.end(body);
}

async function readJson(req) {
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  if (!chunks.length) return {};
  return JSON.parse(Buffer.concat(chunks).toString('utf8'));
}

function holdSearch(req, res, url) {
  const query = url.searchParams.get('q');
  const requestId = url.searchParams.get('requestId');
  const intent = Number(url.searchParams.get('intent'));
  const outcome = url.searchParams.get('outcome') || 'ok';

  if (!FIXTURES[query] || !requestId || !Number.isSafeInteger(intent)) {
    json(res, 400, {
      error: 'q must be A or B; requestId and integer intent are required',
    });
    return;
  }

  if (pending.has(requestId)) {
    json(res, 409, { error: 'duplicate requestId' });
    return;
  }

  const item = {
    req,
    res,
    query,
    requestId,
    intent,
    outcome,
    arrival: ++arrival,
    closed: false,
  };

  pending.set(requestId, item);

  record('search_arrived', {
    requestId,
    query,
    intent,
    outcome,
    arrival: item.arrival,
  });

  res.on('close', () => {
    if (pending.get(requestId) === item) {
      item.closed = true;
      pending.delete(requestId);
      record('search_client_closed', { requestId, query, intent });
    }
  });
}

function release(requestId) {
  const item = pending.get(requestId);

  if (!item) {
    return {
      ok: false,
      status: 404,
      error: 'request is not pending',
    };
  }

  pending.delete(requestId);

  const { res, query, intent, outcome } = item;

  if (res.destroyed || res.writableEnded) {
    record('release_skipped_closed', { requestId, query, intent });
    return {
      ok: false,
      status: 409,
      error: 'response already closed',
    };
  }

  if (outcome === 'fail') {
    record('search_released', {
      requestId,
      query,
      intent,
      outcome: 'fail',
    });

    json(res, 500, {
      query,
      requestId,
      intent,
      error: Synthetic failure for ${query},
    });
  } else {
    record('search_released', {
      requestId,
      query,
      intent,
      outcome: 'ok',
    });

    json(res, 200, {
      query,
      requestId,
      intent,
      results: FIXTURES[query],
    });
  }

  return { ok: true, status: 200 };
}

async function serveStatic(res, pathname) {
  const relative = pathname === '/' ? 'index.html' : pathname.slice(1);
  const normalized = normalize(relative)
    .replace(/^(\.\.(\/|\\|$))+/, '');
  const file = join(ROOT, normalized);

  if (!file.startsWith(ROOT)) {
    json(res, 403, { error: 'forbidden' });
    return;
  }

  try {
    const body = await readFile(file);
    const type = extname(file) === '.js'
      ? 'text/javascript; charset=utf-8'
      : 'text/html; charset=utf-8';

    res.writeHead(200, {
      'Content-Type': type,
      'Content-Length': body.length,
      'Cache-Control': 'no-store',
    });

    res.end(body);
  } catch {
    json(res, 404, { error: 'not found' });
  }
}

const server = http.createServer(async (req, res) => {
  const url = new URL(
    req.url,
    http://${req.headers.host || ${HOST}:${PORT},
  );

  if (url.pathname.startsWith('/__control/')) {
    if (!isLoopback(req.socket.remoteAddress)) {
      json(res, 403, {
        error: 'control endpoints are loopback-only',
      });
      return;
    }

    try {
      if (
        req.method === 'GET' &&
        url.pathname === '/__control/state'
      ) {
        json(res, 200, {
          pending: [...pending.values()].map(
            ({
              requestId,
              query,
              intent,
              outcome,
              arrival,
            }) => ({
              requestId,
              query,
              intent,
              outcome,
              arrival,
            }),
          ),
          events: serverEvents,
        });
        return;
      }

      if (
        req.method === 'POST' &&
        url.pathname === '/__control/release'
      ) {
        const { requestId } = await readJson(req);
        const result = release(requestId);
        json(res, result.status, result);
        return;
      }

      if (
        req.method === 'POST' &&
        url.pathname === '/__control/reset'
      ) {
        for (const item of pending.values()) {
          item.res.destroy();
        }

        pending.clear();
        serverEvents = [];
        arrival = 0;

        json(res, 200, { ok: true });
        return;
      }
    } catch (error) {
      json(res, 400, {
        error: String(error.message || error),
      });
      return;
    }

    json(res, 404, { error: 'unknown control endpoint' });
    return;
  }

  if (
    req.method === 'GET' &&
    url.pathname === '/api/search'
  ) {
    holdSearch(req, res, url);
    return;
  }

  if (req.method === 'GET') {
    await serveStatic(res, url.pathname);
    return;
  }

  json(res, 405, { error: 'method not allowed' });
});

server.listen(PORT, HOST, () => {
  console.log(
    search-race-lab listening on http://${HOST}:${PORT},
  );
});

Start it with:

node server.mjs

To see requests reach the server:

curl -s http://127.0.0.1:4173/__control/state

To release B and then A:

curl -s \
  -X POST \
  -H 'Content-Type: application/json' \
  -d '{"requestId":"r2"}' \
  http://127.0.0.1:4173/__control/release

curl -s \
  -X POST \
  -H 'Content-Type: application/json' \
  -d '{"requestId":"r1"}' \
  http://127.0.0.1:4173/__control/release

Those commands determine ordering. Any short poll interval later in the client merely observes that a gate has been reached; it never creates the race.

Implement the deliberately unguarded search

The interface is deliberately plain. That keeps the experiment on asynchronous ownership rather than on frontend and backend fundamentals, framework lifecycles, or component abstractions.

Complete public/index.html:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport"
        content="width=device-width,initial-scale=1">
  <title>Search intent ownership lab</title>
</head>
<body>
  <main id="view">
    <h1>Search intent ownership lab</h1>

    <form id="search-form">
      <label>
        Query
        <input id="query"
               autocomplete="off"
               value="A">
      </label>

      <button type="submit">Search</button>
      <button id="clear" type="button">Clear</button>
      <button id="dispose" type="button">Dispose</button>
      <button id="reinit"
              type="button">Reinitialize</button>

      <label>
        Variant
        <select id="variant">
          <option value="unguarded">unguarded</option>
          <option value="abort-only">abort-only</option>
          <option value="ownership">ownership</option>
        </select>
      </label>
    </form>

    <p>
      View:
      <strong id="lifecycle">active</strong>
    </p>

    <p>
      Visible query:
      <strong id="visible-query">(empty)</strong>
    </p>

    <p>
      Loading:
      <strong id="loading">false</strong>
    </p>

    <p>
      Error:
      <strong id="error">(none)</strong>
    </p>

    <ul id="results"></ul>

    <h2>Ledger</h2>
    <pre id="ledger"></pre>
  </main>

  <script type="module" src="/app.js"></script>
</body>
</html>

The complete client below deliberately carries all three variants so every comparison uses the same fixture and instrumentation. In unguarded, attemptWrite() accepts every write. In abort-only, a later search aborts the earlier controller but still accepts every eventual UI write. Only ownership makes the token authoritative.

Complete public/app.js:

const form = document.querySelector('#search-form');
const input = document.querySelector('#query');
const variantSelect = document.querySelector('#variant');
const visibleQuery = document.querySelector('#visible-query');
const loading = document.querySelector('#loading');
const errorBox = document.querySelector('#error');
const results = document.querySelector('#results');
const lifecycle = document.querySelector('#lifecycle');
const ledgerBox = document.querySelector('#ledger');

let sequence = 0;
let generation = 0;
let requestCounter = 0;
let currentToken = null;
let currentOperation = null;
let viewActive = true;
let ledger = [];

const transformGates = new Map();

function record(type, fields = {}) {
  const event = {
    seq: ++sequence,
    type,
    ...fields,
  };

  ledger.push(event);

  ledgerBox.textContent = ledger
    .map((item) => JSON.stringify(item))
    .join('\n');

  return event;
}

function snapshot() {
  return {
    lifecycle: lifecycle.textContent,
    visibleQuery: visibleQuery.textContent,
    loading: loading.textContent === 'true',
    error:
      errorBox.textContent === '(none)'
        ? null
        : errorBox.textContent,
    results: [...results.children]
      .map((li) => li.textContent),
    currentGeneration:
      currentToken?.generation ?? null,
    currentKind:
      currentToken?.kind ?? null,
  };
}

function createTransition(kind) {
  const token = Object.freeze({
    generation: ++generation,
    kind,
  });

  currentToken = token;

  record('intent_transition', {
    generation: token.generation,
    kind,
  });

  return token;
}

function owns(token) {
  return viewActive && currentToken === token;
}

function attemptWrite(operation, field, mutate) {
  const accepted =
    operation.variant === 'ownership'
      ? owns(operation.token)
      : true;

  record('ui_write_attempt', {
    requestId: operation.requestId,
    query: operation.query,
    intent: operation.token.generation,
    field,
    accepted,
    variant: operation.variant,
  });

  // The final ownership decision and mutation are synchronous.
  // Do not insert an await between these two operations.
  if (accepted) mutate();

  return accepted;
}

function setResults(items) {
  results.replaceChildren();

  for (const text of items) {
    const li = document.createElement('li');
    li.textContent = text;
    results.append(li);
  }
}

function gatePromise(requestId) {
  let gate = transformGates.get(requestId);

  if (!gate) {
    let release;

    const promise = new Promise((resolve) => {
      release = resolve;
    });

    gate = { promise, release };
    transformGates.set(requestId, gate);
  }

  return gate.promise;
}

function releaseTransform(requestId) {
  const gate = transformGates.get(requestId);

  if (!gate) return false;

  transformGates.delete(requestId);
  gate.release();

  record('transform_released', { requestId });

  return true;
}

async function transformBody(
  body,
  operation,
  holdTransform,
) {
  record('transform_start', {
    requestId: operation.requestId,
    query: operation.query,
    intent: operation.token.generation,
  });

  if (holdTransform) {
    record('transform_held', {
      requestId: operation.requestId,
      query: operation.query,
      intent: operation.token.generation,
    });

    await gatePromise(operation.requestId);
  } else {
    await Promise.resolve();
  }

  record('transform_end', {
    requestId: operation.requestId,
    query: operation.query,
    intent: operation.token.generation,
  });

  return body;
}

function abortOperation(operation, reason) {
  if (!operation) return;

  record('abort_call', {
    requestId: operation.requestId,
    query: operation.query,
    intent: operation.token.generation,
    reason,
    bodyComplete: operation.bodyComplete,
  });

  operation.controller.abort();
}

async function search(query, options = {}) {
  if (!viewActive) {
    throw new Error(
      'view is disposed; reinitialize before searching',
    );
  }

  const variant =
    options.variant || variantSelect.value;

  const previous = currentOperation;

  // Ownership is invalidated before the previous
  // controller is aborted.
  const token = createTransition('search');

  const controller = new AbortController();
  const requestId = r${++requestCounter};

  const operation = {
    query,
    requestId,
    token,
    controller,
    variant,
    bodyComplete: false,
  };

  currentOperation = operation;

  if (
    (variant === 'abort-only' ||
      variant === 'ownership') &&
    previous
  ) {
    abortOperation(previous, 'new-search');
  }

  attemptWrite(
    operation,
    'visible-query-start',
    () => {
      visibleQuery.textContent = query;
    },
  );

  attemptWrite(
    operation,
    'error-clear-start',
    () => {
      errorBox.textContent = '(none)';
    },
  );

  attemptWrite(
    operation,
    'results-clear-start',
    () => {
      setResults([]);
    },
  );

  attemptWrite(
    operation,
    'loading-start',
    () => {
      loading.textContent = 'true';
    },
  );

  record('search_started', {
    requestId,
    query,
    intent: token.generation,
    variant,
  });

  const params = new URLSearchParams({
    q: query,
    requestId,
    intent: String(token.generation),
    outcome: options.outcome || 'ok',
  });

  try {
    const response = await fetch(
      /api/search?${params},
      {
        signal: controller.signal,
        cache: 'no-store',
      },
    );

    record('response_headers', {
      requestId,
      query,
      intent: token.generation,
      status: response.status,
    });

    const body = await response.json();

    operation.bodyComplete = true;

    record('body_complete', {
      requestId,
      query,
      intent: token.generation,
      status: response.status,
    });

    const transformed = await transformBody(
      body,
      operation,
      Boolean(options.holdTransform),
    );

    if (!response.ok) {
      const failure = new Error(
        transformed.error ||
          HTTP ${response.status},
      );

      failure.name = 'SyntheticSearchError';
      throw failure;
    }

    attemptWrite(
      operation,
      'results',
      () => {
        visibleQuery.textContent = query;
        errorBox.textContent = '(none)';
        setResults(transformed.results);
      },
    );
  } catch (err) {
    const observed = {
      name: err?.name || 'Error',
      constructorName:
        err?.constructor?.name || 'unknown',
      message: String(err?.message || err),
    };

    record('request_rejected', {
      requestId,
      query,
      intent: token.generation,
      ...observed,
    });

    if (err?.name !== 'AbortError') {
      attemptWrite(
        operation,
        'error',
        () => {
          visibleQuery.textContent = query;
          setResults([]);
          errorBox.textContent = observed.message;
        },
      );
    } else {
      record('abort_handled_without_error_ui', {
        requestId,
        query,
        intent: token.generation,
      });
    }
  } finally {
    attemptWrite(
      operation,
      'loading-finally',
      () => {
        loading.textContent = 'false';
      },
    );

    if (variant === 'ownership') {
      if (
        currentOperation === operation &&
        currentToken === token
      ) {
        currentOperation = null;
      }
    } else if (currentOperation === operation) {
      currentOperation = null;
    }

    record('finally_complete', {
      requestId,
      query,
      intent: token.generation,
    });
  }

  return {
    requestId,
    query,
    intent: token.generation,
  };
}

function clearView() {
  const previous = currentOperation;

  // Invalidate before cancellation.
  createTransition('reset');
  currentOperation = null;

  if (previous) {
    abortOperation(previous, 'reset');
  }

  input.value = '';
  visibleQuery.textContent = '(empty)';
  errorBox.textContent = '(none)';
  setResults([]);
  loading.textContent = 'false';

  record('view_reset', snapshot());
}

function disposeView() {
  const previous = currentOperation;

  // Invalidate before cancellation.
  createTransition('dispose');
  currentOperation = null;
  viewActive = false;

  if (previous) {
    abortOperation(previous, 'dispose');
  }

  lifecycle.textContent = 'disposed';
  visibleQuery.textContent = '(disposed)';
  errorBox.textContent = '(none)';
  setResults([]);
  loading.textContent = 'false';

  record('view_disposed', snapshot());
}

function reinitializeView() {
  viewActive = true;

  // Fresh monotonic identity. generation is never reset.
  createTransition('reinitialize');

  currentOperation = null;
  lifecycle.textContent = 'active';
  visibleQuery.textContent = '(empty)';
  errorBox.textContent = '(none)';
  setResults([]);
  loading.textContent = 'false';

  record('view_reinitialized', snapshot());
}

async function control(path, body) {
  const response = await fetch(path, {
    method:
      body === undefined ? 'GET' : 'POST',
    headers:
      body === undefined
        ? undefined
        : {
            'Content-Type': 'application/json',
          },
    body:
      body === undefined
        ? undefined
        : JSON.stringify(body),
    cache: 'no-store',
  });

  const value = await response.json();

  if (!response.ok) {
    throw new Error(
      ${path}: ${response.status} +
      ${JSON.stringify(value)},
    );
  }

  return value;
}

async function waitForPending(requestId) {
  for (;;) {
    const state =
      await control('/__control/state');

    if (
      state.pending.some(
        (item) => item.requestId === requestId,
      )
    ) {
      return state;
    }

    // Observation polling only.
    // This delay never creates response ordering.
    await new Promise((resolve) =>
      setTimeout(resolve, 5),
    );
  }
}

async function waitForEvent(type, requestId) {
  for (;;) {
    const match = ledger.find(
      (event) =>
        event.type === type &&
        event.requestId === requestId,
    );

    if (match) return match;

    // Observation polling only.
    await new Promise((resolve) =>
      setTimeout(resolve, 5),
    );
  }
}

async function releaseServer(requestId) {
  return control(
    '/__control/release',
    { requestId },
  );
}

async function resetLab() {
  await control('/__control/reset', {});

  for (const gate of transformGates.values()) {
    gate.release();
  }

  transformGates.clear();

  sequence = 0;
  ledger = [];
  ledgerBox.textContent = '';
  requestCounter = 0;
  currentOperation = null;
  viewActive = true;

  // Deliberately do not reset generation.
  createTransition('reinitialize');

  lifecycle.textContent = 'active';
  visibleQuery.textContent = '(empty)';
  errorBox.textContent = '(none)';
  setResults([]);
  loading.textContent = 'false';
}

function checkpoint(label) {
  const value = {
    label,
    state: snapshot(),
  };

  record('checkpoint', value);
  return value;
}

async function runCase(name, variant) {
  await resetLab();
  variantSelect.value = variant;

  const checkpoints = [];

  if (name === 'in-order') {
    const a = search('A', { variant });

    await waitForPending('r1');
    checkpoints.push(checkpoint('A pending'));

    await releaseServer('r1');
    await a;

    checkpoints.push(
      checkpoint('A rendered'),
    );

    const b = search('B', { variant });

    await waitForPending('r2');
    await releaseServer('r2');
    await b;

    checkpoints.push(
      checkpoint('B rendered'),
    );
  }

  if (name === 'reordered') {
    const a = search('A', { variant });
    await waitForPending('r1');

    const b = search('B', { variant });
    await waitForPending('r2');

    checkpoints.push(
      checkpoint(
        'A and B pending; active B',
      ),
    );

    await releaseServer('r2');
    await b;

    checkpoints.push(
      checkpoint('B released and settled'),
    );

    const state =
      await control('/__control/state');

    if (
      state.pending.some(
        (item) => item.requestId === 'r1',
      )
    ) {
      await releaseServer('r1');
    }

    await a;

    checkpoints.push(
      checkpoint(
        'A released or already aborted',
      ),
    );
  }

  if (name === 'post-body') {
    const a = search('A', {
      variant,
      holdTransform: true,
    });

    await waitForPending('r1');
    await releaseServer('r1');
    await waitForEvent(
      'transform_held',
      'r1',
    );

    checkpoints.push(
      checkpoint(
        'A body complete; transform held',
      ),
    );

    const b = search('B', { variant });

    await waitForPending('r2');
    await releaseServer('r2');
    await b;

    checkpoints.push(
      checkpoint(
        'B rendered while A transform held',
      ),
    );

    releaseTransform('r1');
    await a;

    checkpoints.push(
      checkpoint('A transform released'),
    );
  }

  if (name === 'stale-error') {
    const a = search('A', {
      variant,
      outcome: 'fail',
      holdTransform: true,
    });

    await waitForPending('r1');
    await releaseServer('r1');
    await waitForEvent(
      'transform_held',
      'r1',
    );

    const b = search('B', { variant });

    await waitForPending('r2');
    await releaseServer('r2');
    await b;

    checkpoints.push(
      checkpoint(
        'B success visible; A failure transform held',
      ),
    );

    releaseTransform('r1');
    await a;

    checkpoints.push(
      checkpoint(
        'A delayed failure released',
      ),
    );
  }

  if (name === 'stale-finally') {
    const a = search('A', {
      variant,
      holdTransform: true,
    });

    await waitForPending('r1');
    await releaseServer('r1');
    await waitForEvent(
      'transform_held',
      'r1',
    );

    const b = search('B', { variant });
    await waitForPending('r2');

    checkpoints.push(
      checkpoint(
        'B pending; A transform held',
      ),
    );

    releaseTransform('r1');
    await a;

    checkpoints.push(
      checkpoint(
        'A finally ran while B pending',
      ),
    );

    await releaseServer('r2');
    await b;

    checkpoints.push(
      checkpoint('B settled'),
    );
  }

  if (name === 'clear-pending') {
    const a = search('A', { variant });

    await waitForPending('r1');

    clearView();

    checkpoints.push(
      checkpoint('cleared while A pending'),
    );

    await a;

    checkpoints.push(
      checkpoint(
        'A abort/finally settled',
      ),
    );
  }

  if (name === 'dispose-reinit') {
    const a = search('A', {
      variant,
      holdTransform: true,
    });

    await waitForPending('r1');
    await releaseServer('r1');
    await waitForEvent(
      'transform_held',
      'r1',
    );

    disposeView();

    checkpoints.push(
      checkpoint(
        'disposed with A transform held',
      ),
    );

    reinitializeView();

    checkpoints.push(
      checkpoint(
        'reinitialized empty view',
      ),
    );

    releaseTransform('r1');
    await a;

    checkpoints.push(
      checkpoint(
        'old A transform released after reinit',
      ),
    );
  }

  return {
    name,
    variant,
    checkpoints,
    snapshot: snapshot(),
    ledger: structuredClone(ledger),
  };
}

form.addEventListener(
  'submit',
  (event) => {
    event.preventDefault();

    const query = input.value.trim();

    if (query) {
      void search(query);
    }
  },
);

document
  .querySelector('#clear')
  .addEventListener(
    'click',
    clearView,
  );

document
  .querySelector('#dispose')
  .addEventListener(
    'click',
    disposeView,
  );

document
  .querySelector('#reinit')
  .addEventListener(
    'click',
    reinitializeView,
  );

window.lab = {
  search,
  clearView,
  disposeView,
  reinitializeView,
  releaseTransform,
  releaseServer,
  serverState: () =>
    control('/__control/state'),
  resetLab,
  runCase,
  snapshot,
  ledger: () =>
    structuredClone(ledger),
};

record('page_ready', {
  userAgent: navigator.userAgent,
});

Results are inserted through created li elements and textContent; no server result is inserted as HTML.

The first control is intentionally boring:

await lab.runCase('in-order', 'unguarded');

The expected sequence is A starts → A releases → A renders → B starts → B releases → B renders. The final view is B. That passing result demonstrates why a test suite that only exercises request order can miss the defect entirely.

Release the newer response first

Now force the interleaving that matters:

await lab.runCase('reordered', 'unguarded');

The fixture starts A as r1, observes r1 pending at the server, starts B as r2, observes r2 pending, releases r2, waits for B to settle, then releases r1.

After B starts, the independent application oracle says the active search intent is B. That expectation is not derived from which response happens to arrive next.

The WHATWG HTML Standard: Web Application APIs, accessed September 25, 2026, defines event-loop tasks, task queues, and microtask machinery. It does not assign semantic ownership of an application’s search result. The standard even allows implementation-defined selection among runnable task queues in its processing model. Therefore “the browser will usually execute these callbacks in the order I expect” is not a UI-ownership rule.

Proposed test and expected oracle:

Checkpoint

Current intent

Expected correct UI

Unguarded expected UI

A pending

A

query A, loading true, no results

same

A and B pending

B

query B, loading true

same

Release B

B

B results, no error, loading false

B results

Release A

B

still B results

A results: defect

The last line is the canonical fetch stale response race condition: A’s handler does nothing illegal at the JavaScript or HTTP layer. It simply has no authorization to modify the current view.

Record write attempts as well as final screen state

Do not stop at screenshot comparison.

Suppose stale A writes the wrong results and a later operation immediately restores B. A final screenshot could look correct while the user experienced a transient rollback. The ledger must therefore record attempted mutations.

For each write, the fixture records:

requestId
query
intent generation
field being mutated
variant
accepted true/false

The broader timeline records search_started, server arrival, response headers, body_complete, transform start/end, abort_call, request_rejected, every ui_write_attempt, and finally_complete.

For the repaired implementation, the strongest evidence is not merely “final results are B.” It is:

A results write attempt:
requestId = r1
query     = A
intent    = old generation
accepted  = false

That event demonstrates enforcement at the commit boundary.

Test abort while the older fetch is pending

The first comparison adds a fresh AbortController to each invocation. When B becomes current, B’s transition invalidates A first, then the code calls A’s controller.

That sequence matters:

const previous = currentOperation;
const token = createTransition('search');
const controller = new AbortController();

currentOperation = operation;

if (previous) {
  abortOperation(previous, 'new-search');
}

The ownership transition precedes cancellation. Even if cancellation invokes callbacks, rejects a promise, or proves ineffective at a later stage, A has already lost UI authority.

Documented behavior. The Chrome for Developers article “Abortable fetch”, published September 28, 2017, explains that aborting an active Fetch also aborts response-body reading, and demonstrates handling a rejection whose name is AbortError. This is an established 2017 explanation, not a capability introduced in 2026.

The Fetch Standard defines Fetch abort steps and ties them to the request’s signal. It also specifies how an abort can reject the Fetch promise and error a still-readable response body.

The pending-A comparison is:

await lab.runCase('reordered', 'abort-only');

Expected sequence:

  1. A reaches the server and remains held.

  2. B starts.

  3. The client calls A.controller.abort().

  4. A’s still-pending Fetch/body operation rejects.

  5. The cancellation path is treated as expected cancellation, not as an error to display.

  6. B is released and renders B.

The fixture logs the rejection constructor, name, and message rather than assuming every abort-capable API presents an identical error object.

For the default AbortController.abort() used here, the standards/vendor material makes AbortError the expected Fetch result under the declared conditions. The pinned Chromium 144.0.7559.96 observation remains uncollected because managed browser policy blocked the loopback page before the test ran. That missing browser evidence requires a hold decision; it is not permission to label the expected rejection as observed.

Even after this comparison passes on the eventual test host, it proves only that abort participates effectively while this Fetch/body operation is pending. It does not prove that abort is sufficient to authorize every later UI write.

Move the race beyond body consumption

The stronger comparison deliberately moves A outside Fetch-controlled work.

Sequence:

  1. Start A.

  2. Release A from the server.

  3. Await response.json().

  4. Record body_complete.

  5. Enter transformBody().

  6. Hold A on a laboratory-owned arbitrary promise.

  7. Start B.

  8. Abort A’s controller as part of B startup.

  9. Complete and render B.

  10. Release A’s transform promise.

The Chrome vendor explanation explicitly notes that calling .abort() after Fetch has already completed is ignored by Fetch. The Fetch Standard is more precise: its abort-Fetch operation rejects the Fetch promise, but that step is a no-op if that promise has already fulfilled; it errors a response body only when that body is still readable.

Nothing in those rules automatically reaches into an unrelated application promise and cancels it.

That is also consistent with the WHATWG DOM Standard: APIs that support an AbortSignal register behavior that reacts to the signal. Promises in general do not spontaneously acquire abort semantics because some earlier API happened to use the same controller.

The intentionally flawed test is:

await lab.runCase('post-body', 'abort-only');

Expected result: B renders first. Releasing A’s transform later resumes A. Because the abort-only variant never checks ownership after that transform, A’s results write is accepted and the final visible results become A.

That is not evidence of a Chromium bug. It is the expected consequence of asking an API-specific cancellation mechanism to enforce an application-level commit policy that it was never given.

Prove that the body finished before aborting

Do not infer this scenario from response headers.

fetch() can fulfill with a Response before its body has been fully consumed. A test that records only “200 headers received” and then aborts has not shown that body reading was outside the cancellation boundary.

The fixture therefore records:

response_headers
body_complete
transform_start
transform_held
abort_call { bodyComplete: true }

response.json() is awaited before bodyComplete becomes true. The Fetch Standard’s body-consumption algorithms distinguish fully reading a response body from merely having a response object or headers.

The required evidence ordering is:

A response_headers
A body_complete
A transform_start
A transform_held
B intent_transition
A abort_call bodyComplete=true
B body_complete
B results write accepted
A transform_released
A transform_end
A stale results write ...

Without that order, the abort-only post-processing conclusion is inconclusive.

Authorize commits with a current-intent token

The repair is not “put more aborts everywhere.” The repair is explicit ownership.

Each declared transition creates a fresh identity:

function createTransition(kind) {
  const token = Object.freeze({
    generation: ++generation,
    kind,
  });

  currentToken = token;
  return token;
}

The generation is monotonically increasing and the object itself is a fresh identity. Search A captures token A. Search B creates token B before aborting A. Reset creates another token. Dispose creates another. Reinitialize creates another.

Each invocation also captures its own controller and operation object. It does not rely on reading a mutable global controller later.

The final authorization check is:

function owns(token) {
  return viewActive && currentToken === token;
}

function attemptWrite(operation, field, mutate) {
  const accepted =
    operation.variant === 'ownership'
      ? owns(operation.token)
      : true;

  record('ui_write_attempt', {
    requestId: operation.requestId,
    query: operation.query,
    intent: operation.token.generation,
    field,
    accepted,
  });

  if (accepted) mutate();

  return accepted;
}

The important property is structural: there is no await between the last ownership check and the synchronous DOM mutation.

This is unsafe:

if (owns(token)) {
  const formatted = await formatResults(data);
  render(formatted);
}

Ownership could change during await formatResults(...).

This is the relevant pattern:

const formatted = await formatResults(data);

if (owns(token)) {
  render(formatted);
}

Or, with centralized instrumentation:

const formatted = await formatResults(data);

attemptWrite(operation, 'results', () => {
  render(formatted);
});

For the post-body test:

await lab.runCase('post-body', 'ownership');

the expected ledger records A’s final results attempt with accepted: false; B remains visible.

Abort still has value. It can reduce work, bandwidth, parsing, or callback activity when the participating API honors the signal. But correctness under this contract comes from commit authorization. The latest active intent remains correct even if old work cannot be cancelled.

Guard errors, loading and finally cleanup

Many partial repairs guard only the success renderer:

if (token === currentToken) {
  renderResults(data);
}

That is insufficient.

A stale operation can still write an error or run cleanup after the current operation starts. Those paths affect the same shared interface and therefore require the same ownership rule.

Consider delayed failure:

  1. A receives the synthetic HTTP 500.

  2. A fully parses the body.

  3. A pauses at the transform gate.

  4. B starts and becomes current.

  5. B succeeds and displays B.

  6. A resumes and throws SyntheticSearchError.

Before A resumes, the independent expected state is:

visible query = B
results       = B results
error         = none
loading       = false

In abort-only, A’s completed Fetch cannot be rescued by cancelling the already-finished network operation. The intentionally flawed catch path accepts A’s error mutation. Expected wrong outcome: the successful B view is replaced by A’s stale error.

In ownership, the catch path still records the attempt, but:

field    = error
query    = A
accepted = false

B remains intact.

Loading has a different failure shape. Hold A after body completion, start B but leave B pending, then release A’s transform. At that checkpoint the independently expected state is:

visible query = B
loading       = true

An unguarded A finally can execute:

loading.textContent = 'false';

and hide B’s spinner even though B remains pending.

Keep cleanup scoped to its own request

Cleanup needs two protections.

First, the loading mutation itself is ownership-guarded:

attemptWrite(
  operation,
  'loading-finally',
  () => {
    loading.textContent = 'false';
  },
);

Second, clearing request bookkeeping uses captured identity:

if (
  currentOperation === operation &&
  currentToken === token
) {
  currentOperation = null;
}

An old finally must not clear or abort whatever controller happens to be globally current at cleanup time.

The stale-finally acceptance case therefore requires:

B pending
A transform released
A loading-finally attempted
A loading-finally accepted=false
visible query remains B
loading remains true

Only B’s own settlement may clear B’s current loading state.

This is why request ownership applies to success, error, start-state changes, and finally cleanup, not merely to renderResults().

Invalidate pending work on reset and disposal

Search B is not the only event that can make A stale.

A user can clear the input while A is pending. The expected visible state must be declared before running the case:

lifecycle     = active
visible query = (empty)
results       = []
error         = none
loading       = false

Reset performs the transition before cancellation:

const previous = currentOperation;
createTransition('reset');
currentOperation = null;

if (previous) {
  abortOperation(previous, 'reset');
}

That ordering means even an immediate cancellation callback belongs to an already-invalidated search.

This lifecycle rule complements broader browser development foundations without turning the article into a general browser tutorial.

Disposal is stronger. Once disposed, viewActive is false. Old A cannot write even if its token somehow remained referenced:

function owns(token) {
  return viewActive && currentToken === token;
}

The dispose-reinit case goes further:

  1. A finishes its body.

  2. A waits in the transform.

  3. Dispose invalidates A.

  4. Reinitialize creates a new identity.

  5. The new active view is empty.

  6. Release old A.

  7. A must not repopulate the new view.

Do not reset a generation counter to zero on reinitialization. Suppose old A captured generation 1, disposal destroys the view, initialization resets the counter, and a new operation also receives 1. A naïve numeric equality test can accidentally resurrect A’s authority.

Use an always-increasing generation, a fresh unique identity object, or both. The fixture deliberately leaves generation untouched in resetLab().

The fixture’s DOM event listeners are page-lifetime listeners rather than dynamically recreated view subscriptions. In a componentized production equivalent, disposal should keep explicit references or a listener-specific signal and remove only listeners owned by that disposed view; it should not indiscriminately remove unrelated page handlers.

Run the ownership comparison matrix

The following table is the acceptance oracle for the commanded release sequences. Because managed browser policy in the documented test environment blocked loopback before the fixture loaded, these are expected outcomes, not observed browser outcomes.

Variant and case

Commanded sequence

Checkpoint

Independent owner

Expected UI

Unguarded, in-order

start A → release A → start B → release B

A settled

A

A results, no error, loading false

Unguarded, in-order

same

B settled

B

B results, no error, loading false

Unguarded, reordered

start A → start B

both pending

B

query B, empty results, loading true

Unguarded, reordered

release B

B settled, A held

B

B results, no error, loading false

Unguarded, reordered

release A

A settles late

B

Actual flawed expectation: A overwrites B

Abort-only, pending-A

start A → start B and abort A

after abort

B

query B; A cancellation must not display an error

Abort-only, pending-A

release B

B settled

B

B results, no error, loading false

Abort-only, post-body

release A body → hold transform → start B and abort A → release B

B settled

B

B results

Abort-only, post-body

release A transform

A resumes

B

Flawed variant accepts A; final results A

Ownership, reordered

start A → start B → release B

B settled

B

B results

Ownership, reordered

release or settle old A if still present

final

B

B results; A write denied

Ownership, post-body

A body complete and held → B complete → release A transform

final

B

B results; A result write accepted = false

Abort-only, stale error

A 500 body complete and held → B succeeds → release A

final

B

Flawed variant shows A error

Ownership, stale error

same

final

B

B results; no error; A error write denied

Abort-only, stale finally

A body held → start B pending → release A

before B settles

B

Flawed variant: loading = false

Ownership, stale finally

same

before B settles

B

loading = true; A cleanup denied

Ownership, clear pending

A pending → Clear

reset checkpoint

reset

empty query and results, no error, loading false

Ownership, dispose and reinitialize

A body held → dispose → reinitialize → release A

final

reinitialized view

active empty view; old A result denied

This table intentionally separates “what the buggy implementation is expected to do” from “what the application contract says should be visible.” Without both columns, a test can accidentally derive its expected value from the same scheduling behavior it is supposed to challenge.

Separate scheduling evidence from browser assumptions

A test qualifies only if its ledger proves the required gate order.

For the canonical stale-success case, require server evidence that both r1 and r2 arrived and that the harness released r2 before r1.

For the post-body case, require A body_complete and A transform_held before B starts and before A’s abort call.

For stale cleanup, require B to remain pending while A’s finally executes.

A hundred naturally passing runs on a fast workstation do not substitute for one run that forces the target interleaving. Conversely, a failure without evidence of the required gate state is not sufficient to diagnose the ownership bug.

Living platform standards describe scheduling semantics; they are not a browser-build lock. Browser acceptance must record the actual named executable and build used for that run.

The primary target for this batch is:

Chromium 144.0.7559.96

A future Firefox, Safari, Edge, or another Chromium build must be recorded as a separate browser run, not silently merged into the primary result.

Explain the limits of cancellation and debouncing

Cancellation and ownership answer different questions.

Cancellation asks: can some participating asynchronous work be asked to stop?

Ownership asks: if work eventually reaches a UI mutation, is it still authorized to commit?

The DOM Standard provides a generic signaling mechanism because APIs can define how they respond to abort. It specifically describes APIs registering abort algorithms and rejecting participating promise-returning operations with the signal’s abort reason. That model does not state that every arbitrary promise in the same application becomes cancelable.

Fetch participates. An application-created transform promise participates only if the application explicitly designs it to inspect a signal, register an abort listener, race against cancellation, or otherwise terminate itself. Even then, a final ownership guard remains valuable because “work stopped” and “this caller still owns shared UI” are separate invariants.

The browser-vendor Fetch explanation likewise distinguishes aborting a request/body from calling abort after Fetch has completed.

Debouncing is separate again. A debounce policy might wait, for example, until input has been quiet before starting a search. That can reduce how many requests are created. It cannot prove that any two requests that do exist will complete in intent order. Requests can still overlap because of manual submissions, differing server work, retries, multiple interaction paths, or a request already in flight when another intent is declared.

So this is not sufficient:

const search = debounce(runSearch, 300);

Nor is this:

previousController?.abort();
previousController = new AbortController();

The ownership requirement still belongs immediately before the shared-state commit.

The same reasoning applies when considering API latency and performance context: making an endpoint faster can reduce how often a race is noticed, but it does not prove a per-intent mutation rule.

Finally, do not turn client cancellation into a server rollback claim. An HTTP request may already have reached a server before a client stops consuming its result. Whether server work can or should be interrupted is API- and application-specific. This laboratory avoids that second problem completely: /api/search performs no business writes, and cancellation is not claimed to undo server-side work.

Turn the fixture into repeatable acceptance checks

The harness drives Chromium through the DevTools Protocol without a testing framework or browser-mocking layer. It launches the same localhost application that a reviewer can inspect manually.

Complete harness.mjs:

import { spawn } from 'node:child_process';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const ROOT = new URL('./', import.meta.url);
const BASE = 'http://127.0.0.1:4173';
const CHROME =
  process.env.CHROME || 'chromium';

function sleep(ms) {
  return new Promise(
    (resolve) => setTimeout(resolve, ms),
  );
}

async function waitHttp(url, limit = 200) {
  for (let i = 0; i < limit; i++) {
    try {
      const response = await fetch(url, {
        cache: 'no-store',
      });

      if (response.ok) return;
    } catch {}

    await sleep(25);
  }

  throw new Error(
    Timed out waiting for ${url},
  );
}

async function getJson(url) {
  const response = await fetch(url, {
    cache: 'no-store',
  });

  if (!response.ok) {
    throw new Error(
      ${url}: ${response.status},
    );
  }

  return response.json();
}

class Cdp {
  constructor(ws) {
    this.ws = ws;
    this.nextId = 0;
    this.pending = new Map();

    ws.addEventListener(
      'message',
      (event) => {
        const msg = JSON.parse(event.data);

        if (!msg.id) return;

        const waiter =
          this.pending.get(msg.id);

        if (!waiter) return;

        this.pending.delete(msg.id);

        if (msg.error) {
          waiter.reject(
            new Error(
              JSON.stringify(msg.error),
            ),
          );
        } else {
          waiter.resolve(msg.result);
        }
      },
    );
  }

  call(method, params = {}) {
    const id = ++this.nextId;

    return new Promise(
      (resolve, reject) => {
        this.pending.set(
          id,
          { resolve, reject },
        );

        this.ws.send(
          JSON.stringify({
            id,
            method,
            params,
          }),
        );
      },
    );
  }
}

async function connectPage() {
  for (let i = 0; i < 200; i++) {
    try {
      const targets =
        await getJson(
          'http://127.0.0.1:9222/json',
        );

      const page = targets.find(
        (target) =>
          target.type === 'page',
      );

      if (page?.webSocketDebuggerUrl) {
        const ws = new WebSocket(
          page.webSocketDebuggerUrl,
        );

        await new Promise(
          (resolve, reject) => {
            ws.addEventListener(
              'open',
              resolve,
              { once: true },
            );

            ws.addEventListener(
              'error',
              reject,
              { once: true },
            );
          },
        );

        return new Cdp(ws);
      }
    } catch {}

    await sleep(25);
  }

  throw new Error(
    'Timed out waiting for Chromium ' +
    'DevTools page target',
  );
}

async function evaluate(
  cdp,
  expression,
  awaitPromise = true,
) {
  const result =
    await cdp.call(
      'Runtime.evaluate',
      {
        expression,
        awaitPromise,
        returnByValue: true,
      },
    );

  if (result.exceptionDetails) {
    throw new Error(
      JSON.stringify(
        result.exceptionDetails,
      ),
    );
  }

  return result.result.value;
}

function finalState(result) {
  return result.snapshot;
}

function hasDenied(result, field) {
  return result.ledger.some(
    (event) =>
      event.type ===
        'ui_write_attempt' &&
      event.field === field &&
      event.accepted === false,
  );
}

const EXPECT = [
  [
    'in-order',
    'unguarded',
    (result) =>
      finalState(result)
        .visibleQuery === 'B' &&
      finalState(result)
        .results[0]
        ?.startsWith('B result'),
  ],

  [
    'reordered',
    'unguarded',
    (result) =>
      finalState(result)
        .visibleQuery === 'A' &&
      finalState(result)
        .results[0]
        ?.startsWith('A result'),
  ],

  [
    'reordered',
    'ownership',
    (result) =>
      finalState(result)
        .visibleQuery === 'B' &&
      finalState(result)
        .results[0]
        ?.startsWith('B result'),
  ],

  [
    'reordered',
    'abort-only',
    (result) =>
      finalState(result)
        .visibleQuery === 'B' &&
      finalState(result)
        .results[0]
        ?.startsWith('B result'),
  ],

  [
    'post-body',
    'abort-only',
    (result) =>
      finalState(result)
        .visibleQuery === 'A' &&
      finalState(result)
        .results[0]
        ?.startsWith('A result'),
  ],

  [
    'post-body',
    'ownership',
    (result) =>
      finalState(result)
        .visibleQuery === 'B' &&
      hasDenied(result, 'results'),
  ],

  [
    'stale-error',
    'abort-only',
    (result) =>
      finalState(result)
        .visibleQuery === 'A' &&
      Boolean(
        finalState(result).error,
      ),
  ],

  [
    'stale-error',
    'ownership',
    (result) =>
      finalState(result)
        .visibleQuery === 'B' &&
      finalState(result)
        .error === null &&
      hasDenied(result, 'error'),
  ],

  [
    'stale-finally',
    'abort-only',
    (result) =>
      result.checkpoints.find(
        (checkpoint) =>
          checkpoint.label ===
          'A finally ran while B pending',
      )?.state.loading === false,
  ],

  [
    'stale-finally',
    'ownership',
    (result) =>
      result.checkpoints.find(
        (checkpoint) =>
          checkpoint.label ===
          'A finally ran while B pending',
      )?.state.loading === true &&
      hasDenied(
        result,
        'loading-finally',
      ),
  ],

  [
    'clear-pending',
    'ownership',
    (result) =>
      finalState(result)
        .visibleQuery === '(empty)' &&
      finalState(result)
        .loading === false,
  ],

  [
    'dispose-reinit',
    'ownership',
    (result) =>
      finalState(result)
        .visibleQuery === '(empty)' &&
      hasDenied(result, 'results'),
  ],
];

const profile =
  await mkdtemp(
    join(
      tmpdir(),
      'search-race-chromium-',
    ),
  );

const server = spawn(
  process.execPath,
  ['server.mjs'],
  {
    cwd: new URL('.', ROOT),
    stdio: [
      'ignore',
      'pipe',
      'pipe',
    ],
  },
);

server.stdout.pipe(process.stderr);
server.stderr.pipe(process.stderr);

let chrome;
let failed = false;

try {
  await waitHttp${BASE}/);

  chrome = spawn(
    CHROME,
    [
      '--headless=new',
      '--no-sandbox',
      '--disable-gpu',
      '--remote-debugging-port=9222',
      --user-data-dir=${profile},
      ${BASE}/,
    ],
    {
      stdio: [
        'ignore',
        'ignore',
        'pipe',
      ],
    },
  );

  chrome.stderr.on(
    'data',
    () => {},
  );

  const cdp = await connectPage();

  await cdp.call('Runtime.enable');
  await cdp.call('Page.enable');

  await cdp.call(
    'Page.navigate',
    { url: ${BASE}/ },
  );

  let ready = false;

  for (let i = 0; i < 200; i++) {
    if (
      await evaluate(
        cdp,
        'Boolean(window.lab)',
        false,
      )
    ) {
      ready = true;
      break;
    }

    await sleep(25);
  }

  if (!ready) {
    throw new Error(
      'window.lab did not become ready',
    );
  }

  const ua = await evaluate(
    cdp,
    'navigator.userAgent',
    false,
  );

  const browserVersion =
    await cdp.call(
      'Browser.getVersion',
    );

  console.log(
    JSON.stringify({
      environment: {
        node: process.version,
        userAgent: ua,
        browserProduct:
          browserVersion.product,
        browserRevision:
          browserVersion.revision,
        chromeCommand: CHROME,
      },
    }),
  );

  for (
    const [
      name,
      variant,
      predicate,
    ] of EXPECT
  ) {
    const result =
      await evaluate(
        cdp,
        window.lab.runCase( +
        ${JSON.stringify(name)}, +
        ${JSON.stringify(variant)}),
      );

    const pass =
      Boolean(predicate(result));

    if (!pass) failed = true;

    const rejects =
      result.ledger.filter(
        (event) =>
          event.type ===
          'request_rejected',
      );

    console.log(
      JSON.stringify({
        name,
        variant,
        pass,
        checkpoints:
          result.checkpoints,
        final:
          result.snapshot,
        rejects,
      }),
    );
  }
} catch (error) {
  failed = true;

  console.error(
    'HARNESS_ERROR',
    error?.stack || error,
  );
} finally {
  chrome?.kill('SIGTERM');
  server.kill('SIGTERM');

  await sleep(200);

  await rm(
    profile,
    {
      recursive: true,
      force: true,
    },
  ).catch(() => {});
}

process.exitCode =
  failed ? 1 : 0;

Run:

node harness.mjs

For a non-default Chromium-family executable:

CHROME=/absolute/path/to/browser node harness.mjs

A qualified run must retain the emitted environment record, every case result, checkpoint states, and rejection details. Exit 0 means all declared predicates passed. Exit 1 means at least one predicate failed or the harness itself could not establish the test environment.

Reset between cases is explicit: server-held responses are destroyed, transform gates are released and removed, the ledger/request counter is cleared, the view is reinitialized, but the ownership generation is not reset.

Assign UI-state ownership and release decisions

A principal-level review should turn the evidence into an explicit release decision rather than ending with “seems fixed.”

Decision

Criteria

Accept ownership rule

Current intent is independently defined; each relevant mutation checks its captured current token and active-view state immediately before synchronous commit; required stale controls pass

Refactor update path

Results, errors, loading, cleanup, or an asynchronous helper can mutate shared UI without final ownership authorization

Hold unproven ordering

Required A/B or post-body gate sequence was not demonstrated; browser/build was not recorded; evidence conflicts with documentation; or environment prevented execution

Invalidate reset/disposed view

Search/reset/dispose/reinitialize creates fresh ownership before aborting older work; old callbacks cannot resurrect the state

Asynchronous helpers should preferably return data rather than own the DOM. The view layer owns commit authorization. A helper that intentionally mutates UI must accept the same ownership context and satisfy the same final check.

QA owns the interleaving evidence: request IDs, intent IDs, release order, body-completion marker, transform gate, checkpoint state, and accepted/denied write ledger.

An await followed by an unguarded mutation is a review target, not automatic proof of a defect. The mutation may be operation-local, immutable, or otherwise isolated. The defect exists when stale work can mutate state whose application contract belongs to another intent.

Require stale-success, stale-error and stale-cleanup controls

Do not qualify a repair from one green success case.

Minimum acceptance coverage is:

  • Stale success: B succeeds, then old A tries to render; A is denied.

  • Stale error: B succeeds, then old A fails; A cannot replace B with an error.

  • Stale cleanup: B remains pending while old A finishes; A cannot hide B’s loading indicator.

  • Pending reset: clear while A is pending; the empty state persists.

  • Disposed/reinitialized view: A finishes post-body work after a new view exists; A is denied.

  • Pending-fetch abort comparison: record what the pinned browser actually rejects and confirm expected cancellation does not become user-visible error state.

  • Post-body abort-only comparison: establish that body consumption finished before abort, then demonstrate that unrelated asynchronous work survives unless explicitly designed otherwise.

The intended release decision for this article’s implementation is therefore two-part. The ownership design is acceptable as the repair rule because the fixture makes authorization explicit across all write paths. The browser execution remains on hold until the named primary browser can run the deterministic gates on a host whose browser policy permits the loopback fixture. A local expected matrix is not a substitute for that evidence.

Practice browser integration foundations with Refonte Learning

This laboratory is a useful pattern for learning where JavaScript syntax ends and interface correctness begins: the browser provides asynchronous APIs, while application code still owns the rule that decides whether a completed operation may change the current view.

Refonte Learning’s Frontend Development programme page lists JavaScript ES6+ and APIs/AJAX among its competencies, and its programme-specific details currently state a four-month period with 10–12 hours per week. Those facts make the programme relevant to readers building browser/API foundations, without implying that this exact Fetch-race fixture or individualized concurrency mentoring is part of the curriculum.

The programme-specific section lists four months, while a generic Frontend Development card on the same page lists three months. Confirm the duration shown for the cohort you are considering before enrolling.