Envoy’s TLS write path copies 16 KB for every 16 KB it sends — not occasionally, but for the life of a connection. This is what causes it and how the fix works.

WhereSslSocket::doWrite
Costone 16 KB malloc + 16 KB memcpy + free, per 16 KB of TLS egress
Triggerany HTTP response: small headers in front of a full-size body
Fixwrite the contiguous front slice as one short TLS record
Result−10.3% CPU on the affected path, copies per iteration 10 → 1

The line

int rc = SSL_write(rawSsl(), write_buffer.linearize(bytes_to_write), bytes_to_write);

SSL_write() needs contiguous memory. Envoy’s write buffer is a chain of slices. linearize() bridges them: if the front slice already holds enough it returns a pointer for free, otherwise it allocates a slice, copies into it, and returns that.

It reads like a fallback for an awkward case. On a proxied response it is the steady state.

Why it never recovers

Two numbers matter, and they are the same number: the most Envoy hands to one SSL_write() is 16384 bytes, and Slice::default_slice_size_ — the size of each slice the read path reserves — is also 16384.

That is a reservation, not a limit. reservation.commit() only commits what was actually read, and other paths size a slice to whatever was written into it, so slices smaller than 16 KB are ordinary. But a body streamed from a fast upstream fills them right up, and that is the shape this section is about.

The short slice at the front is the response headers. ConnectionImpl::write() moves the codec’s output into the connection buffer wholesale, so a few hundred bytes of headers land as their own slice ahead of the body’s full-size ones. Call that size H.

Here is what linearize(16384) does to that buffer:

The leftover is always exactly H, for any H. The copy takes H bytes off the front and leaves H bytes behind on the next slice, so the offset is conserved rather than worn down. The short slice in the “after” state is a different H bytes — the tail of slice 2, not the original headers.

Call a buffer in this shape — a short slice of H bytes sitting ahead of full-size ones — a misaligned chain. The point is that linearize() does not clear it. It reproduces it, so every write from then on allocates and copies 16 KB.

Why the write buffer never re-alignsStep one: the buffer is a short header slice of H bytes ahead of full 16 KB slices. Step two: linearize copies H plus 16384 minus H into a fresh 16 KB slice and drains that much, which leaves the second slice holding exactly H bytes. Step three: writing and draining the fresh slice returns the buffer to the shape it had in step one, so the next write copies again.1 — the write buffer: headers ahead of full-size body slicesH1638416384…H = the response headers,a few hundred bytes2 — linearize(16384) copies H + (16384 − H) into a fresh slice, and drains that much16384 — fresh copyH16384…slice 2 gave up (16384 − H) and keeps 16384 − (16384 − H) = H bytes3 — the 16384 record is written and drained, so the fresh slice is goneH1638416384…identical to step 1 — but this His the tail of slice 2repeatsOne 16 KB malloc + one 16 KB memcpy + one free, per 16 KB, for the life of the connection.
linearize() reproduces the exact shape it was called to remove, so the next write faces the same buffer as the last one.

The fix

When a write would have to copy again right after the last one did, write just the contiguous front slice instead. That is one short TLS record, and from then on the buffer is aligned and the writes that follow cost no copy at all.

How one short record escapes the cycleStep one: copied last write and about to copy again. Step two: write only the 200-byte front slice as a short record and drain it. Step three: the buffer now starts on a slice boundary, so every following write is contiguous and free.1 — copied last write, about to copy again200 B16 KB16 KB…2 — write just the front slice: one short record, no copy200 Bwritten and drained — this consumes the offset3 — aligned16 KB16 KB16 KB…Every write from here on is contiguous: no allocation, no memcpy.The cost isn't avoided — it's paid once instead of on every write.
One short record buys permanent alignment. That is the trade the change makes.

The rule, in three cases

Each write asks for up to 16 KB. What happens depends on the buffer:

the bufferwhat it does
front slice already holds enoughwrite it directly — no copy needed
front slice is short, and the last write had to copy, and the slice behind it can cover the next writewrite just the front slice: one short record, and the chain is aligned
anything elsecopy into a new slice, as before

The first case is not new — linearize() already returns that pointer untouched. The third is the old behaviour. Only the middle one is added:

if (front_slice.len_ >= bytes_to_write) {
  // Already contiguous — linearize() would return this pointer without copying.
  return {front_slice.mem_, bytes_to_write, false};
}

if (avoid_repeated_linearize && linearized_last_write &&
    nextSliceCoversWrite(write_buffer, bytes_to_write)) {
  // Copied last time, about to copy again, and one short record fixes it.
  return {front_slice.mem_, front_slice.len_, false};
}

return {write_buffer.linearize(bytes_to_write), bytes_to_write, true};

All three conditions in the middle case are load-bearing. Dropping either of the last two breaks it in a different way.

Why “the last write had to copy”, and not a size threshold

A natural guard would be “only write the front slice directly if it is large enough to be worth a syscall”. It never fires. A threshold asks whether the front slice is big; the state it needs to act on is the one where the front slice is permanently small — held at H by the conservation shown above. The answer is no on exactly the writes where the fast path would help.

