Synchronized Updates and Frame Coalescing: How Phosphor Paints Atomic Screens

October 19, 2026

A BBS screen is a grid of characters, and for as long as character grids have flown over dial-up lines and TCP sockets alike, they have had one chronic way of embarrassing themselves: tearing. A full-screen repaint travels as a stream of cursor moves and color changes, and a client on a slow link — or a busy one — can catch the middle of it. You’ve seen the result: half the old screen with half the new one written over it, a status bar flashing the wrong colors for a tenth of a second, a table redrawn one row at a time like a document slowly printing.

Phosphor now runs a pair of optimizations that make that class of artifact go away on the clients that support them, without changing anything for the clients that don’t: synchronized updates (the DEC mode 2026 protocol that xterm.js, Terminal.app, and friends already speak) and frame coalescing on the server’s event loop. This post is a tour of both — what they are, how they work, and how Phosphor decides who gets them.

What synchronized updates are

DEC private mode 2026 is a small protocol with a big idea: a program can bracket a stretch of screen output between two escape sequences and tell the terminal everything inside belongs together — don’t let anyone see it half-applied.

// jterm's wire vocabulary for the protocol:
// CSI ? 2026 h  — begin synchronized update (BSU)
// CSI ? 2026 l  — end synchronized update (ESU)
public static final String BEGIN_SYNC_UPDATE = CSI + "?2026h";
public static final String END_SYNC_UPDATE   = CSI + "?2026l";

A compliant terminal collects everything it receives between BSU and ESU and paints it in a single atomic step, on its own paint cycle. Nothing renders half-finished, because nothing is allowed to render until the bracket closes. It’s the sort of thing a GPU-era terminal does for free — it just needed the program on the other end to ask.

That’s the entire protocol. Two sequences, ~16 bytes of overhead per frame. The subtlety is all in which frames get the bracket, and that’s where the engineering lives.

The bracket policy: atomicity where tearing hurts

Phosphor’s screen layer repaints as deltas — only the cells that changed travel over the wire. A bracket policy decides which of those deltas get wrapped. The rules, straight from the screen implementation:

// from jterm's DefaultScreen: the four gates a delta passes to earn a bracket
int TINY_DELTA_CELLS = 16;
bracket = terminal.syncOutputSupported()   // 1. the client declared 2026
    && !syncBracketSuppressed              // 2. not a transient pulse (menu blink)
    && deltaCost <= (width * height / 4)   // 3. small enough that atomity is cheap
    && deltaCost > TINY_DELTA_CELLS;       // 4. big enough that tearing could show

Four gates, each earning its place:

Small deltas paint immediately. A change of sixteen cells or fewer — your keystroke echoed in the chat line, the cursor stepping through a text editor — cannot tear in any way an eye can catch, and latency matters enormously there. Bracketing a two-cell change buys nothing and costs a paint-cycle delay. So tiny deltas stay unbracketed and land the instant they arrive.

Large deltas get a bracket. Anything up to a quarter of the screen repaints in one atomic step on capable clients — tables redrawing, screens transitioning. On a slow or busy link this is the difference between a clean flip and the old print-head effect. Beyond half the screen, the repaint IS the new screen — it goes out as a complete repaint, which a terminal applies wholesale by definition, and needs no bracket of its own.

Transient pulses stay unbracketed. Some outputs exist to be seen for an instant — the menu row that flashes when you pick it pulses for a tenth of a second and then contradicts itself on purpose. A compliant terminal is entitled to merge bracketed updates that arrive faster than its paint cycle, and a 150-millisecond highlight followed immediately by a full repaint is exactly the kind of transient that merging can swallow. So the screen layer exposes an opt-out that the menu code opens around precisely those two pulse frames: they paint immediately and unbracketed, and everything else keeps the full policy. Latency-sensitive by design; atomic where atomicity pays.

Empty deltas emit nothing. Before any of this runs, the screen computes whether there is anything to paint at all — an empty delta is byte-for-byte silent. No lone brackets on idle wires, no gratuitous wake-ups in the client.

One more law lives at the bottom of the refresh path: if a bracket opens, it closes before the function returns, and the flush that carries the cells carries the closing bracket with it. A bracket that dangles across a network boundary is a frozen terminal — the client is holding its paint waiting for an ESU that took the slow road. Phosphor’s screens can’t emit one.

What frame coalescing is

The second optimization happens before any bytes are written. A text interface’s event loop has a natural rhythm: wait for input, dispatch it, repaint, repeat. Fast typists, pastes, and key auto-repeat break that rhythm by queueing many keystrokes faster than the loop can paint — and painting the screen once per keystroke means painting states that no one will ever see, each one superseded by the next.

Phosphor’s event loop takes one batch per iteration instead:

// jterm's DefaultTextGUI event loop, coalescing branch (default):
hadInput = drainPendingInput(COALESCE_MAX_KEYS) > 0;  // dispatch up to 32 keys
checkAutoRefresh();
drainAndRender(0);   // render AT MOST ONCE per iteration — last state wins

Up to 32 queued keystrokes dispatch through the exact same path a single keystroke takes — the same intercepts, the same window routing, the same Ctrl-T chat overlay, the same Ctrl-C detach semantics — and then the screen renders once, showing the state the last key produced. Paste two hundred characters into a text field and watch it land in a single step rather than two hundred intermediate paints. Hold the down-arrow in a list and the server sends one compact frame per batch, not one per repeat event.

Every keystroke still runs through the full dispatch pipeline in order, so intercepts, modals, and anything event-driven behave identically — the only thing that merges is the painting. And the escape hatch is a keystroke away: Ctrl-L forces a complete repaint on the very next cycle, the classic “redraw, please,” for a client whose canvas got wiped out from under it.

The wire feels it too. A burst that would have been two hundred small frames is one frame, and on a modem-speed connection the savings aren’t subtle.

Asking the client first

None of this is ever assumed. A terminal that doesn’t speak 2026 must receive byte-for-byte identical output to what it received before the feature existed — so Phosphor asks every connection whether it wants the protocol, and takes no for an answer gracefully.

At login — after the ECHO handshake settles, since ANSI sent before negotiation completes gets garbled as literal text by some CP437 clients — the telnet tier sends a DECRQM query:

server → client:  ESC [ ? 2026 $ p      "do you support synchronized updates?"
client → server:  ESC [ ? 2026 ; 2 $ y  "yes — mode exists and is available"

A client that answers with the DECRPM form meaning supported (modes 1 or 2) gets brackets and coalesced-render output for the life of the session. The reply is captured and stripped before it can reach the application layer as phantom keystrokes. A client that answers nothing within a half-second grace window is declared unsupported — verdict in, no nagging, legacy streaming from then on.

The verdict is write-once and lands in one place, bridged down to the screen layer:

// the capability verdict bridges to the Terminal the screen drives —
// but ONLY on a tier that actually negotiated. Every other tier's
// Terminal defaults its flag to false, and 2026 is never synthesized.
if (terminal instanceof SyncCapableSocketTerminal syncTerminal) {
    syncTerminal.setSyncOutputSupported(supported);
}

That last line is the quiet backstop of the whole design: the capability has to be earned by negotiation on a real socket. Terminal implementations that never negotiated — mocks, embedded screens, in-process sessions — carry the default flag, which is false, and emit legacy output forever. A default of off means no tier can hallucinate a capability handshake the client never had.

Support today, tier by tier:

  • Telnet (Terminal.app, xterm.js, any 2026-speaking client): probes at login, brackets mid-size deltas, coalesces keystroke bursts. Atomic frames.
  • Web terminal: your browser’s connection rides the same telnet session through Phosphor’s WebSocket bridge, so it gets the same probe — xterm.js 6 answers it natively.
  • Legacy / CP437 / dedicated BBS clients (SyncTERM, MuffinTerm) and SSH: no 2026 verdict is ever declared, so they receive exactly the byte stream they’ve always received. Untouched, by construction.

The web bridge keeps frames balanced

One extra piece serves the web tier, because synchronized updates change what a message boundary means. xterm.js holds all rendering while a bracket is open — which is exactly what makes it tear-free — so an unlucky WebSocket message that ended with a bracket still open would freeze the browser’s paint until bytes from the next message happened to arrive. The bridge that pipes the telnet session into the browser therefore walks every chunk of server output and applies one rule: never end a message with a bracket open. Bytes belonging to an unbalanced frame are held back until the frame’s closing bracket arrives, then everything forwards in one balanced message. On disconnect, whatever was held back gets flushed — the guarantee is that coalescing never loses a byte, it only ever delays one.

The safety hatch

Every layer of this ships with an off switch, and one honest master switch above them all: a sysop setting, terminal.sync_protocol. Turn it off and every positive capability verdict is overridden to unsupported — no brackets, no coalescing, byte-for-byte legacy streaming on every tier, no recompile, no restart dance. The optimization is a layer you can peel off, not a design you’re stuck with.

And it’s tested like a protocol, not like a rendering hint — a dozen dedicated law tests across the three layers of the stack guard the rules above: the probe’s wire bytes and reply parser, the capability bridge’s write-once discipline, the screen’s bracket gates and transient exemption, the bridge’s never-unbalanced-messages law, the flush boundary, the kill switch, and the coalescing loop’s dispatch fidelity. Around them, the full suites at both layers — north of eleven thousand tests — stay green on every change.

What you’ll actually notice

Connect from Terminal.app or the web terminal over a slow link and scroll something big: the flip is clean, no rows printing one at a time over their ancestors. Type fast or paste a paragraph: the screen paints once per burst instead of stuttering through every intermediate state. Pick a menu item: the highlight pulse is still instant — it deliberately rides outside the atomic windows. And connect from a client that’s never heard of mode 2026: you notice exactly nothing, which is the point.

The protocol is running on Phosphor now. SyncTERM, MuffinTerm, and every other flavor of terminal you bring to the door keep working precisely as they always have — the modern ones just get to look modern doing it.