Mert Özen Further With Every Line
Frontend Browser APIs and PWA — #01

Fetch in Depth: Streaming Responses, AbortSignal.any, keepalive, fetchLater and Angular 22's Fetch Backend

Mert Özen Nov 15, 2026 20 min 7 views
Fetch in Depth: Streaming Responses, AbortSignal.any, keepalive, fetchLater and Angular 22's Fetch Backend

To process a large response without waiting for all of it, read response.body line by line with TextDecoderStream; AbortSignal.any combines a timeout with the user's cancel, and keepalive and fetchLater send data while the page closes. With real output from Chromium 158 and WebKit 27.2 we show the HTTP/2 requirement of upload streaming, keepalive's 64 KiB limit, WebKit differences and route-level withXhr() for upload progress in Angular 22's HttpClient.

Version note: The examples in this post were run with Angular 22.2 (TypeScript 6.0), Node.js 24 and Playwright 1.65; browser output comes from Playwright's Chromium 158 and WebKit 27.2 builds. Support data was read from MDN browser-compat-data 8.1.9 and web-features 3.46 (November 2026); Firefox could not be run in this environment, so Firefox statements come from that data.

Predict First

The API of Bookstore, the online bookstore used as the shared example across this site's series, returns the book list. The function below tries to read the same response first as JSON, then as plain text:

export async function bodyTwice() {
  const response = await fetch("/api/books");
  const books = await response.json();
  console.log("json():", books.length, "books, bodyUsed =", response.bodyUsed);
  try {
    const text = await response.text();
    console.log("text():", text.length, "chars");
  } catch (err) {
    console.log(`text(): ${err.name}: ${err.message}`);
  }
}

What does the second line print: 557 characters of text, an empty string, or an error? If it is an error, do Chromium and WebKit report the same message?

The Intermediate JavaScript series post "JavaScript Fetch API: GET, POST, response.ok, Timeouts and an Intro to CORS" covered the basics of fetch, the response.ok check and AbortController. This post picks up from there: we read the response body as a stream, send an upload as a stream, combine the user's cancel with a timeout in one signal, compare three ways of sending data while the page closes, and look at why Angular 22's HttpClient now uses Fetch. Every example ran against a small mock Bookstore API (api/server.ts, dependency-free Node.js) in real browsers driven by Playwright.

Request and Response: A Body Is Read Once

The answer: an error. Both engines throw a TypeError, with different messages:

Chromium 158:
  json(): 6 books, bodyUsed = true
  text(): TypeError: Failed to execute 'text' on 'Response': body stream already read
  json(): 6 books; clone text(): 557 chars
WebKit 27.2:
  json(): 6 books, bodyUsed = true
  text(): TypeError: Body is disturbed or locked
  json(): 6 books; clone text(): 557 chars

The body of a Response or Request is a ReadableStream. json(), text(), blob(), arrayBuffer() and bytes() read that stream to the end and consume it; as soon as reading starts, bodyUsed becomes true and a second read is impossible. If you need the same body twice, call clone() before reading:

export async function bodyTwiceWithClone() {
  const response = await fetch("/api/books");
  const copy = response.clone(); // clone before the first read
  const books = await response.json();
  const text = await copy.text();
  console.log("json():", books.length, "books; clone text():", text.length, "chars");
}

clone() tees the stream into two branches. The cost: if one of the copies is read slowly, the difference is buffered in memory. In a service worker that writes a response to the cache and also returns it to the page, clone() is mandatory; in ordinary application code, reading the body once and keeping the result in a variable is cheaper.

Do not branch on the error message: the same error says "body stream already read" in Chromium and "Body is disturbed or locked" in WebKit. What you should check is response.bodyUsed.

Processing a Large Report Line by Line with response.body

Bookstore's back office has a button that downloads the order report as CSV and adds up the revenue. The mock API writes 20,000 rows in parts of 500, waiting 20 ms between parts, much like a real report endpoint that reads rows from the database and sends them as it goes. response.text() waits until the whole body has arrived. response.body is a stream you can read as chunks arrive:

// Splits a stream of text chunks into lines; a line can be cut between two chunks
export function splitLines() {
  let rest = "";
  return new TransformStream({
    transform(chunk, controller) {
      const lines = (rest + chunk).split("\n");
      rest = lines.pop();
      for (const line of lines) controller.enqueue(line);
    },
    flush(controller) {
      if (rest) controller.enqueue(rest);
    },
  });
}

