Skip to content

Async APIs (unzip/inflate/gunzip) crash with TypeError: k is not a function in minified production builds #288

Description

@web-dev-sam

What's broken

In a minified production build, calling any of fflate's async APIs (unzip, inflate, gunzip, …) can crash the worker the moment it tries to return a result:

Uncaught TypeError: k is not a function

When this happens, decompression never completes — the callback fires with an error (or not at all), and the data is lost. There is no bad input or corrupt archive involved; the same archive decompresses fine with the sync APIs.

Who hits it and why it's hard to diagnose

This is a nasty one to track down because it only shows up at the intersection of three independent conditions, none of which is obviously related to decompression:

  1. Minified build only. Works perfectly in development (unminified). Breaks only after a production minify pass. → looks like "works on my machine".
  2. Large inputs only. unzip()/inflate() decompress synchronously inline for small or poorly-compressed data and only spawn a worker for entries ≥ 512 KB uncompressed that compress well (see unzip threshold). Small files never hit the worker, so they never crash. → looks like "only breaks on big files".
  3. Bundle-dependent. Whether it triggers depends on how the minifier happens to rename one specific internal variable in your bundle (details below). → looks intermittent / "only in some apps".

So a consumer sees: "works in dev, works on small files, breaks in prod on large files, and a coworker's build is fine." Nothing points at fflate, the minifier, or workers. In our case it surfaced through read-excel-file (which calls async unzip()) failing only on large .xlsx files in the production bundle, with no clue that fflate or a worker was even involved.

Impact

  • Silent, environment-specific production failures. Green in dev and tests, broken in prod.
  • Opaque to downstream consumers. Libraries built on fflate (e.g. read-excel-file) surface only k is not a function from a blob-URL worker — no actionable signal.
  • The only reliable workaround is to abandon the async path (unzipSync/inflateSync), which never builds a worker but blocks the main thread — defeating the point of the async API.

Root cause

The async worker is assembled by string concatenation in wrkr (src/index.ts):

return wk(
  ch[id].c +
  ';onmessage=function(e){for(var k in e.data)self[k]=e.data[k];onmessage=' +
  init.toString() + '}',
  id, td, cbfs(td), cb
);

Two properties of this string make it fragile under minification:

  1. for(var k in e.data) lives inside a string literal, so no minifier can rename that k. It is permanently k.
  2. init.toString() is concatenated inside the bootstrap function bodyinit becomes a closure lexically nested in function(e){ … onmessage=<init> }. Its free identifiers therefore resolve against the bootstrap's scope first.

init references worker globals that wcln/cbfs install by their mangled names. For the inflate path:

(ev) => pbf(inflateSync(ev.data[0], gopt(ev.data[1])))   // init
const pbf = (msg) => postMessage(msg, [msg.buffer]);     // the global it calls

When the minifier mangles the top-level pbf to the single letter k, the generated worker source becomes (de-minified for clarity):

self.k = function (msg) { return postMessage(msg, [msg.buffer]); };   // inlined pbf → global self.k

self.onmessage = function (e) {
    for (var k in e.data) self[k] = e.data[k];   // <-- local `var k` (the hardcoded loop variable)
    self.onmessage = function (ev) {             // init, nested in this function
        return k(inflateSync(ev.data[0], gopt(ev.data[1])));  // `k` resolves to the LOOP variable, not self.k
    };
};

init's reference to k binds to the bootstrap's var k (the for…in loop variable) instead of the intended global self.k. After the loop runs, k holds the last enumerated key string of e.data. So on the next message k(...) calls a string → TypeError: k is not a function.

Why it's specifically — and only — the hardcoded k

The two sets of names injected into the worker scope are distinct top-level variables, so the minifier assigns them distinct mangled names; they can never collide with each other:

  • inlined functions (pbf, inflateSync, gopt, …) → self.<name> = function…
  • transferred data deps (fleb, fdeb, …) → keys of e.data, assigned via the loop

The only identifier in the worker immune to mangling is the literal k in the bootstrap string. So the sole possible collision is "some real top-level var mangles to k", and it only manifests through the lexical capture above — which is why condition 3 (bundle-dependent) exists, and why it's deterministic for any bundle where pbf (or inflateSync/gopt) lands on k.

Why large inputs only

unzip() only constructs a worker for entries that are large and compressible:

// Synchronously decompress under 512KB, or barely-compressed data
if (su < 524288 || sc > 0.8 * su) { /* inline inflateSync — no worker */ }

Small or poorly-compressing entries take the synchronous fallback (no worker, no bootstrap, no crash). A single entry ≥ 512 KB uncompressed that compresses below 80% takes the worker path and triggers the bug. Large .xlsx files clear this easily — their sheet*.xml / sharedStrings.xml entries are big and highly compressible.

Reproduction

The end-to-end trigger is bundle-dependent (it needs pbf to mangle to k), but the underlying scoping defect is deterministic. Paste this into a Worker to reproduce the exact failure mode:

// Mirrors fflate's generated worker when `pbf` minifies to `k`.
self.k = function (msg) { return postMessage(msg, [msg.buffer]); };   // inlined pbf (global)

self.onmessage = function (e) {
    for (var k in e.data) self[k] = e.data[k];   // hardcoded loop var, immune to mangling
    self.onmessage = function (ev) {              // init, nested → captures the loop var
        return k(ev.data);                        // intended: self.k(...). Actual: k = last loop key (a string)
    };
};
const w = new Worker(URL.createObjectURL(new Blob([/* the source above */], { type: 'text/javascript' })));
w.postMessage({ foo: 1, bar: 2 }); // bootstrap message; loop leaves k === "bar"
w.postMessage(new Uint8Array([1])); // → "TypeError: k is not a function"

End-to-end: bundle a project that calls async unzip()/inflate() with a minifier, feed it an archive whose largest entry is ≥ 512 KB uncompressed and well-compressed, and observe the crash whenever the build mangled pbf to k.

Suggested fixes (most surgical first)

  1. Remove the only hardcoded local binding. Replace the for…in with Object.assign, so there is no var k to capture and init's free k resolves to the global self.k as intended:

    ';onmessage=function(e){Object.assign(self,e.data);onmessage=' + init.toString() + '}'

    (td is a plain object, so Object.assign is equivalent here. It's ES2015 — a concern only if pre-ES2015 worker targets must be kept.)

  2. Un-nest init so it isn't a closure inside the bootstrap — but any new hardcoded name introduced re-creates the same collision class against the mangled scope, so it must be chosen to be collision-proof, which is hard to guarantee. Option 1 introduces no new name and is preferred.

  3. General principle: any literal identifier emitted into the worker's shared scope is unsafe by construction, since it competes with minifier-generated names. Option 1 is the minimal application of that.

Environment

  • fflate: 0.8.3 (latest); bootstrap unchanged on master, so all versions shipping the worker are affected.
  • Build: browser, minified. Observed with Oxc/Rolldown; the same class affects esbuild/terser whenever a top-level identifier mangles to the bootstrap loop-variable name.
  • Trigger API: any async path that spawns a worker (unzip / inflate / gunzip / …).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions