Deep Dive: CTS Flow Control in the Serial/Modem System
Why you can't just shove bytes through a virtual modem—RTS/CTS hardware flow control, buffer management, and making dial-up feel authentic.
Deep Dive: CTS Flow Control in the Serial/Modem System
So you’re building a dial-up emulator and you think you can just shove bytes through a virtual modem as fast as the browser will let you. Spoiler: you can’t. At least, not if you want it to feel like the real thing.
This is the story of CTS flow control in emulator.ca—why it matters, how it works, and what happens when you get it wrong. (I got it wrong a few times before getting it right.)
What is RTS/CTS Flow Control?
Let’s start with the basics. In the RS-232 serial world, we have this dance between the DTE (Data Terminal Equipment—your computer) and the DCE (Data Communication Equipment—your modem). The DTE says “I want to send data” by asserting RTS (Request To Send). The DCE responds with CTS (Clear To Send) when it’s ready to accept that data.
DTE (Computer) DCE (Modem)
│ │
│── RTS: "I want to send" ──────>│
│ │
│<── CTS: "Go ahead" ────────────│
│ │
│── Data ────────────────────────>│
│ │
The key insight is that CTS isn’t just a polite “yes, I’m listening.” It’s a gate. When CTS drops, the DTE must stop sending. When CTS rises, the DTE can resume. This is called hardware flow control, and it’s how modems of the 80s and 90s avoided losing data when their internal buffers got full.
(There’s also XON/XOFF software flow control, which uses control characters instead of signal lines. We support that too, but hardware flow control is what most real dial-up software expected.)
The Problem: Output Overruns
Here’s what happens in an emulated environment if you don’t respect flow control: the backend—let’s say it’s a BASIC interpreter or a text adventure—dumps its output as fast as JavaScript can manage. The modem’s TX buffer fills up. Then one of two bad things happens:
-
Data gets dropped. The buffer overflows and bytes vanish into the void. Your user sees garbled output or missing characters.
-
The timing feels wrong. Even if you avoid data loss, the pacing is off. A 300 baud modem should feel like 30 characters per second. If your backend can dump 10KB instantly, even rate-limiting on the output side creates a weird “bursting” feel that doesn’t match the authentic experience.
The fix is to respect the modem’s backpressure signal. When the modem says “stop,” you stop. When it says “go,” you go. That’s CTS.
DTE/DCE Signal Handling
In our architecture, we have explicit types for the signals that flow in each direction:
// serial-types.ts
export type DTEInputSignal = 'DTR' | 'RTS';
export type DCEOutputSignal = 'CTS' | 'DCD' | 'DSR' | 'RI';
DTR (Data Terminal Ready) and RTS come from the computer to the modem. CTS, DCD (Carrier Detect), DSR (Data Set Ready), and RI (Ring Indicator) come from the modem to the computer.
The modem tracks both sets of signals:
// base-modem.ts
protected dteSignals: Record<DTEInputSignal, boolean> = {
DTR: false,
RTS: false,
};
protected dceSignals: Record<DCEOutputSignal, boolean> = {
CTS: false,
DCD: false,
DSR: true, // DSR is always asserted when modem is powered
RI: false,
};
When the DTE asserts RTS, the modem doesn’t immediately assert CTS. Real modems have a small delay—the “RTS-to-CTS delay”—that accounts for internal processing. Our profile captures this:
// From ModemProfile
rtsToCtsDelayMs?: number; // Typically 5ms
The Modem Side: RTS-to-CTS Delay and Buffer Awareness
The modem’s updateCts() method is where the magic happens. CTS isn’t just a mirror of RTS—it also reflects whether the modem’s transmit buffer has room:
// base-modem.ts
protected updateCts(): void {
// Only assert CTS when connected
if (this.state !== 'connected') {
this.emitSignal('CTS', false);
return;
}
// CTS is ready when RTS is asserted AND buffer has space
const ready = this.dteSignals.RTS && this.txBuffer.length < this.maxBuffer;
// Apply RTS-to-CTS delay from profile
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);
}
There are two conditions for CTS to go high:
- RTS must be asserted. The DTE has to ask first.
- The TX buffer must have space. If the buffer is full (we use a 1024-byte limit), CTS stays low even if RTS is high.
And there’s a delay. That 5ms doesn’t sound like much, but it prevents the UI from feeling “instant” and gives the rest of the system time to behave like a real serial line. Remove that delay and everything feels subtly wrong—like watching a video at 1.1x speed.
(The scheduler is injectable, by the way. We use a ManualScheduler in tests so we can control time precisely. Real code uses defaultScheduler which wraps setTimeout.)
The Connection Manager Side: Queueing and Draining
The connection manager sits between the backends (BASIC, Forth, Dungeon, etc.) and the BBS-side modem. When a backend wants to send output, the connection manager doesn’t just slam it through. It checks CTS first.
// connection-manager.ts
// Flow control: queue backend output when CTS is low
private outputQueue: Uint8Array[] = [];
private ctsReady: boolean = false; // Start false until modem is connected
The writeToSerialWithFlowControl() method implements the gate:
private writeToSerialWithFlowControl(data: Uint8Array): void {
if (this.ctsReady && this.outputQueue.length === 0) {
// Modem ready and no queue - write immediately
this.bbsSerial.write(data);
} else {
// Modem busy or queue exists - add to queue to preserve order
this.outputQueue.push(data);
this.log(`Queued ${data.length} bytes (queue size: ${this.outputQueue.length})`);
}
}
Notice the second condition: even if CTS is high, if there’s already a queue, we append to the queue. This preserves ordering. Without it, you’d get interleaved output where newer data arrives before older data that was waiting.
When CTS goes high, we drain:
private handleCtsChange(ctsValue: boolean): void {
const wasReady = this.ctsReady;
this.ctsReady = ctsValue;
if (ctsValue && !wasReady && this.outputQueue.length > 0) {
this.log(`CTS high, draining ${this.outputQueue.length} queued chunks`);
this.drainOutputQueue();
} else if (!ctsValue && wasReady) {
this.log('CTS low, queueing subsequent output');
}
}
private drainOutputQueue(): void {
while (this.ctsReady && this.outputQueue.length > 0) {
const chunk = this.outputQueue.shift()!;
this.bbsSerial.write(chunk);
// After write, CTS might go low - check is done in while condition
}
if (this.outputQueue.length > 0) {
this.log(`Queue paused, ${this.outputQueue.length} chunks remaining`);
}
}
The drain loop checks ctsReady on every iteration. Writing to the serial port triggers the modem to buffer data, which might fill the buffer, which would drop CTS. The loop handles this gracefully by pausing mid-drain.
Subscribing to CTS Changes
The connection manager subscribes to CTS changes when it initializes:
// connection-manager.ts constructor
this.ctsUnsubscribe = this.bbsModem.onSignal((change) => {
if (change.signal === 'CTS') {
this.handleCtsChange(change.value);
}
});
This is the callback chain:
- Modem calls
emitSignal('CTS', ready)after the RTS-to-CTS delay - All signal subscribers are notified
- Connection manager’s
handleCtsChange()runs - If CTS went high, the queue drains
Why Timing Matters for the “Feel” of Dial-Up
Here’s the thing that took me a while to internalize: dial-up isn’t just slow, it’s rhythmic. At 300 baud, you get about 30 characters per second. That’s slow enough that you can almost read each character as it appears. At 1200 baud, text scrolls at a readable pace. At 2400 and above, you start seeing blocks of text.
The TX buffer drain respects this rhythm:
// base-modem.ts
protected startTxDrain(): void {
const TICK_INTERVAL = 50; // Fixed tick interval (ms)
const byteIntervalMs = getByteIntervalMs(this.profile);
let lastTickTime = this.scheduler.now();
let byteCredit = 0; // Fractional bytes accumulated
const drain = () => {
// Calculate how many bytes we can send this tick
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(); // Buffer changed, update CTS
}
this.txTimer = this.scheduler.setTimeout(drain, TICK_INTERVAL);
};
this.txTimer = this.scheduler.setTimeout(drain, TICK_INTERVAL);
}
The byteCredit accumulator handles fractional timing. At 300 baud with 10 bits per byte (8 data + 1 start + 1 stop), we get 30 bytes/second, or about 33ms per byte. With a 50ms tick interval, we’d send roughly 1-2 bytes per tick. The credit system ensures we don’t lose precision to integer rounding.
After each drain batch, we call updateCts(). If the buffer dropped below threshold, CTS goes high (after the delay). If it’s still full, CTS stays low. The cycle continues.
The Debugging Story: What Happens When You Get It Wrong
When I first implemented this, I made a classic mistake: I forgot to check the queue length before writing directly. The code looked like:
// WRONG - race condition!
if (this.ctsReady) {
this.bbsSerial.write(data);
} else {
this.outputQueue.push(data);
}
Can you spot the bug? If CTS is high but there’s already data in the queue from a previous low-CTS period, the new data jumps ahead of the queued data. The user sees output out of order.
The fix was simple but important:
// CORRECT - preserve ordering
if (this.ctsReady && this.outputQueue.length === 0) {
this.bbsSerial.write(data);
} else {
this.outputQueue.push(data);
}
Another gotcha: cleaning up on disconnect. If the user hangs up while there’s still data in the queue, you need to clear it:
private cleanupBackend(): void {
if (this.activeBackend) {
this.activeBackend.hangup();
this.activeBackend = null;
}
// Clear output queue on disconnect
this.outputQueue = [];
}
Without that line, the next connection might start with stale data. Ask me how I found that one. (It involved a very confused BASIC interpreter and a user who insisted they never typed “PRINT ‘HELLO’” but there it was on their screen.)
Testing Flow Control
The tests for flow control are straightforward once you understand the system. Here’s how we verify the queueing behavior:
// connection-manager.test.ts
it('should queue output when CTS is low', () => {
const internalManager = manager as any;
expect(internalManager.ctsReady).toBe(false);
const testData = new Uint8Array([65, 66, 67]); // ABC
internalManager.writeToSerialWithFlowControl(testData);
// Data should be queued since CTS is low
expect(internalManager.outputQueue.length).toBe(1);
expect(internalManager.outputQueue[0]).toEqual(testData);
});
it('should drain queue when CTS goes high', () => {
const internalManager = manager as any;
const writeSpy = jest.spyOn(internalManager.bbsSerial, 'write');
// Queue some data (CTS low)
const data1 = new Uint8Array([65]); // A
const data2 = new Uint8Array([66]); // B
internalManager.writeToSerialWithFlowControl(data1);
internalManager.writeToSerialWithFlowControl(data2);
expect(internalManager.outputQueue.length).toBe(2);
// Simulate CTS going high
internalManager.handleCtsChange(true);
// Queue should be drained in order
expect(internalManager.outputQueue.length).toBe(0);
expect(writeSpy).toHaveBeenNthCalledWith(1, data1);
expect(writeSpy).toHaveBeenNthCalledWith(2, data2);
});
The ManualScheduler lets us control time in tests. We can advance the clock by exact amounts to test the RTS-to-CTS delay without waiting for real milliseconds.
The Full Picture
Let me sketch out the complete data flow with flow control:
Backend (BASIC, Forth, etc.)
│
├── "HELLO\r\n" (8 bytes)
▼
ConnectionManager.writeToSerialWithFlowControl()
│
├── Is CTS high AND queue empty?
│ ├── Yes: bbsSerial.write(data)
│ └── No: outputQueue.push(data)
▼
BBS Serial Port
│
├── writeHandler → BBS Modem.handleSerialWrite()
▼
BBS Modem TX Buffer
│
├── Buffer fills → updateCts() → CTS drops
│
├── startTxDrain() runs every 50ms
│ ├── Calculate bytes based on baud rate
│ ├── line.transmit(bytes)
│ └── updateCts() → if buffer has space, CTS rises
▼
Line (Phone Network)
│
├── Apply latency/jitter
▼
User Modem.receiveFromLine()
│
├── emitData() to user serial port
▼
Terminal Display
When CTS drops:
- ConnectionManager queues new output instead of writing
- Modem continues draining its existing buffer
- When buffer has space, modem asserts CTS (after delay)
- ConnectionManager’s callback fires
- Queue drains until CTS drops again or queue empties
It’s not glamorous, but it’s the difference between a modem that feels real and one that occasionally drops characters for no good reason.
Related Reading
- 2026-01-26 — First implementation of CTS-based backpressure and the debugging story
- 2026-02-01 — Flow control timeout hardening during server migration
CTS flow control isn’t sexy, but it’s essential. Real dial-up had backpressure, and so does ours.