export async function sumReport(url = "/api/reports/orders.csv?rows=20000") {
  const started = performance.now();
  const response = await fetch(url);
  if (!response.ok) throw new Error(`HTTP ${response.status}`);

  let rows = 0;
  let total = 0;
  let firstRowMs = 0;
  const lines = response.body.pipeThrough(new TextDecoderStream()).pipeThrough(splitLines());
  for await (const line of lines) {
    if (line.startsWith("orderId")) continue; // header
    if (rows++ === 0) firstRowMs = performance.now() - started;
    total += Number(line.split(",")[4]);
  }
  const ms = performance.now() - started;
  console.log(`stream: first row after ${firstRowMs.toFixed(0)} ms, ${rows} rows, ${total} TRY in ${ms.toFixed(0)} ms`);
}

export async function sumReportWithText(url = "/api/reports/orders.csv?rows=20000") {

TextDecoderStream turns byte chunks into UTF-8 text and joins a multi-byte character correctly when it is split across two chunks. splitLines is a TransformStream that splits the incoming text into lines and keeps the unfinished last line for the next chunk. Iterating a stream directly with for await...of has existed since Chrome 124 and Firefox 110 and became Baseline with Safari 27 in September 2026. If you support older Safari versions, a response.body.getReader() and read() loop does the same job. Summing the same report both ways:

Chromium: stream: first row after 3 ms, 20000 rows, 13666390 TRY in 870 ms
Chromium: text(): first row after 865 ms, 20000 rows, 13666390 TRY in 872 ms
WebKit:   stream: first row after 7 ms, 20000 rows, 13666390 TRY in 858 ms
WebKit:   text(): first row after 866 ms, 20000 rows, 13666390 TRY in 868 ms

The total time is almost the same, because the server is the bottleneck. The difference is the first row: with the stream it is processed after 3–7 ms, with text() after 865 ms. That matters in two places. First, the user sees results early ("5,000 rows so far"). Second, memory: text() holds all 20,000 rows in one string and then builds an array of lines on top of it, whereas the stream keeps only a few chunks in memory at a time. When the report has 2 million rows instead of 20,000, the second approach is the one that keeps the browser tab alive.

Upload Streaming: duplex: 'half' and the HTTP/2 Requirement

The same idea works in the other direction: passing a ReadableStream as the request body. If Bookstore's cover upload screen pipes the file through a TransformStream and counts how many bytes the network stack pulls, you get upload progress without XMLHttpRequest:

// Request streams need duplex: "half"; browsers without support ignore the getter
// and turn the stream into the string "[object ReadableStream]" (which sets a Content-Type)
export const supportsRequestStreams = (() => {
  let duplexRead = false;
  const request = new Request("/", {
    method: "POST",
    body: new ReadableStream(),
    get duplex() {
      duplexRead = true;
      return "half";
    },
  });
  return duplexRead && !request.headers.has("Content-Type");
})();

// Counts the bytes the network stack pulls from the stream: upload progress without XHR
function countBytes(onProgress) {
  let sent = 0;
  return new TransformStream({
    transform(chunk, controller) {
      sent += chunk.byteLength;
      onProgress(sent);
      controller.enqueue(chunk);
    },
  });
}

export async function uploadCoverStream(isbn, file, base = "") {
  const body = file.stream().pipeThrough(countBytes((sent) => console.log(`sent ${sent} of ${file.size}`)));
  const response = await fetch(`${base}/api/covers/${isbn}`, {
    method: "POST",
    headers: { "Content-Type": file.type },
    body,
    duplex: "half",
  });
  return response.json();
}

duplex: "half" is required: it tells the browser that you will read the response only after the whole request body has been sent. A browser that does not know this option converts the stream with String() and sends the text "[object ReadableStream]"; that is why the feature detection checks both whether the getter was read and whether a Content-Type header was added. The results of three attempts:

HTTP/2 (https://localhost:5282), Chromium:
  sent 262144 of 3000000
  sent 1310720 of 3000000
  sent 2097152 of 3000000
  sent 3000000 of 3000000
  => {"isbn":"9780441172719","bytes":3000000,"chunks":247}
  [api] #36 POST /api/covers/9780441172719 HTTP/2
HTTP/1.1 (http://localhost:5281), Chromium:
  Failed to load resource: net::ERR_ALPN_NEGOTIATION_FAILED
  => "TypeError: Failed to fetch"
HTTP/1.1, WebKit:
  => true
  sent 3000000 of 3000000
  Failed to load resource: request body stream exhausted
  => "TypeError: Load failed"

Chromium supports this only over HTTP/2 and later: the same code fails against an HTTP/1.1 server with ERR_ALPN_NEGOTIATION_FAILED. For the HTTP/2 test I used a self-signed certificate and started the mock API with the TLS_KEY and TLS_CERT variables. WebKit is more instructive: in Playwright's WebKit 27.2 build the feature detection returned true, the stream was read to the end, yet the request failed with "request body stream exhausted". According to MDN's data, the duplex option of fetch does not exist in Safari and exists only in Firefox's preview builds. Request streaming is therefore a Chromium-only improvement today. An upload screen that relies on it should always fall back, in its catch, to a plain File body without a stream.

Combining a Timeout and the User's Cancel with AbortSignal.any

The Advanced JavaScript series post "Advanced JavaScript Async Patterns: The Promise.all Family, Cancellation, Retry and Timeouts" covered cancellation and timeout patterns. Fetch has two ready-made pieces: AbortSignal.timeout(ms) creates a signal that aborts on its own when the time is up, and AbortSignal.any([...]) returns a combined signal that aborts when any of the given signals aborts. Both are Baseline (any since March 2024: Chrome 116, Firefox 124, Safari 17.4). The first version:

// The user's cancel button and a timeout, combined into one signal
export async function loadReport(url, userSignal, timeoutMs = 5000) {
  const signal = AbortSignal.any([userSignal, AbortSignal.timeout(timeoutMs)]);
  const response = await fetch(url, { signal });
  return response.text(); // the signal also cancels reading the body
}

export async function abortScenarios() {
  const cases = [
    ["slow server, 1 s timeout", "/api/reports/orders.csv?rows=10&delay=3000", 1000, null],
    ["user cancels after 300 ms", "/api/reports/orders.csv?rows=10&delay=3000", 5000, 300],
    ["cancel while the body streams", "/api/reports/orders.csv?rows=50000", 5000, 300],
    ["fast server", "/api/reports/orders.csv?rows=10", 1000, null],
  ];
  for (const [name, url, timeoutMs, cancelAfterMs] of cases) {
    const controller = new AbortController();
    if (cancelAfterMs) setTimeout(() => controller.abort(), cancelAfterMs);
    const started = performance.now();
    try {
      const csv = await loadReport(url, controller.signal, timeoutMs);
      console.log(`${name}: ok, ${csv.length} chars`);
    } catch (err) {
      const ms = (performance.now() - started).toFixed(0);
      console.log(`${name}: ${err.name} after ${ms} ms (${err.message})`);
    }
  }
}
Chromium: slow server, 1 s timeout: TimeoutError after 1003 ms (signal timed out)
Chromium: user cancels after 300 ms: AbortError after 301 ms (signal is aborted without reason)
Chromium: cancel while the body streams: AbortError after 302 ms (signal is aborted without reason)
Chromium: fast server: ok, 360 chars
WebKit:   slow server, 1 s timeout: ok, 360 chars
WebKit:   user cancels after 300 ms: AbortError after 307 ms (Fetch is aborted)
WebKit:   cancel while the body streams: AbortError after 301 ms (Fetch is aborted)
WebKit:   fast server: ok, 360 chars

In Chromium everything is as expected: the timeout gives TimeoutError, the user's cancel gives AbortError, and the signal also works while the body is being read (the third line cut the 50,000-row report at 302 ms). Per the specification, fetch rejects with the signal's reason, and a timeout's reason is a TimeoutError DOMException.

WebKit showed two problems. First, the timeout of the request made with the combined signal never fired: the request waited three seconds and succeeded. Trying the variants one by one on fresh pages showed a clear pattern: when only fetch references the signal returned by AbortSignal.any, an abort that happens after roughly one second never reaches the request (a 200 ms timeout worked; a 1,000 ms timeout and a manual cancel at 2,000 ms did not). Adding an abort listener to the signal fixed it; the specification forbids garbage-collecting a combined signal that has listeners, so collection is the likely cause. Second, WebKit rejects with AbortError instead of TimeoutError even when AbortSignal.timeout is used on its own. These are behaviours I saw in Playwright's WebKit build; do not rely on them in production before testing in real Safari. The version that works correctly in both browsers:

// The user's cancel button and a timeout, combined into one signal
export async function loadReport(url, userSignal, timeoutMs = 5000) {
  const signal = AbortSignal.any([userSignal, AbortSignal.timeout(timeoutMs)]);
  // the listener also keeps the combined signal alive in WebKit (see the note below)
  signal.addEventListener("abort", () => console.log(`  abort event: ${signal.reason.name}`), { once: true });
  try {
    const response = await fetch(url, { signal });
    return await response.text(); // the signal also cancels reading the body
  } catch (err) {
    // WebKit rejects with AbortError even after a timeout; the signal's reason is reliable
    throw signal.aborted ? signal.reason : err;
  }
}
  abort event: TimeoutError
slow server, 1 s timeout: TimeoutError after 1008 ms
  abort event: AbortError
user cancels after 300 ms: AbortError after 302 ms
  abort event: AbortError
cancel while the body streams: AbortError after 303 ms
fast server: ok, 360 chars

This output is from WebKit; Chromium's is the same. Reading which signal fired from signal.reason instead of the error name also removes the difference between the two browsers.

cache, credentials, mode and priority

The second argument of fetch is often used only for method, headers and body, yet a few more options decide its behaviour:

OptionDefaultWhen to change it
cache"default"Skip the HTTP cache ("no-store"), ask the server every time ("no-cache")
credentials"same-origin"Send cookies to an API on another origin ("include", also needs CORS permission on the server)
mode"cors"Allow only the same origin ("same-origin"); "no-cors" gives an opaque response you cannot read
priority"auto"Move a request needed for the first screen forward ("high"), push analytics back ("low")
keepalivefalseSmall requests that must finish even when the page closes (below)

cache is the most misunderstood. The mock API answers /api/books with Cache-Control: max-age=60 and an ETag; I sent the same request with six different modes and looked at the server log:

// /api/books answers with Cache-Control: max-age=60 and an ETag
export async function cacheModes() {
  for (const cache of ["default", "default", "no-cache", "no-store", "reload", "force-cache"]) {
    const response = await fetch("/api/books", { cache });
    console.log(`cache: "${cache}" -> ${response.status}`);
    await new Promise((resolve) => setTimeout(resolve, 100)); // keep the server log in order
  }
}
cache: "default" -> 200
cache: "default" -> 200
cache: "no-cache" -> 200
cache: "no-store" -> 200
cache: "reload" -> 200
cache: "force-cache" -> 200
[api] #69 GET /api/books
[api] #69 200 557 bytes
[api] #70 GET /api/books (If-None-Match)
[api] #70 304 Not Modified
[api] #71 GET /api/books
[api] #71 200 557 bytes
[api] #72 GET /api/books
[api] #72 200 557 bytes

The page saw 200 six times, but only four requests reached the server. The second "default" and "force-cache" came from the browser's HTTP cache. "no-cache" does not mean "do not use the cache": the browser asked the server with If-None-Match, got 304 and handed the cached body to the page as 200. "no-store" and "reload" did not read the cache at all. I got this result in Chromium; in Playwright's WebKit build all six requests went to the server, so test cache behaviour separately in Safari. priority has been supported since Chrome 101, Firefox 132 and Safari 17.2; since it is only a hint, a browser that does not support it ignores it and nothing breaks.

Sending Data While the Page Closes: sendBeacon, keepalive and fetchLater

Bookstore wants to measure how long a user stays on the book detail page. A normal fetch is cancelled when the page closes. There are three tools for this: navigator.sendBeacon (older, POST only, the response cannot be read), fetch(..., { keepalive: true }) (you choose the method and headers) and fetchLater (you register the request in advance and the browser sends it as the page closes). The first two share a limit:

// keepalive requests share a 64 KiB budget for request bodies that are still in flight
export async function keepaliveLimit() {
  for (const size of [1000, 70000]) {
    const body = "x".repeat(size);
    const beacon = navigator.sendBeacon("/api/events", body);
    console.log(`sendBeacon(${size} bytes) -> ${beacon}`);
    try {
      await fetch("/api/events", { method: "POST", body, keepalive: true });
      console.log(`fetch keepalive (${size} bytes) -> sent`);
    } catch (err) {
      console.log(`fetch keepalive (${size} bytes) -> ${err.name}: ${err.message}`);
    }
  }
}
Chromium: sendBeacon(1000 bytes) -> true
Chromium: fetch keepalive (1000 bytes) -> sent
Chromium: sendBeacon(70000 bytes) -> false
Chromium: fetch keepalive (70000 bytes) -> TypeError: Failed to fetch
WebKit:   sendBeacon(1000 bytes) -> true
WebKit:   fetch keepalive (1000 bytes) -> sent
WebKit:   sendBeacon(70000 bytes) -> false
WebKit:   fetch keepalive (70000 bytes) -> TypeError: Load failed

According to the Fetch Standard, the bodies of a page's unfinished keepalive requests cannot exceed 64 KiB in total, and sendBeacon uses the same budget. With a 70,000-byte body, sendBeacon returned false and fetch rejected with a TypeError; the request never reached the server. WebKit also logged the reason to the console: "Reached maximum amount of queued data of 64Kb for keepalive requests". The limit applies not to a single request but to all keepalive requests in flight at the same time; code that sends five separate events while the page closes runs out of this budget sooner than expected.

The hard part is timing. unload and beforeunload do not fire reliably on mobile; the recommended approach is to send when the page becomes hidden on visibilitychange. fetchLater leaves this to the browser: you register the request up front, abort it and register a new one whenever the data changes, and the browser sends the last registration when the page closes or enters the bfcache (or when activateAfter expires):

// Sends how long the user read the book page, once, when the page goes away
export function trackReading(isbn) {
  const started = Date.now();
  let pending = null;
  const payload = () => JSON.stringify({ type: "reading", isbn, seconds: Math.round((Date.now() - started) / 1000) });

  if ("fetchLater" in window) {
    // register now, replace it whenever the data changes; the browser sends the last one
    const update = () => {
      pending?.abort();
      pending = new AbortController();
      fetchLater("/api/events", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: payload(),
        signal: pending.signal,
      });
    };
    update();
    setInterval(update, 1000);
    return "fetchLater";
  }

  // fallback: send when the page is hidden (pagehide/unload are not reliable on mobile)
  document.addEventListener("visibilitychange", () => {
    if (document.visibilityState === "hidden") {
      fetch("/api/events", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: payload(),
        keepalive: true,
      });
    }
  });
  return "keepalive";
}

In Chromium I closed the page after 2.5 seconds; WebKit has no fetchLater, so the fallback ran:

[chromium] => "function"
[chromium] => "fetchLater"
[api] #370 POST /api/events
[api] #370 204 event 53 bytes application/json {"type":"reading","isbn":"9780441172719","seconds":2}
[webkit] => "undefined"
[webkit] => "keepalive"
[webkit] visibilitychange 2562
[api] #389 POST /api/events
[api] #389 204 event 53 bytes application/json {"type":"reading","isbn":"9780441172719","seconds":3}
[api] #390 POST /api/events
[api] #390 204 event 53 bytes application/json {"type":"reading","isbn":"9780441172719","seconds":5}

Chromium sent only the last of the registrations renewed every second, once. In WebKit the page was hidden first (second 3), then another visibilitychange arrived while Playwright closed the browser and a second event went out. In real life a page can also be hidden and shown many times; the server should handle these events as "the last one wins" for the same session. According to MDN's data, fetchLater exists only in Chrome and Edge 135+ and is experimental; Firefox and Safari do not have it. It has its own quota: 640 KiB per page in total and 64 KiB at a time for a single origin; going over throws QuotaExceededError. It throws TypeError for URLs that are not trustworthy (not HTTPS); localhost counts as trustworthy, so the example had no problem.

Angular 22: HttpClient Now Uses Fetch

As the Angular series post "Angular 22: OnPush by Default, Stable Signal Forms and a File-by-File Comparison with Angular 12" showed, in Angular 22 HttpClient sends requests with the Fetch API by default; withFetch() is deprecated and withXhr() brings back XMLHttpRequest. The request types in Playwright confirm it: the catalog loaded with httpResource shows up as fetch. The one thing Fetch lacks is upload progress. When the cover upload screen asks for reportUploadProgress without withXhr(), the request never leaves:

[chromium] console.error: ERROR | HttpErrorResponse: status 0; error = Error: NG02824: The FetchBackend does not support upload progress reporting. Please use `withXhr()` on your `provideHttpClient()` configuration if you want to report upload progress.

There is no need to switch the whole app back to XHR. When you put provideHttpClient(withXhr()) only into that route's providers, the route's injector gets its own HttpClient and the rest of the app stays on Fetch:

import { provideHttpClient, withXhr } from '@angular/common/http';
import { Routes } from '@angular/router';

export const routes: Routes = [
  { path: '', loadComponent: () => import('./catalog/catalog-page').then((m) => m.CatalogPage) },
  {
    path: 'reports/orders',
    loadComponent: () => import('./reports/order-report-page').then((m) => m.OrderReportPage),
  },
  {
    path: 'admin/covers',
    loadComponent: () => import('./admin/cover-upload-page').then((m) => m.CoverUploadPage),
    // only this route uses XMLHttpRequest, for upload progress; the rest of the app keeps Fetch
    providers: [provideHttpClient(withXhr())],
  },
];
export class CoverUploadPage {
  private readonly http = inject(HttpClient);
  protected readonly progress = signal('');

  upload(file: File | undefined) {
    if (!file) return;
    this.http
      .post<CoverResult>('/api/covers/9780441172719', file, {
        observe: 'events',
        reportUploadProgress: true,
      })
      .subscribe((event) => {
        if (event.type === HttpEventType.UploadProgress) {
          const percent = Math.round((100 * event.loaded) / (event.total ?? file.size));
          this.progress.set(`${percent}%`);
          console.log(`upload ${event.loaded} of ${event.total}`);
        } else if (event.type === HttpEventType.Response) {
          this.progress.set(`done: ${event.body?.bytes} bytes`);
          console.log(`server received ${event.body?.bytes} bytes`);
        }
      });
  }
}

To watch the upload on localhost I throttled Chromium's upload speed to 1 MB/s through CDP (the same as network throttling in DevTools); a 3 MB file produced 29 UploadProgress events:

request xhr POST /api/covers/9780441172719
upload 114688 of 3000000
upload 212992 of 3000000
upload 327680 of 3000000
...
upload 3000000 of 3000000
server received 3000000 bytes

A route-level provideHttpClient call does not inherit the interceptors you defined at the top level by default; if you have an auth interceptor, either pass the same withInterceptors here too or use withRequestsMadeViaParent(). withRequestsMadeViaParent() cannot be used in the same call as withXhr(), though; Angular rejects that with a configuration error.

For the report I made the opposite decision. FetchBackend reads the body as a stream but collects all chunks and parses them in one piece at the end; the partialText you get with reportDownloadProgress is the whole text so far. To process a large CSV line by line, I wrote a small service that uses plain fetch instead of HttpClient and connected it to resource():

@Service()
export class OrderReportService {
  readonly rowsRead = signal(0);

  // plain fetch instead of HttpClient: HttpClient would keep the whole body in memory
  async sum(abortSignal: AbortSignal): Promise<ReportSummary> {
    const signal = AbortSignal.any([abortSignal, AbortSignal.timeout(30_000)]);
    signal.addEventListener('abort', () => console.log(`report aborted: ${signal.reason.name}`), {
      once: true,
    });
    const response = await fetch('/api/reports/orders.csv?rows=20000', { signal });
    if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);

    let rows = 0;
    let total = 0;
    this.rowsRead.set(0);
    const lines = response.body.pipeThrough(new TextDecoderStream()).pipeThrough(splitLines());
    for await (const line of lines) {
      if (line.startsWith('orderId')) continue;
      total += Number(line.split(',')[4]);
      if (++rows % 5000 === 0) this.rowsRead.set(rows);
    }
    this.rowsRead.set(rows);
    return { rows, total };
  }
}
export class OrderReportPage {
  private readonly reports = inject(OrderReportService);
  protected readonly run = signal(0);
  protected readonly rowsRead = this.reports.rowsRead;

