Blog··9 min read

The chunk-boundary bug: why streaming PII redaction is hard

Godfrey Lebo
Godfrey Lebo
Founder, Sether · Last updated 2026-07-09
Direct answer

Streaming PII redaction fails when a value splits across chunk boundaries: "sarah@ac" arrives in one chunk and "me.com" in the next, and a per-chunk regex passes both halves. The fix is a bounded hold-back window (Sether defaults to 256 bytes) that only emits text once no possible detector match can span the boundary.

TL;DR
  • The obvious streaming redactor, run the detectors on each chunk, is silently broken. Any value that splits across a chunk boundary slips through in two innocent-looking halves.
  • Demos never catch this because demos rarely split mid-value. Real network chunking splits anywhere, including inside an email address.
  • I caught it during pre-release hardening with a property-based test: generate random partitions of PII-bearing text and assert identical redaction regardless of chunking.
  • The fix is a bounded hold-back window (safeDistanceBytes, default 256): only emit text once no detector match could span the boundary.
  • If you built your own streaming redactor, test it with a value split mid-match. Most home-grown implementations fail that test.

How did I find this bug?

I was building the streaming path for Sether, my open-source PII redaction library. The non-streaming path was done: text in, tokens out, redactSync and a vault to restore values in the reply. Streaming looked like the easy part.

LLM responses arrive as a stream of chunks, so I wrote the obvious thing. Take each chunk, run the detectors on it, replace any matches, emit the result. It passed every test I had written by hand. It worked in every demo I ran against a live model.

Then, during the pre-release hardening pass, I added property-based tests. Instead of hand-picking inputs, a property test generates thousands of random ones and checks that an invariant holds for all of them. The invariant I wrote was simple: redacting a text should give the same output no matter how you chop it into chunks.

It failed within seconds. The failing case had split an email address across two chunks, and the email had sailed through untouched.

Why does per-chunk redaction fail?

Here is the naive version, simplified. It is the implementation almost everyone writes first, because it looks correct:

The naive per-chunk redactor (broken)
// Looks right. Is not.
async function* redactStream(chunks: AsyncIterable<string>) {
  for await (const chunk of chunks) {
    // Run detectors on this chunk in isolation
    yield redactText(chunk); // regex + validation on chunk only
  }
}

The problem is that the stream does not respect your detectors. Chunk boundaries land wherever the network and the provider's tokenizer put them. So "sarah@acme.com" can arrive as "sarah@ac" followed by "me.com".

Run an email regex on "sarah@ac" and it does not match, there is no dot-something after the domain start. Run it on "me.com" and it does not match either, there is no @ before it. Two innocent halves, one leaked email.

Chunk 1Chunk 2Naive per-chunk resultCorrect result
sarah@acme.comsarah@acme.com leaks<EMAIL_1>
call +234 8031 234 5678phone number leaks<PHONE_1>
4111 1111 1111 1111card number leaks<CARD_1>
sk-proj-AbCd3fGh...API key leaks<OPENAI_KEY_1>

The moment I saw the failing case, the worse realization landed. This is not a bug in my code specifically. It is a bug in the obvious design. Every naive streaming redactor has it, including the ones people copy-paste into production request paths from blog posts.

Why do demos never catch it?

Because demos do not split mid-value. When you test by hand, you send a short prompt, get a short reply, and the whole email lands in one chunk. The redactor looks perfect.

Chunk boundaries depend on token lengths, network buffering, and provider behavior, none of which you control. A redactor can run cleanly for a long time and then split a value at exactly the wrong byte. It is not flaky in a way you can see; it is correct-looking with a hole in it.

This is why I now treat "it works in the demo" as close to zero evidence for streaming code. The input space that matters, all the ways a text can be partitioned, is exactly the space a demo never explores.

How does the hold-back window fix it?

The fix is to stop pretending each chunk is independent. Sether's streaming redactor keeps a bounded window of recent text unemitted, controlled by safeDistanceBytes, default 256 bytes. Text is only released once no possible detector match could span the boundary between what is emitted and what is still held.