Why a size threshold can never fireA scale of front-slice size from 0 to 16 KB. The fast path opens only above a 4 KB threshold, but on a misaligned chain the front slice stays at 200 bytes, far below it, so the guard is never true.Front slice size — where each path appliescopy pathfast path — write the front slice directly4 KB threshold016 KBfront slice on a misaligned chain: 200 B, permanentlyThe fast path opens at 4 KB. A misaligned chain holds the front slice at H. The guard is never true.
A threshold tests for a large front slice. A misaligned chain never has one, so no threshold value helps.

Why the slice behind it has to be checked

Writing the front slice only re-aligns the buffer if the slice behind it can satisfy the next write on its own:

bool nextSliceCoversWrite(Buffer::Instance& write_buffer, uint64_t bytes_to_write) {
  const Buffer::RawSliceVector slices = write_buffer.getRawSlices(/*max_slices=*/2);
  return slices.size() == 2 && slices[1].len_ >= bytes_to_write;
}

Without that check, a chain of equal sub-16 KB slices goes wrong — the slice behind the front one is short too, so nothing gets aligned.

How badly depends on what 16384 leaves over. A linearize consumes whole slices until it needs part of one more, and that slice keeps slice_size − (16384 mod slice_size) bytes. Pick a size just above an exact divisor of 16 KB and that remainder is almost nothing: 4097 is one byte over 16384/4, so it strands 4 bytes; 5462 is just over 16384/3 and strands 2. Those few bytes are all the short record has to write.

These are slice sizes fed to a unit test, not constants in Envoy — a slice holds whatever its producer wrote, so any size occurs. Taking 4097 as the worst case:

Why a short record achieves nothing on a chain of equal 4097-byte slicesA linearize of 16 KB consumes three whole 4097-byte slices plus 4093 bytes of the fourth, leaving that slice holding 4 bytes. The short record then writes only those 4 bytes, but the slice behind it is still 4097 bytes and cannot satisfy the next 16 KB write, so the next write copies again.1 — linearize(16 KB) takes 3 whole slices plus 4093 B of the 4th4097409740974093 of it4097…4 B left over (not to scale)2 — the short record writes those 4 bytes, and aligns nothing409740974097…a 4-byte TLS record — one syscall, ~24 B on the wirethe slice behind it is 4097 — still short of 16 KB3 — so the next write copies again, and the cycle repeats from step 1409740974097…Bytes copied: unchanged. Records and syscalls: doubled.
The short record only helps if the slice behind the front one can cover the next write. Here it never can, so each copy is followed by a near-empty record that aligns nothing.

Measured in that test, 64 slices of each size:

slice sizeTLS records, without the check
4096 B16 → 25 (1.56×)
4097 B17 → 32 (1.88×)
5462 B22 → 42 (1.90×)

Nearly double the records and writev syscalls, for identical bytes copied. A 2-byte TLS record still costs about 24 bytes on the wire and one full syscall.

4096 is in the table to show the problem is not about awkward remainders: it divides 16 KB exactly, so nothing is stranded, and the short record writes a whole 4096-byte slice instead. The slice behind it is still 4096, so the next write copies anyway. Any chain of equal sub-16 KB slices fails this way; the odd sizes just make it look worse. Real buffers take that shape whenever framing is interleaved with data — HTTP/1 chunked encoding, small HTTP/2 frames, addBufferFragment chains.

The check also means a write that drains the whole buffer is never split in two, since it can only pass when data remains behind the front slice.

When a write doesn’t go through

If the socket is full, SSL_write() writes nothing and returns SSL_ERROR_WANT_WRITE. Envoy sends the same write again when the socket is ready.

A retry must not re-run the decision above. By then the buffer has already been linearized, so the code would see a contiguous front slice, conclude that no copy was needed, and clear linearized_last_write. When the retry lands and drains, the short remainder is back at the front with no history behind it — and it copies again. On a congested connection that repeats on every write, and the change never takes effect at all.

So the pending write is stored and replayed unchanged: same pointer, same length, and the same record of whether it came from a copy. BoringSSL requires the identical pointer in any case, since Envoy does not set SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER.

One ordering detail: that pending write is cleared before write_buffer.drain(), not after. drain() can run callbacks that re-enter doWrite(), and a nested call must not find a write still pending that has already been sent.

Results

Eight alternating paired runs — baseline, fixed, baseline, fixed — so machine interference lands on both arms:

Per-pair CPU change, misaligned buffer versus aligned controlOn the misaligned buffer all eight paired runs are faster, ranging from 8.2 to 12.6 percent, with a median of 10.3 percent. On the aligned control the eight pairs scatter above and below zero with a median of 0.2 percent, showing no consistent effect.Misaligned bufferAligned buffer (control)0%−5−10faster ↓−12.6median −10.3% · 8 of 8 pairs fasterone direction, every timemedian +0.2% · 3 faster, 4 slower, 1 flatno consistent direction — as intendeddashed line = median
Each bar is one baseline-versus-fixed pair. The control panel is the point: where the buffer is not misaligned, the change does nothing.
buffermedianspread
misaligned−10.3%8 of 8 pairs faster, −8.2% to −12.6%
aligned (control)+0.2%signs mixed — no regression

The timing is macOS, where a memcpy is cheap relative to a syscall; the balance differs in production. The deterministic number is the copy counter: 10 copies per iteration → 1.

Rollout

Guarded by envoy.reloadable_features.tls_avoid_repeated_linearize, default on. The flag is read when a TLS connection is created, so disabling it applies to new connections rather than to connections already open.