Deep Dive: CTS Flow Control in the Serial/Modem System
Why you can't just shovel bytes through a virtual modem: RTS/CTS backpressure, buffer gates, and the timing budget that makes dial-up feel like dial-up.
On this page
The first version of the modem wrote bytes to the line as fast as the backend could produce them. Data reached the other side, so it worked. But a BBS that should have painted text at 30 characters per second dumped a whole screen in one burst and then sat waiting for input. The connection had the latency of 300 baud and the pacing of a LAN.
The signal I had been ignoring was CTS.
The Smallest Contract
RTS/CTS is a handshake between a DTE (the computer) and a DCE (the modem). The DTE asserts RTS to request permission to send. The DCE asserts CTS when it can accept bytes.
// web/src/serial/serial-types.ts
export type DTEInputSignal = 'DTR' | 'RTS';
export type DCEOutputSignal = 'CTS' | 'DCD' | 'DSR' | 'RI';
On most modern hardware this handshake is vestigial; USB serial adapters often hardwire CTS high. For an emulated modem trying to reproduce the cadence of a 300-baud connection, it’s the mechanism that enforces the timing budget. Without it nothing stops the backend from overwhelming the line.
CTS as a Buffer Gate
The BaseModem class raises CTS only when two conditions hold: the modem is in a connected state, and the transmit buffer has room.
// web/src/serial/modems/base-modem.ts
protected updateCts(): void {
if (this.state !== 'connected') {
this.emitSignal('CTS', false);
return;
}
const ready = this.dteSignals.RTS && this.txBuffer.length < this.maxBuffer;
if (this.ctsTimer !== null) {
this.scheduler.clearTimeout(this.ctsTimer);
}
this.ctsTimer = this.scheduler.setTimeout(() => {
this.emitSignal('CTS', ready);
this.ctsTimer = null;
}, this.profile.rtsToCtsDelayMs ?? 5);
}
Three numbers shape the behaviour:
maxBuffer: 1024 bytes. When the transmit buffer exceeds this, CTS drops, signalling the DTE to wait.rtsToCtsDelayMs: 5 ms default. A small delay before CTS responds to RTS changes, avoiding signal chatter.TICK_INTERVAL: 50 ms. The drain loop runs on this cadence, processing accumulated byte credits.
These are not arbitrary. A 1024-byte buffer at 300 baud holds roughly 34 seconds of data: long enough to absorb a burst, short enough that backpressure still reaches the backend while it matters.
The Drain Loop: Byte Credits and Timing
Once bytes are in the transmit buffer, the modem drains them onto the line at the configured baud rate. Scheduling one timer per byte is expensive and drifts, so the drain loop accumulates byte credit from elapsed time and sends batches.
// web/src/serial/modems/base-modem.ts
const TICK_INTERVAL = 50;
const byteIntervalMs = getByteIntervalMs(this.profile);
let lastTickTime = this.scheduler.now();
let byteCredit = 0;
const drain = () => {
if (this.txBuffer.length === 0) {
this.txTimer = null;
this.updateCts();
return;
}
if (this.state !== 'connected') {
this.txTimer = this.scheduler.setTimeout(drain, TICK_INTERVAL);
return;
}
const now = this.scheduler.now();
const elapsed = now - lastTickTime;
lastTickTime = now;
byteCredit += elapsed / byteIntervalMs;
const bytesToSend = Math.min(Math.floor(byteCredit), this.txBuffer.length);
byteCredit -= bytesToSend;
if (bytesToSend > 0) {
const bytes = new Uint8Array(this.txBuffer.slice(0, bytesToSend));
this.txBuffer.splice(0, bytesToSend);
if (this.line) {
this.line.transmit(this as unknown as Modem, bytes);
}
this.updateCts();
}
this.txTimer = this.scheduler.setTimeout(drain, TICK_INTERVAL);
};
At 300 baud with 1 start bit, 8 data bits, and 1 stop bit, getByteIntervalMs returns about 33.3 ms per byte. A 50 ms tick therefore sends one or two bytes and carries the fractional credit forward. The average throughput comes out right without a timer per byte, and a late tick catches up on the next one instead of accumulating drift — which matters, because browser timers are late fairly often.
Connection Manager: Honouring the Gate
The modem enforces CTS, but the connection manager — which bridges backends to the BBS-side modem — has to respect it. When CTS drops, backend output queues; when it rises, the queue drains in order.
// web/src/serial/connection-manager.ts
private writeToSerialWithFlowControl(data: Uint8Array): void {
if (this.ctsReady && this.outputQueue.length === 0) {
this.bbsSerial.write(data);
} else {
this.outputQueue.push(data);
}
}
private drainOutputQueue(): void {
while (this.ctsReady && this.outputQueue.length > 0) {
const chunk = this.outputQueue.shift()!;
this.bbsSerial.write(chunk);
}
}
The outputQueue.length === 0 check took me three sessions to get right.
The Reordering Bug
My first implementation wrote directly whenever CTS was high and ignored the queue. That held until CTS toggled mid-burst. With a fast backend producing data in quick succession:
- Chunk A arrives, CTS is high, write directly
- Chunk B arrives, CTS drops (buffer full), queue B
- Chunk C arrives, CTS still low, queue C
- CTS rises
- Chunk D arrives, CTS is high, write directly ← Bug: D now precedes B and C
- Drain queue: B, C
The output arrived as A, D, B, C. On a terminal that shows up as garbled text: lines out of sequence and cursor positioning commands landing in the wrong place, which is a miserable thing to read a bug report about.
The fix is that once a queue exists, new data joins the queue regardless of CTS. Ordering is a hard constraint, not an optimization.
if (this.ctsReady && this.outputQueue.length === 0) {
// Only write directly when queue is empty
Why Rhythm Matters
Dial-up is slow, but it is also regular. A BBS screen that takes eight seconds to paint gives the eye time to follow text as it appears, and ANSI art of the era used that pacing directly — the animation effects are just characters arriving in a particular order at a known rate.
Without CTS enforcement, a modern backend dumps its output in one burst. The modem then smears that burst out to match the baud rate, but the batching wrecks the pacing: eight seconds of content might arrive as a half-second flood followed by seven seconds of nothing. It is still slow. It just isn’t slow in the right places.
CTS pushes backpressure from the emulated phone line back to the backend, so the generation of content is paced too, not only its delivery.
What CTS Does Not Do
CTS is one signal in a larger handshake, not a flow control protocol on its own. I did not implement XON/XOFF, because RTS/CTS covers the BBS-to-terminal path and running both invites edge cases I did not want to debug.
CTS also does not replace buffering. The 1024-byte limit is a compromise: a larger buffer delays CTS transitions until the backpressure arrives too late to matter, a smaller one toggles the signal constantly.
Related Reading
What surprised me is how far up the stack one bit reaches. CTS is updated a few times a second by a buffer length comparison, and it ends up governing how fast a BBS backend generates text several layers away. I had assumed pacing was something I would have to enforce deliberately at each layer. It turned out to be something I could enforce once, at the bottom, and let propagate.