A typewriter pacer takes text that arrives in uneven bursts and releases it one character at a time at a steady, even rhythm — the smooth "typing" effect you see in AI chat apps. When a model streams a reply, the network delivers text in lumps: ten characters, a pause, then thirty more. Painting each lump the instant it lands looks jittery, so a pacer buffers whatever arrives and drains that buffer at a fixed pace, giving the reader a calm, constant stream no matter how bursty the source is.
function typewriterPacer(
onChar: (char: string) => void, // called once per released character
delay?: number, // milliseconds between characters (default 20)
): {
push(text: string): void; // feed a burst of text into the buffer
flush(): void; // emit everything buffered right now
stop(): void; // cancel pending output and drop the buffer
};
const pacer = typewriterPacer((ch) => process.stdout.write(ch), 20);
pacer.push('Hello'); // 'H','e','l','l','o' released at 20ms, 40ms, 60ms...
pacer.push(' world'); // queued behind the rest — the cadence stays steady
// flush() skips the wait and emits whatever is left immediately
const pacer = typewriterPacer(onChar, 50);
pacer.push('done');
pacer.flush(); // 'd','o','n','e' all emitted now; the pending timer is cleared
delay ms, no matter how much text is waiting.push while the buffer is still draining appends to the same queue; it must not start a second, competing timer.push restarts it cleanly.onChar is your only output.We are building a small buffer that accepts text in uneven bursts and hands it back one character at a time on a fixed heartbeat.
Picture a chat app streaming a reply from a language model. The text does not arrive smoothly — the network hands you The qu, then a pause, then ick brown fox. If you paint each chunk on screen the instant it lands, the reader sees stutters: a freeze, a jump, a freeze. What you want instead is a calm, even "typing" effect. The pacer sits between the messy input and the screen: text goes in in lumps, and comes out one steady character at a time.
Think of a funnel. Bursts of text pour into the top at random moments and pile up in a buffer. Underneath sits a metronome ticking at a fixed interval, and on every tick it lets exactly one character fall through. The pile rises and falls with the bursts, but the drip out of the bottom never changes its rhythm.
The two moving parts are a queue (the pile of characters waiting their turn) and a single timer (the metronome). Everything else follows from keeping those two honest.
The obvious idea: when a burst arrives, schedule one setTimeout per character, spacing them by delay.
function typewriterPacer(onChar, delay = 20) {
return {
push(text) {
[...text].forEach((ch, i) => {
setTimeout(() => onChar(ch), i * delay);
});
},
flush() {},
stop() {},
};
}
This looks right for a single burst, but it falls apart the moment a second burst arrives before the first has finished. Each push starts its own timeline from now, so the two timelines overlap: the second burst's first character fires at almost the same moment as one of the first burst's later characters. The output arrives in colliding clumps — two characters at once, then a gap — which is the exact jitter we set out to remove. And because every timer is already scheduled, there is no clean way to flush or stop.
Keep one shared queue and one timer. A burst simply appends to the queue; the timer drains it one character per tick and reschedules itself until the queue runs dry.
function typewriterPacer(onChar, delay = 20) {
const queue = []; // characters buffered but not yet shown
let timer = null; // the single drain timer; null means idle
function tick() {
onChar(queue.shift()); // release exactly one character
// Reschedule only while text remains; otherwise go idle.
timer = queue.length > 0 ? setTimeout(tick, delay) : null;
}
return {
push(text) {
for (const ch of text) queue.push(ch); // enqueue the whole burst
// Start the metronome only if it is not already running.
if (timer === null && queue.length > 0) {
timer = setTimeout(tick, delay);
}
},
flush() {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
while (queue.length > 0) onChar(queue.shift()); // dump the rest now
},
stop() {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
queue.length = 0; // drop whatever was buffered
},
};
}
module.exports = { typewriterPacer };
The shift in thinking is that arrival and display are now separate concerns. push never touches the clock's rhythm — it only adds to the pile. A single timer owns the cadence, and the timer === null guard is what stops a second burst from starting a competing metronome. When the queue empties, tick sets the timer back to null so the next push can start a fresh one.
Say delay is 20ms and onChar writes to the screen.
push('Hi') — the queue becomes ['H', 'i']. The timer was null, so we schedule the first tick for t=20ms.tick runs onChar('H'), leaving ['i']. Text remains, so it reschedules for t=40ms.push('!') at t=25ms — the queue becomes ['i', '!']. The timer is not null (a tick is already pending for t=40ms), so we just add to the pile and leave the rhythm alone.onChar('i'), queue ['!'], reschedule for t=60ms.onChar('!'), the queue is empty, so the timer becomes null and the metronome rests.Three characters, emitted at 20/40/60ms — a perfectly even beat, even though the '!' arrived in the middle of the stream.
push always calls setTimeout, two overlapping bursts run two metronomes and the output doubles up. Fix: only start the timer when timer === null.tick reschedules unconditionally, it keeps firing on an empty queue and calls onChar(undefined) forever. Fix: reschedule only while queue.length > 0, and set timer = null otherwise.text.split('') — that cuts a multi-byte emoji such as 🎉 into two broken halves. Fix: iterate with for...of (or [...text]), which walks whole Unicode code points.clearTimeout leaves a pending tick that later fires into an empty queue. Fix: clear the timer and null it before touching the buffer.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
A typewriter pacer takes text that arrives in uneven bursts and releases it one character at a time at a steady, even rhythm — the smooth "typing" effect you see in AI chat apps. When a model streams a reply, the network delivers text in lumps: ten characters, a pause, then thirty more. Painting each lump the instant it lands looks jittery, so a pacer buffers whatever arrives and drains that buffer at a fixed pace, giving the reader a calm, constant stream no matter how bursty the source is.
function typewriterPacer(
onChar: (char: string) => void, // called once per released character
delay?: number, // milliseconds between characters (default 20)
): {
push(text: string): void; // feed a burst of text into the buffer
flush(): void; // emit everything buffered right now
stop(): void; // cancel pending output and drop the buffer
};
const pacer = typewriterPacer((ch) => process.stdout.write(ch), 20);
pacer.push('Hello'); // 'H','e','l','l','o' released at 20ms, 40ms, 60ms...
pacer.push(' world'); // queued behind the rest — the cadence stays steady
// flush() skips the wait and emits whatever is left immediately
const pacer = typewriterPacer(onChar, 50);
pacer.push('done');
pacer.flush(); // 'd','o','n','e' all emitted now; the pending timer is cleared
delay ms, no matter how much text is waiting.push while the buffer is still draining appends to the same queue; it must not start a second, competing timer.push restarts it cleanly.onChar is your only output.We are building a small buffer that accepts text in uneven bursts and hands it back one character at a time on a fixed heartbeat.
Picture a chat app streaming a reply from a language model. The text does not arrive smoothly — the network hands you The qu, then a pause, then ick brown fox. If you paint each chunk on screen the instant it lands, the reader sees stutters: a freeze, a jump, a freeze. What you want instead is a calm, even "typing" effect. The pacer sits between the messy input and the screen: text goes in in lumps, and comes out one steady character at a time.
Think of a funnel. Bursts of text pour into the top at random moments and pile up in a buffer. Underneath sits a metronome ticking at a fixed interval, and on every tick it lets exactly one character fall through. The pile rises and falls with the bursts, but the drip out of the bottom never changes its rhythm.
The two moving parts are a queue (the pile of characters waiting their turn) and a single timer (the metronome). Everything else follows from keeping those two honest.
The obvious idea: when a burst arrives, schedule one setTimeout per character, spacing them by delay.
function typewriterPacer(onChar, delay = 20) {
return {
push(text) {
[...text].forEach((ch, i) => {
setTimeout(() => onChar(ch), i * delay);
});
},
flush() {},
stop() {},
};
}
This looks right for a single burst, but it falls apart the moment a second burst arrives before the first has finished. Each push starts its own timeline from now, so the two timelines overlap: the second burst's first character fires at almost the same moment as one of the first burst's later characters. The output arrives in colliding clumps — two characters at once, then a gap — which is the exact jitter we set out to remove. And because every timer is already scheduled, there is no clean way to flush or stop.
Keep one shared queue and one timer. A burst simply appends to the queue; the timer drains it one character per tick and reschedules itself until the queue runs dry.
function typewriterPacer(onChar, delay = 20) {
const queue = []; // characters buffered but not yet shown
let timer = null; // the single drain timer; null means idle
function tick() {
onChar(queue.shift()); // release exactly one character
// Reschedule only while text remains; otherwise go idle.
timer = queue.length > 0 ? setTimeout(tick, delay) : null;
}
return {
push(text) {
for (const ch of text) queue.push(ch); // enqueue the whole burst
// Start the metronome only if it is not already running.
if (timer === null && queue.length > 0) {
timer = setTimeout(tick, delay);
}
},
flush() {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
while (queue.length > 0) onChar(queue.shift()); // dump the rest now
},
stop() {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
queue.length = 0; // drop whatever was buffered
},
};
}
module.exports = { typewriterPacer };
The shift in thinking is that arrival and display are now separate concerns. push never touches the clock's rhythm — it only adds to the pile. A single timer owns the cadence, and the timer === null guard is what stops a second burst from starting a competing metronome. When the queue empties, tick sets the timer back to null so the next push can start a fresh one.
Say delay is 20ms and onChar writes to the screen.
push('Hi') — the queue becomes ['H', 'i']. The timer was null, so we schedule the first tick for t=20ms.tick runs onChar('H'), leaving ['i']. Text remains, so it reschedules for t=40ms.push('!') at t=25ms — the queue becomes ['i', '!']. The timer is not null (a tick is already pending for t=40ms), so we just add to the pile and leave the rhythm alone.onChar('i'), queue ['!'], reschedule for t=60ms.onChar('!'), the queue is empty, so the timer becomes null and the metronome rests.Three characters, emitted at 20/40/60ms — a perfectly even beat, even though the '!' arrived in the middle of the stream.
push always calls setTimeout, two overlapping bursts run two metronomes and the output doubles up. Fix: only start the timer when timer === null.tick reschedules unconditionally, it keeps firing on an empty queue and calls onChar(undefined) forever. Fix: reschedule only while queue.length > 0, and set timer = null otherwise.text.split('') — that cuts a multi-byte emoji such as 🎉 into two broken halves. Fix: iterate with for...of (or [...text]), which walks whole Unicode code points.clearTimeout leaves a pending tick that later fires into an empty queue. Fix: clear the timer and null it before touching the buffer.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.