  // resource() aborts the previous load when run() changes or the page is destroyed
  protected readonly report = resource({
    params: () => this.run() || undefined,
    loader: ({ abortSignal }) => this.reports.sum(abortSignal),
  });

  constructor() {
    effect(() => console.log(`rows read: ${this.rowsRead()}, status: ${this.report.status()}`));
  }
}

resource() gives the loader an abortSignal and aborts it when the parameters change or the component is destroyed. The service combines that signal with its own timeout using AbortSignal.any. In Playwright I summed the report once, then switched to the catalog page in the middle of a second run:

rows read: 0, status: idle
request fetch GET /api/reports/orders.csv
rows read: 0, status: loading
rows read: 5000, status: loading
rows read: 10000, status: loading
rows read: 15000, status: loading
rows read: 20000, status: loading
rows read: 20000, status: resolved
report: 20000 rows, TRY13,666,390.00
request fetch GET /api/reports/orders.csv
rows read: 20000, status: loading
rows read: 0, status: loading
rows read: 5000, status: loading
report aborted: AbortError
[api] #90 GET /api/reports/orders.csv?rows=20000
[api] #90 200 csv 20000 rows
[api] #91 GET /api/reports/orders.csv?rows=20000
[api] #91 client aborted

When the page was left, resource() aborted the signal, reading the stream stopped at row 5,000, and the server log shows that the client cut the request. This choice has a cost: plain fetch does not go through HttpClient interceptors. If the auth header, error logging or retry live in an interceptor, this service has to do them by hand.

