Skip to main content

transformers.js's progress_callback Fetched Every Model File Twice

I passed it to show download progress. Version 4.3.1 then GET the whole file just to read its size — and Chrome aborted one of the overlapping, immutably cached requests.

Updated 5 min readMarkdown

I run OpenAI's Whisper in the browser through @huggingface/transformers 4.3.1, as an automatic-speech-recognition pipeline on WebAssembly with 8-bit weights. The model is served from the same site, not from Hugging Face. To show download progress, the worker used to pass progress_callback into pipeline().

On the live site, the first transcription failed a few megabytes in with a generic "network error". The underlying Chrome failure, recorded in the commit this post is based on, was net::ERR_CACHE_WRITE_FAILURE. This post is about why that happened, and how progress is counted now without that callback.

What progress_callback does in 4.3.1

I read the library's 4.3.1 source in node_modules while writing this. When pipeline() is given a progress_callback, it first calls get_file_metadata for every expected model file so it can emit a total. That is meant to be a cheap size check.

For a remote http: or https: URL, _get_file_metadata is careful: it sends Range: bytes=0-0 (fetch_file_head) and reads the size from Content-Range. A site path is not treated as a URL. This project sets env.localModelPath to /models/ on purpose — a 4.3.1 comment in the worker says the library only checks that a local file exists when the path is not a URL, and otherwise loads no tokenizer.

For a non-URL local path, the metadata helper falls through to getFile(localPath). In the browser that is a normal env.fetch of the whole file:

export async function getFile(urlOrPath) {
  // …filesystem branch omitted…
  return env.fetch(urlOrPath, {
    headers: getFetchHeaders(urlOrPath),
  });
}

It does that only to read Content-Length. The pipeline then fetches the same files again to load them. The two weight files in this model are encoder_model_quantized.onnx and decoder_model_merged_quantized.onnx. Each was requested twice at once.

The commit records a DevTools check: four .onnx requests before the change, two after. I did not re-count those requests myself.

Why one of those requests failed

The model folder is named after a pinned revision, so the files never change under that name. next.config.mjs therefore serves /models/:path* with:

Cache-Control: public, max-age=31536000, immutable

Two overlapping GETs of the same immutably cached URL is the situation the commit ties to ERR_CACHE_WRITE_FAILURE. Even if the cache write had succeeded, the model would have been downloaded twice. I have not reproduced the Chrome error myself; I am describing the source and the commit record.

Counting bytes without the callback

The worker no longer passes progress_callback. Progress is counted from the body of a wrapped env.fetch, and the known total comes from the pinned file list (SPEECH_MODEL_BYTES, about 81 MB — the ONNX runtime is not included in that figure):

const received = new Map<string, number>();
const baseFetch = env.fetch;
env.fetch = async (input: string | URL, init?: RequestInit) => {
  const response = await baseFetch(input, init);
  const url = typeof input === "string" ? input : input.href;
  if (!url.includes(SPEECH_MODEL_PATH) || !response.body || !response.ok) {
    return response;
  }
  let seen = 0;
  const counted = response.body.pipeThrough(
    new TransformStream<Uint8Array, Uint8Array>({
      transform(chunk, controller) {
        seen += chunk.byteLength;
        received.set(url, seen);
        let loaded = 0;
        for (const n of received.values()) loaded += n;
        post({ kind: "download", loaded, total: SPEECH_MODEL_BYTES });
        controller.enqueue(chunk);
      },
    }),
  );
  return new Response(counted, {
    status: response.status,
    statusText: response.statusText,
    headers: response.headers,
  });
};

pipeline() is then called with only device: "wasm" and dtype: "q8". The wrapper still lets the library fetch each file once. It only taps the stream, sums bytes per URL, and posts { kind: "download", loaded, total }.

What the test pins

tests/transcribe.test.ts now asserts that the worker source does not contain progress_callback: and does contain env.fetch = async. I read that assertion. I did not run the suite, and the test does not drive a browser or assert the request count.

Takeaways

  • In transformers.js 4.3.1, progress_callback is not a passive listener. On a local site path it triggers a full GET of each file just to learn its size, then a second GET to use the file.
  • The library's cheap Range probe applies to http: / https: URLs. A path such as /models/... does not take that path.
  • Cache-Control: immutable and two concurrent writes of the same URL are a bad pair. A "network error" a few megabytes in may be a cache write failure, not a dropped connection.
  • If you already know the file sizes — this project pins them next to hashes — count bytes on the one fetch you cannot avoid, and do not ask the library to measure them first.

The page that uses this worker is AUP4 to Text. Disclosure: I built it.


Source note (2026-11-12): I read Waveward commit fe8fe14 (AuthorDate 2026-11-12 11:56:00 +0800), the current src/lib/transcribe/transcribe.worker.ts and model.ts, next.config.mjs cache headers, the new assertions in tests/transcribe.test.ts, and @huggingface/transformers 4.3.1 (get_file_metadata.js, getFile in hub.js, and the progress_callback branch in pipelines.js) on the Mac. I did not run the test suite or re-trace the network panel.

No comments yet. Be the first to comment.