Compatibility

Does Obfuscation Break Streams and Backpressure?

Streams are the part of the platform where JavaScript hands the runtime a bag of callbacks and then waits. You supply an object with start and pull methods, the platform calls them when it wants data, and your code sits in an await until a chunk arrives. That inverted control flow is worth measuring rather than assuming, and it produced the quietest failure mode we have recorded anywhere on this site.

What was measured

The sample uses node's real WHATWG streams implementation rather than a stub, which matters for the same reason it mattered when we measured Web Crypto: a model can only tell you about the half you wrote.

It builds a ReadableStream from an underlying source with start and pull methods, drains it with an explicit reader loop that awaits read() and stops on done, then releases the lock. It pipes a second stream through a TransformStream that upper-cases each chunk. It collects a third stream to text through a Response, using encoded byte chunks. Finally it reads a single chunk from a fourth stream, inspects the step object, and cancels the reader.

Async iteration is deliberately not used. for await over a stream is an iteration question rather than a stream question, and we keep the two apart because the ES5 downlevelling of for-of over a non-array iterable has a tracked defect of its own. This page does not cover async iteration over streams and should not be read as clearing it.

The sample was run unprotected, then protected in five configurations and run again, diffed line by line: the ES5 target on defaults, the modern target, the two identifier-renaming presets our gate uses, and a string-table preset that moves and encodes every literal. All five produced output identical to the original. Three chunks arrived in order, the transform produced the upper-cased pair, the collected text came back with the right length, the step object reported done as false with the right value, and cancellation completed.

The first sample was wrong, and saying so is part of the result

An earlier version of this sample collected a stream to text by handing string chunks to a Response. That throws in node, because a Response body must carry byte chunks, and the original run therefore ended in an error before its last three lines.

The protected runs ended in the same error, so the comparison said the protected build behaved identically to the original. It did, and the statement was worthless: both sides were crashing at the same place for a reason that had nothing to do with protection.

We fixed the sample to feed encoded bytes and re-measured, which is where the clean result above comes from. This failure mode is worth naming because it is invisible in a summary: a comparison between two broken runs reports agreement. Any measurement of this kind needs the original's own output checked before the diff is believed.

The member-renaming column, and the quietest failure on this site

Three regular expressions were measured against the same sample, and all three broke it in different ways. Two break loudly. One does not break loudly at all.

Renaming the read result protocol, done and value, fails at the reader loop with a RangeError. Your loop reads a renamed done that is always undefined, never terminates on the platform's actual signal, and pushes undefined values until an array operation gives up. Loud, immediate, easy to find.

Renaming the platform methods themselves, the set containing getReader, read, releaseLock, cancel, enqueue and close, fails at the first call with a TypeError naming a generated member. Also loud.

Renaming start, pull and transform, the callbacks on the underlying source and the transformer, produces something else entirely. The protected process exits with status zero, prints nothing at all, and reports no error.

That is the whole failure. The underlying source you pass to the ReadableStream constructor is a dictionary the platform reads by name. Rename its keys and the platform looks for start, does not find it, looks for pull, does not find it, and concludes that this is a source that has nothing to say. Your reader awaits a chunk that will never arrive. Node runs out of pending work and exits cleanly, because an await that never settles is not an error, it is simply the end of the program.

Why a clean exit is worse than a crash

Every other compatibility hazard documented on this site announces itself somehow: a thrown error, a wrong value, a log line that changed. This one announces nothing. A stream that never produces looks exactly like a stream with no data.

In a browser the equivalent is a download that shows no progress, an upload that never completes, or a server-sent feed that appears idle. In a build pipeline it is a step that finishes suspiciously quickly and writes an empty file. In a test suite it is a test that awaits a chunk and is killed by the runner's timeout, which reads as a flaky test rather than a broken build.

The defensive habit is small: give any stream-driven operation a timeout with a real error message, and assert on the number of chunks or bytes rather than on the absence of an exception. A pipeline that asserts it moved a non-zero quantity of data will catch this on the first run. One that only asserts it did not throw will not catch it at all.

How to check your own build

Protect your bundle, run one streaming path end to end, and assert on quantity: chunk count, byte count, or the final string length. Compare that number against the unprotected run rather than against a hard-coded expectation, so the check keeps working as your data changes.

If you use member renaming, keep the pattern away from three groups of names. The read result fields done and value belong to the platform. The stream methods belong to the platform. The underlying-source and transformer callbacks, start, pull, cancel, transform, flush and write, look like your own code because you wrote them, and they are not: they are keys the platform reads out of a dictionary you handed it. That third group is the trap, because it is the only one that looks local.

The same shape appears anywhere the platform reads a configuration object by name, which is why an anchored allowlist of your internal field names remains the recommendation across every one of these measurements.

The short version

Streams behave identically after protection in all five configurations we measured, against node's real implementation rather than a stub: reader loops, transform streams, collecting to text, single reads and cancellation all matched the unprotected run exactly.

Member renaming is the option to scope. Renaming the read result fields or the stream methods fails loudly. Renaming the callbacks on your underlying source is the case to remember, because the platform simply never calls them, your reader waits for a chunk that never comes, and the process exits successfully having printed nothing. Assert on how much data moved, not on the absence of an error.

Frequently asked questions

Does obfuscation break the Streams API?

Not in anything we measured. Against node's real WHATWG implementation, reader loops, a transform stream, collecting a stream to text, a single read and cancellation all produced output identical to the unprotected run across five configurations, including both language targets and the string-table preset.

Why does my protected stream never deliver any data?

Most likely member renaming matched start, pull or transform. Those are keys the platform reads out of the object you hand the constructor, so renaming them means the platform never finds a source to call. We measured this exact case: the process exits with status zero, prints nothing and reports no error, because an await that never settles is not an error.

Do backpressure and the pull mechanism still work after protection?

Yes. The pull callback was driven by the platform on demand in every configuration and the chunks arrived in the correct order with the correct count. The mechanism is unchanged as long as member renaming has not rewritten the callback names the platform is looking for.

Is it safe to rename done and value?

No. They are the fields of the result object the platform returns from read(), so renaming them leaves your loop reading a property that is always undefined. We measured a RangeError as the loop failed to terminate and kept collecting undefined values. This one at least fails loudly.

Does this page cover for await over a stream?

No, deliberately. Async iteration is a separate question from the stream itself, and the ES5 downlevelling of for-of over a non-array iterable has a tracked defect of its own, so we measured with an explicit reader loop instead. Do not read this page as clearing async iteration over streams.

How should I test a streaming path after enabling protection?

Assert on quantity rather than on the absence of an exception: count chunks, bytes or final string length and compare the protected run against the unprotected one. Add a timeout with a real message to any stream-driven step, because the failure mode worth catching here is a step that hangs quietly and then exits successfully.

Related reading