In Production

If there is a proxy in front of a streaming report endpoint, check its behaviour first. Reverse proxies such as Nginx may buffer the response by default, and the client again sees the first row at the very end; review the proxy settings in the Web Servers and Hosting series post "Serving Angular with Nginx: SPA Fallback, Cache Headers, Compression and an /api Proxy" with this in mind. Compression can have the same effect; measure the time to first byte in the real environment.

Pick the timeout by measuring. If the report's p95 duration is 8 seconds, a 5-second timeout cuts the report of one user in twenty. Count TimeoutError and AbortError separately: the first says the server got slower, the second says the user gave up, and they call for different actions.

Events sent when the page closes can be lost. Count it when sendBeacon returns false or fetchLater throws QuotaExceededError; that is the only way to know what percentage of analytics is missing. If the code sending these events needs the auth cookie, test the credentials and CORS settings too: keepalive requests leave after the page has closed, so you have no chance of seeing their errors in the console.

Common Pitfalls

  • Reading a response body twice. The second read throws a TypeError; if you need it, call clone() before reading.
  • Branching on the error name. WebKit gives AbortError on a timeout too; read which signal fired from signal.reason.
  • Enabling request streaming because feature detection says so. In WebKit 27.2 detection returned true but the upload failed; always keep a fallback without a stream.
  • Sending more than 64 KiB while the page closes. sendBeacon silently returns false and a keepalive fetch rejects; make the data smaller or send it in parts while the page is open.
  • Sending closing data in the unload event. It is unreliable on mobile and blocks the bfcache; use visibilitychange or fetchLater.
  • Switching the whole app to withXhr() for upload progress. Give it to that route only; Fetch-specific options such as keepalive and priority produce a warning and are ignored under XHR.

A Small Experiment

★☆☆ Add an AbortSignal parameter to sumReport and, when it is cancelled while the report is being read, log the number of rows processed so far. Find the "client aborted" line in the server log.

★★☆ Turn uploadCoverStream into a function that sends the same file again with a plain File body when request streaming is not supported or the request fails with a TypeError. Try it in Chromium against an HTTP/1.1 and an HTTP/2 server and in WebKit against an HTTP/1.1 server; confirm that the server received 3,000,000 bytes in all three cases.

★★★ Move trackReading into an Angular service: it should start when a book detail page opens and, when the route changes (DestroyRef), close that book's time and start the next one. In a browser with fetchLater it should use a single pending registration, elsewhere the visibilitychange fallback, and the server should ignore a second event for the same book.

In this post we looked at ways of getting data from the network and sending it; all of it worked while the page was open and the network was there. The next post moves on to where data is kept in the browser: we will choose between localStorage, IndexedDB, the Cache API, OPFS and cookieStore, measure the quota with navigator.storage.estimate(), and see when the browser deletes data.