readline.cursorTo()

Moves the terminal cursor to an absolute column, and optionally row, on a writable stream.

Signature
readline.cursorTo(stream, x[, y][, callback])
  • Added in Node.js v0.7.7

Parameters

ParameterTypeDescription
$streamstream.WritableThe TTY stream to write the escape sequence to, typically process.stdout.
$xnumberZero-based column to move the cursor to.
$yoptionalnumberZero-based row to move the cursor to. Omit it to change only the column.
$callbackoptionalFunctionInvoked once the write operation completes.

Return value

booleanfalse if the stream wants the caller to wait for the 'drain' event before writing more data; otherwise true.

Errors

Throws ERR_INVALID_ARG_TYPE if callback is provided and is not a function, or if stream is not a writable stream.

Changelog

  • PHP v18.0.0Passing an invalid callback now throws ERR_INVALID_ARG_TYPE instead of ERR_INVALID_CALLBACK.
  • PHP v12.7.0The stream's write() callback and return value are exposed.

cursorTo() writes a CSI escape sequence to the stream you hand it. cursorTo(stream, 0) emits ESC[1G — move to column 1 — and cursorTo(stream, x, y) emits ESC[(y+1);(x+1)H. Both arguments are zero-based in the API and one-based on the wire, which the example below makes visible.

When to reach for it

  • Redrawing a progress bar or spinner in place instead of scrolling the terminal.
  • Building a small status display with a fixed layout.
  • Rewriting the current line after a prompt.

What trips people up

The function does not check whether the stream is a terminal. Point it at a log file, a pipe, or a CI job's captured output and the raw escape bytes land in the file. Guard with if (process.stdout.isTTY) and fall back to plain lines when it is false.

The return value is the underlying write() return value: false means the stream's buffer is full and you should wait for 'drain'. A tight animation loop that ignores it will grow memory.

Moving the cursor does not erase anything. Old text stays on screen until you overwrite it, which is why cursorTo is almost always paired with clearLine.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Redraw a download progress bar on the same terminal line
  • Return to column 0 before rewriting a status message
  • Place a counter at a fixed row while other output scrolls above it
  • Reset the cursor after printing a multi-line spinner frame

Frequently asked questions

How do I overwrite the last line printed in a Node terminal?

Call readline.cursorTo(process.stdout, 0) to return to column 0, then readline.clearLine(process.stdout, 0) to erase the old text, then write the new text. Moving the cursor alone leaves the previous characters visible.

Why does my output contain [1G or [2K characters?

cursorTo() and clearLine() write raw ANSI escape sequences to whatever stream you pass, and a file or pipe records them literally instead of acting on them. Wrap the calls in if (process.stdout.isTTY) and print plain lines otherwise.

Are cursorTo's x and y arguments zero-based?

Yes — cursorTo(stream, 0, 0) is the top-left cell. The emitted escape sequence uses one-based coordinates, so that call writes ESC[1;1H.

More examples

A progress bar that redraws in place

cursorTo(stdout, 0) rewinds to the start of the line and clearLine erases the previous bar, so each frame replaces the last one instead of scrolling. The isTTY guard means the same function prints a single summary line when output is piped to a file.

Example · javascript
import readline from 'node:readline';

function drawProgress(done, total) {
  if (!process.stdout.isTTY) {
    if (done === total) console.log(`${total}/${total} complete`);
    return;
  }
  const width = 30;
  const filled = Math.round((done / total) * width);
  const bar = '#'.repeat(filled) + '-'.repeat(width - filled);
  readline.cursorTo(process.stdout, 0);
  readline.clearLine(process.stdout, 0);
  process.stdout.write(`[${bar}] ${done}/${total}`);
  if (done === total) process.stdout.write('\n');
}

for (let i = 0; i <= 50; i++) {
  drawProgress(i, 50);
  await new Promise((r) => setTimeout(r, 20));
}

Respecting backpressure in an animation loop

write() returning false means the stream buffer is full — over a slow pipe or an SSH session that happens quickly. Awaiting 'drain' before the next frame keeps memory flat; cursorTo() returns the same boolean if you prefer to check it there.

Example · javascript
import readline from 'node:readline';
import { once } from 'node:events';

async function writeFrame(text) {
  readline.cursorTo(process.stdout, 0);
  readline.clearLine(process.stdout, 0);
  if (!process.stdout.write(text)) {
    await once(process.stdout, 'drain');
  }
}

for (const frame of ['working .', 'working ..', 'working ...']) {
  await writeFrame(frame);
  await new Promise((r) => setTimeout(r, 200));
}
process.stdout.write('\n');

A fixed status row above scrolling output

cursorTo(stream, 0, 0) jumps to the absolute top-left corner, so the status row is rewritten while normal console.log output continues below. The save/restore pair is needed because cursorTo has no notion of where the cursor was.

Example · javascript
const readline = require('node:readline');

function status(line) {
  if (!process.stdout.isTTY) return;
  process.stdout.write('\x1b[s');            // save cursor
  readline.cursorTo(process.stdout, 0, 0);   // top-left
  readline.clearLine(process.stdout, 0);
  process.stdout.write(line.slice(0, process.stdout.columns));
  process.stdout.write('\x1b[u');            // restore cursor
}

status('queue: 12 jobs');
console.log('processing job 1');
status('queue: 11 jobs');
console.log('processing job 2');

Discussion

  • Be the first to comment on this lesson.