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 real.
On this page
The first version of the modem wrote bytes to the line as fast as the backend could produce them. It worked—in the sense that data reached the other side. But the rhythm was wrong. A BBS that should have scrolled text at a meditative 30 characters per second dumped screens in a single burst, then paused for input. The timing budget that made dial-up feel like dial-up had collapsed into the worst of both worlds: the latency of a slow connection with the pacing of a fast one.
The fix was a signal I had been ignoring: 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';
In most modern systems, this handshake is vestigial—USB serial adapters often hardwire CTS high. But for an emulated modem trying to recreate the cadence of a 300-baud connection, CTS becomes 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 bursts without starving the line, short enough that backpressure actually propagates.
The Drain Loop: Byte Credits and Timing
Once bytes enter the transmit buffer, the modem drains them onto the line at the configured baud rate. Rather than scheduling one timer per byte (expensive and drift-prone), the drain loop accumulates “byte credit” based on 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 8 data bits, 1 start bit, and 1 stop bit, getByteIntervalMs returns ~33.3 ms per byte. A 50 ms tick sends one or two bytes, and the fractional credit carries forward. The result: accurate average throughput without per-byte timer overhead.
This credit system also handles the browser’s imperfect timing. If a tick arrives late, the accumulated credit ensures we catch up without drift.
Connection Manager: Honouring the Gate
The modem enforces CTS, but the connection manager—which bridges backends to the BBS-side modem—must 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 is the one that took me three debugging sessions to get right.
The Reordering Bug
My first implementation wrote directly whenever CTS was high, ignoring the queue. This worked until CTS toggled mid-burst. With a fast backend producing data in rapid 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 reordered to A, D, B, C. For terminal output, this manifested as garbled text—lines appearing out of sequence, cursor positioning commands landing in the wrong place.
The fix: if a queue exists, new data joins the queue regardless of CTS state. Order becomes 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 track text as it appears. Compression algorithms of the era assumed this pacing. Animation sequences in ANSI art relied on it.
Without CTS enforcement, a modern backend dumps output in a single burst. The modem then smears it out to match the baud rate, but the batching destroys the rhythm. Eight seconds of content might arrive as a half-second flood followed by seven seconds of silence. It is still slow; it just does not feel like it should.
CTS propagates backpressure from the emulated phone line all the way to the backend. The backend produces data at a pace the modem can absorb, which means the generation of content—not just its delivery—matches the cadence of the original system.
What CTS Does Not Do
CTS is not a flow control protocol; it is one signal in a larger handshake. I chose not to implement XON/XOFF (software flow control) because RTS/CTS is sufficient for the BBS-to-terminal path, and mixing the two creates edge cases I did not want to debug.
CTS also does not replace buffering. The 1024-byte limit is a compromise: large enough that the drain loop runs smoothly, small enough that backpressure is meaningful. A larger buffer would delay CTS transitions; a smaller one would toggle constantly.
Related Reading
CTS turned out to be the smallest contract that could make the system honest about time. It is one bit, updated a few times per second, that forces every other layer—backend, connection manager, modem, terminal—to respect the same timing budget. The modem still feels like a modem because CTS says “not yet” and everything else waits.