So when "sarah@ac" arrives, the tail of it stays in the window instead of going out the door. When "me.com" arrives, the detector sees the joined text, matches the full email, and emits <EMAIL_1>. The reader downstream never knows the chunks were reassembled.

The window is bounded on purpose. Buffering the entire stream would also fix correctness, but it would destroy the point of streaming, the user would stare at nothing until the model finished. 256 bytes is longer than any value the built-in detectors can match, and short enough that the added latency is one small chunk, not a paragraph.

The streaming path in Sether
import { Sether } from '@raeven-co/sether';

const sether = new Sether();

// Node streams: redact on the way out, restore on the way back
prompt.pipe(sether.redact());       // holds back up to 256 bytes
reply.pipe(sether.restore());       // same window logic for tokens

// SSE responses have their own helpers that respect event framing:
// createSSERedactStream / createSSERestoreStream

How does a property-based test lock it down?

The test that caught the bug is now the test that guards the fix. The idea fits in a few lines of pseudocode:

The chunking invariant (pseudocode)
// Property: redaction output must not depend on chunking.
property('chunking-invariant', (text: PiiBearingText, seed: number) => {
  const expected = redactSync(text); // whole text at once

  const chunks = randomPartition(text, seed); // split anywhere,
  // including mid-email, mid-card, mid-key

  const actual = collect(streamRedact(chunks));

  assert.equal(actual, expected);
});

The generator builds texts containing known PII, then partitions them at random positions, thousands of runs, every run a different slicing. If any partition produces different output than redacting the whole text at once, the test fails and prints the exact split that broke it.

That last part matters. When the invariant fails, you do not get "something is wrong somewhere", you get "splitting at byte 14 inside this email leaks it". The bug that would have been invisible in production is a one-line reproduction in CI.

How do you test your own implementation?

If you have a streaming redactor in production, home-grown or vendored, here is the five-minute check I would run first, and the fuller list after it.

  1. The five-minute check. Feed your redactor "my email is sarah@ac" then "me.com" as two chunks. If the joined output contains the email, you have the bug.
  2. Split every detector type. Repeat for phone numbers, cards, API keys, whatever you claim to catch. Long values (IBANs, JWTs) are the easiest to split and the most often missed.
  3. Randomize the split point. A loop that re-runs the same text with the boundary at every byte position catches cases a single hand-picked split misses.
  4. Add the invariant to CI. Redacting whole text and redacting the same text in random chunks must produce identical output. Property-based test libraries (fast-check in TypeScript, Hypothesis in Python) make this a short test.
  5. Bound your buffer. If your fix is "buffer everything", measure what that does to time-to-first-token. A bounded window keeps streaming actually streaming.

I want to be precise about the timeline here, because it is the honest part of the story. This bug never reached a user. It was caught during pre-release hardening, before the streaming path shipped, by a test I almost did not write. That is the whole argument for property-based testing on this class of code: the demo said yes, and the property test said no.

For the wider context on where streaming redaction fits in an LLM data-protection setup, start with the complete guide to PII redaction for LLM apps. And if you are wiring this into a TypeScript service today, the step-by-step OpenAI walkthrough covers the full redact-then-restore round trip.

Frequently asked questions

Why 256 bytes for the hold-back window?
It has to be at least as long as the longest value any enabled detector can match, with margin. Emails, phone numbers, IBANs, and API keys all fit well inside 256 bytes. It is configurable via safeDistanceBytes if you add custom detectors with longer matches.
Does the hold-back window add noticeable latency?
It delays emission by at most the window size, in practice one small chunk. Time-to-first-token is unaffected for text before any potential match. Users do not perceive a 256-byte lag inside a streaming reply.
Does this apply to SSE streams too?
Yes, and SSE adds a second trap: event framing. You must reassemble the data fields before matching, or the framing itself becomes another kind of chunk boundary. Sether ships createSSERedactStream and createSSERestoreStream that handle both.
Could I just buffer the whole response and redact once?
That is correct but it turns streaming into non-streaming. The user waits for the full model response before seeing anything. A bounded window gives you the same correctness while the reply still streams.
Try it

Sether is free and MIT-licensed: npm i @raeven-co/sether · pip install sether. Or paste some text into the live sandbox and watch the redaction happen.

Keep reading