rl.on('line')
Registers a listener called with each complete line the readline interface reads from its input.
rl.on('line', listener)- Added in Node.js v0.1.98
Parameters
| Parameter | Type | Description |
|---|---|---|
$'line' | string | The event name. Emitted whenever the input stream receives an end-of-line marker (\n, \r or \r\n). |
$listener | Function | Called as listener(input), where input is the line as a string with the line terminator removed. |
Return value
readline.InterfaceThe interface itself, so listener registrations can be chained.
Errors
Throws ERR_INVALID_ARG_TYPE if listener is not a function. Errors thrown inside the listener are not caught by readline and become uncaught exceptions.
The 'line' event is the original, and still the fastest, way to consume a readline interface. It fires once per complete line, with the terminator stripped, and once more at the end of the stream if the last line has no trailing newline. On a TTY it fires when the user presses Enter.
When to reach for it
- Scanning a very large file where throughput matters — the event API is measurably faster than
for await. - Command loops on
process.stdin, where you also wantrl.prompt()after each line. - Anywhere you need several listeners on the same interface.
What trips people up
The listener is synchronous from readline's point of view. If it returns a promise, readline does not wait: lines keep arriving and your async work fans out unbounded. Either keep the handler synchronous, or call rl.pause() before the async work and rl.resume() after it — or switch to for await, which applies backpressure for free.
Lines can still be delivered after rl.close(), because data already buffered in the stream is drained. Guard with a flag if that matters.
Errors on the input stream are not routed through the interface. Attach 'error' to the input stream yourself.
Example
When to use it
- Count HTTP status codes in a large access log without buffering it
- Drive an interactive command prompt where each Enter runs a command
- Filter a text stream and write matching lines to a second file
- Tail a fixture file in a test and assert on each parsed record
Frequently asked questions
Why does my async 'line' handler run out of order?
readline does not await the listener, so it emits the next line immediately and your promises overlap. Call rl.pause() at the top of the handler and rl.resume() when the async work finishes, or replace the event with a for await...of loop over the interface.
Does the 'line' event include the newline character?
No. The terminator (\n, \r or \r\n) is stripped before the listener is called, so a blank line arrives as an empty string. A final line without a trailing newline is still emitted when the stream ends.
Why do 'line' events still fire after I called rl.close()?
close() relinquishes the streams but does not discard data readline has already buffered, so queued lines are delivered afterwards. Set a flag in your 'close' handler and ignore lines that arrive once it is set.
More examples
Pausing for async work per line
pause() stops the input stream flowing while saveUser() is in flight, so at most one record is written at a time. Without the pause/resume pair every line in the file would call saveUser() before the first promise settled.
import fs from 'node:fs';
import readline from 'node:readline';
import { once } from 'node:events';
const rl = readline.createInterface({
input: fs.createReadStream('users.ndjson'),
crlfDelay: Infinity,
});
rl.on('line', (line) => {
rl.pause();
saveUser(JSON.parse(line))
.then(() => rl.resume())
.catch((err) => {
console.error('row failed:', err.message);
rl.close();
});
});
await once(rl, 'close');
console.log('done');A command loop with prompt redraw
Each Enter emits one 'line' with the terminator removed, so line.trim().split(' ') sees exactly what was typed. Calling rl.prompt() at the end of the handler redraws '> ' for the next command; 'close' runs on quit or Ctrl+D.
const readline = require('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: '> ',
});
const todo = [];
rl.prompt();
rl.on('line', (line) => {
const [cmd, ...rest] = line.trim().split(' ');
if (cmd === 'add') todo.push(rest.join(' '));
else if (cmd === 'list') todo.forEach((t, i) => console.log(`${i + 1}. ${t}`));
else if (cmd === 'quit') return rl.close();
else console.log('add <text> | list | quit');
rl.prompt();
});
rl.on('close', () => console.log(`${todo.length} item(s) left`));Handling stream errors alongside lines
A missing file emits ENOENT on the read stream, not on the interface. Without the input.on('error') handler the process would crash with an unhandled 'error' event; with it, 'close' still fires and reports zero lines.
const fs = require('node:fs');
const readline = require('node:readline');
const input = fs.createReadStream('maybe-missing.txt');
input.on('error', (err) => {
console.error('cannot read input:', err.code);
process.exitCode = 1;
});
const rl = readline.createInterface({ input, crlfDelay: Infinity });
let n = 0;
rl.on('line', () => n++);
rl.on('close', () => console.log('lines seen:', n));
Discussion