Why cat bigfile freezes webview terminals: backpressure from ConPTY to xterm.js
A webview terminal has no natural brake between the shell and the screen. Here is why a flood of output freezes the UI or eats memory, and the batching and ack-based flow control that push the pressure back to the shell.
Open a webview-based terminal, cat a few hundred megabytes of log, and watch what happens. Often the window stops responding to keys. Sometimes the process's memory just climbs.
A native terminal handles the same file without drama, and JavaScript speed is only a small part of the story. A native terminal reads from the PTY only as fast as it can draw, so a fast producer gets throttled by the kernel for free. A webview terminal puts an IPC boundary in the middle of that path, and the most common way to cross it is an async send that returns before the other side has the data. That send quietly removes the brake.
I hit this while building emberterm, a Windows terminal built on Tauri 2 (Rust), Svelte 5 and xterm.js, with the PTY coming from portable-pty over ConPTY. The fix ended up as two layers: batching, so each IPC send carries enough bytes to be worth its cost, and acknowledgements plus a bounded buffer, so a slow webview makes the shell wait instead of making memory grow. This is the whole path once both are in place.
Where the brake goes missing
In a native terminal emulator, the chain is short. The shell writes to the PTY, the emulator reads from it, parses escape sequences, and draws. If the emulator falls behind, it stops reading, the PTY buffer fills, and the shell's next write blocks. The shell slows to the speed of the screen. That is backpressure, and the operating system gives it to you without any code.
A webview terminal adds a hop. On the Rust side, a thread reads ConPTY. On the JavaScript side, xterm.js parses and paints. Between them sits Tauri's IPC. In emberterm, output goes over a Tauri Channel, and the comment on the batching constants spells out what one send costs: each one is a webview.eval, plus a fetch round trip when the payload is above 1 KB.
So there are two separate problems hiding in "the terminal froze."
The first is cost per message. ConPTY hands the reader output in chunks, and emberterm reads into an 8 KB buffer. If every read becomes its own send, a flood turns into a very long stream of eval calls, and the IPC layer becomes the bottleneck. The source says it plainly, in the comment above the reader thread: "one IPC send per ConPTY chunk is what made floods slow."
The second problem is worse. Channel::send returns as soon as the message is handed off, before the webview has parsed anything. From the reader's point of view, every send succeeds instantly. It goes back to ConPTY for more, the shell never blocks, and the backlog builds up on the far side of the boundary where nothing on the producer side can see it.
Think of a restaurant kitchen where the waiter takes every order slip the moment it is written and drops it on a counter the cook can't see over. The waiter is always free. The cook is drowning. Nobody tells the dining room to stop ordering.
Batching fixes the first problem. It does nothing for the second: bigger messages into an unbounded queue are still an unbounded queue. You need both layers.
Layer one: batching for IPC cost
The reader and the sender are separate threads in emberterm. The reader pulls bytes off ConPTY and pushes them into an OutputPipe. The sender takes batches out of it and calls Channel::send. The rules for when a batch is ready live in a plain Batch struct so they can be tested with fake clocks, and they come down to three constants:
/// Each channel send is a `webview.eval` (plus a fetch round trip above 1 KB), so a flood
/// sent chunk by chunk is bound by IPC. Holding output this long caps a flood at ~125 sends
/// a second; xterm paints once per 16 ms frame anyway, so the delay is not visible.
pub(super) const FLUSH_AFTER: Duration = Duration::from_millis(8);
/// Send early at this size: large enough that the per-send cost vanishes, small enough that
/// one `term.write` stays a short parse slice and does not stall the webview.
pub(super) const FLUSH_AT: usize = 64 * 1024;
/// The reader blocks above this, which stops it draining ConPTY and in turn stalls the
/// shell, so a flood the webview cannot keep up with does not grow memory here.
pub(super) const MAX_PENDING: usize = 1024 * 1024;
FLUSH_AFTER is the time budget. Holding output for 8 ms caps a flood at about 125 sends a second. The reasoning for why that delay is invisible: xterm paints once per 16 ms frame, so bytes that arrive 8 ms later than they could have still land in the same or the next frame.
FLUSH_AT is the size budget, and it cuts both ways. Too small and you are back to paying the per-send cost too often. Too large and a single term.write becomes a long parse on the webview's main thread, which is its own kind of freeze. 64 KB is the point where the per-send cost stops mattering but one write is still a short slice of work.
MAX_PENDING belongs to layer two, so I'll come back to it.
Why typing does not feel the 8 ms
A blanket 8 ms delay on every byte would make typing feel sticky, since every keystroke you type comes back as echo from the shell. The batcher avoids that by only making bursts wait. This is the whole decision:
pub(super) fn due_at(&self) -> Option<Instant> {
let first = self.first_at?;
let quiet = self
.last_sent
.is_none_or(|sent| first.duration_since(sent) >= FLUSH_AFTER);
if quiet || self.pending.len() >= FLUSH_AT {
Some(first) // due now
} else {
Some(first + FLUSH_AFTER)
}
}
first_at is when the oldest unsent byte arrived. If that byte showed up at least 8 ms after the previous send, the terminal was quiet, and the output goes immediately. A keystroke echo is almost always in that situation: you type, the shell echoes one character, and nothing else has been sent for a while. If output is already flowing, the new bytes wait up to 8 ms from their own first byte, or go early once 64 KB has piled up.
The tests pin these cases down by name, which I find more readable than any prose: first_output_after_a_quiet_spell_goes_at_once, a_burst_waits_from_its_first_unsent_byte, a_full_batch_is_due_without_waiting.
Not splitting a synchronized frame
There is one more rule in Batch::push. Full-screen programs can wrap a redraw in DEC mode 2026, synchronized output: ESC [ ? 2026 h to begin a frame, ESC [ ? 2026 l to end it. The point is that the terminal should treat everything in between as one update.
A time-based batcher has no idea where frames start and end. Without care, it can cut a frame in half, send the first half now and the second half 8 ms later, and hand the webview a message that ends mid-frame. So push holds a synchronized frame aside until its end marker arrives, then queues it whole:
pub(super) fn push(&mut self, chunk: &[u8], now: Instant) {
if detect_sync(chunk, &mut self.in_sync, &mut self.sync_buf) {
let frame = std::mem::take(&mut self.sync_buf);
self.queue(&frame, now); // the complete frame, in one piece
} else if !self.in_sync {
self.queue(chunk, now);
}
}
Because take always drains everything pending, a frame that went into the queue in one piece also leaves it in one piece. A frame never straddles two sends.
Layer two: acks and a bounded buffer
Batching makes each send cheaper. It does not tell the sender when to stop. For that, the webview has to report how far it has got, and the Rust side has to act on the report.
The ack comes from xterm's write callback
xterm.js's write takes an optional callback that runs once the data has been parsed. That callback is the honest signal, because it fires only once the bytes have been processed. emberterm's Grid component hands it straight through:
getOutputSink(sid)?.attach((bytes, done) => t.write(bytes, done));
The output sink counts parsed bytes and calls session_ack once it has 32 KB to report:
/**
* Parsed bytes are acked in steps this size, so typing echo does not cost an IPC call per
* key. It must stay well under Rust's `HIGH_WATERMARK`, since the remainder is never acked.
*/
export const ACK_EVERY = 32 * 1024;
function settle(bytes: number) {
parsed += bytes;
if (parsed < ACK_EVERY) return;
ack(parsed);
parsed = 0;
}
Acking in steps keeps the ack path itself from becoming an IPC flood. The comment also notes the catch: it has to stay well under the Rust watermark, because the leftover below 32 KB is never acked. That leftover is permanently counted as in flight, so it eats into the sender's budget. At 32 KB against 512 KB, that is acceptable.
The sender waits on unacked bytes
On the Rust side, FlowControl tracks bytes sent but not yet acked:
/// The sender stops while this many sent bytes are not yet acked. `Channel::send` returns
/// before the webview has the data, so without acks a webview that falls behind queues
/// output without limit.
pub(super) const HIGH_WATERMARK: usize = 512 * 1024;
The sender thread's loop is short, and the order of its three lines carries most of the design:
// Waiting before taking a batch leaves output in the pipe, so a webview that falls
// behind stalls the reader (and the shell) instead of growing a queue anywhere.
while sender_flow.wait_for_room() {
let Some(bytes) = pipe.next_batch() else { break };
sender_flow.sent(bytes.len());
if let Err(e) = on_output.send(InvokeResponseBody::Raw(bytes)) {
// ... log and stop
break;
}
}
First, wait_for_room comes before next_batch. If the sender took a batch and then waited, it would be holding bytes outside the pipe, and the pipe would have room again for the reader to keep filling. Waiting first leaves the output where the reader can see it.
Second, sent is called before send, not after. Think about the opposite order. The send goes out, the webview parses it fast and acks, and the ack arrives before the counter was bumped. ack saturates at zero, so it subtracts nothing, and then sent adds the full batch. Those bytes are now counted as unacked forever, and enough of those lost races would wedge the sender at the watermark for good. Counting first means an ack can never arrive for bytes that were not counted yet.
pub(super) fn sent(&self, bytes: usize) {
lock_or_recover(&self.state).unacked += bytes;
}
pub(crate) fn ack(&self, bytes: usize) {
let mut s = lock_or_recover(&self.state);
s.unacked = s.unacked.saturating_sub(bytes);
self.wake.notify_all();
}
The saturation is there for a real case too. When a Grid is torn down in the middle of a write, the disposed xterm never calls back. The sink handles that in detach by acking whatever was still in flight, so the sender is not left waiting for parses that will never happen. It also bumps a generation counter, so a late callback from the dead xterm cannot be counted a second time. A stale ack can therefore show up for bytes Rust already wrote off, and saturating_sub keeps that from wrapping the count.
The reader stops, and so does the shell
The last piece is MAX_PENDING. When the sender is waiting on acks, batches stop leaving the pipe. The reader keeps pushing until 1 MB is pending, and then OutputPipe::push blocks:
pub(super) fn push(&self, chunk: &[u8]) -> bool {
let mut s = lock_or_recover(&self.state);
while s.batch.pending_len() >= MAX_PENDING && !s.closed {
s = self.reader_wake.wait(s).unwrap_or_else(|e| e.into_inner());
}
// ...
}
A reader that is blocked here is not reading ConPTY. ConPTY's buffer fills, and the shell's writes block. That is the brake from a native terminal, restored. cat now runs at the speed xterm can parse, with roughly 1 MB waiting on the Rust side and at most the watermark plus one batch on the webview side, which is the bound the HIGH_WATERMARK comment states.
The frontend follows the same rule. Output that arrives for a pane whose Grid has not mounted yet is buffered in the sink, but it is never acked until a Grid actually parses it. So a hidden pane with a chatty process stalls that process instead of growing the buffer.
Making sure nothing waits forever
Every blocking wait in this design needs a way out, or closing a tab leaves a thread asleep for good. OutputPipe::close wakes both the reader and the sender, and is called by whichever side stops first. FlowControl::close releases a sender blocked on acks, and Session's Drop calls it, because a closed tab will never ack again. On the IPC side, session_ack simply ignores an ack for a session that no longer exists: "An ack racing a tab close is normal: there is no sender left to release."
What carries over to other pipelines
None of this is specific to terminals. Any time a fast producer feeds a slower consumer across a boundary with an async send, you have the same shape: a log shipper writing to a websocket, a worker posting results to a UI thread, a Rust backend streaming rows into a webview.
The first question I'd ask of any such pipeline is where the queue lives. If the send returns before the consumer has processed the data, the queue is on the far side, and the producer has no way to know how long it is. The fix is to make the consumer report progress in units it has actually finished, from the point where the work is done (xterm's parse callback, not the message handler), and have the producer stop at a fixed number of unacknowledged bytes.
Then bound every buffer on the producer side, and let the bound block rather than drop or grow. Blocking is what carries the pressure upstream until it reaches something that can afford to wait, which for a terminal is the shell. Batching sits on top as a separate concern: it trades a small, bounded delay for fewer crossings, and it should only delay when there is a burst to amortize, so interactive traffic still goes straight through. A short checklist I now run against this kind of code:
- Does any send return before the consumer has the data? Then you need acks.
- Is the ack triggered by finished work, or by arrival?
- Is the "sent" count recorded before the send, so an ack cannot outrun it?
- Does every bounded buffer block its writer, and does every blocking wait have a close path?
- Does batching only delay bursts, and leave isolated small writes alone?