JavaScript Basics: Process Text One Line at a Time
Text streams arrive in chunks, but a log or a list of names is usually easier to process one line at a time. Node’s readline interface finds line boundaries, and a for await...of loop lets you consume those lines sequentially. These examples use small in-memory streams so there is no download, prompt, or external file to prepare.
These examples were tested on Linux with Node.js v24.19.0. They use built-in APIs only, need no network access, and include assertions that check the results. See the Node.js readline documentation for the API contract.
Prepare a scratch directory
You need Bash, a text editor, Node.js 24 on your PATH, and the mktemp, rm, and rmdir utilities. Keep using the same terminal. The guarded commands stop the shell if setup fails. Save each named source file as UTF-8; .mjs selects ES modules.
node --version
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1The tested runtime prints:
v24.19.01. Read mixed line endings
Save this as read-line-boundaries.mjs:
import assert from 'node:assert/strict';
import { Readable } from 'node:stream';
import { createInterface } from 'node:readline';
const input = Readable.from(['alpha\r', '\nbeta\ngamma']);
const lines = createInterface({ input, crlfDelay: Infinity });
const result = [];
try {
for await (const line of lines) result.push(line);
} finally {
lines.close();
input.destroy();
}
assert.deepEqual(result, ['alpha', 'beta', 'gamma']);
console.log(JSON.stringify(result));Run it:
node read-line-boundaries.mjsOutput:
["alpha","beta","gamma"]The first newline is split across two chunks. crlfDelay: Infinity treats the carriage return and line feed as one newline even if they arrive separately. The final gamma has no newline, but readline still yields it when input ends. Start consuming immediately after creating the interface; inserting unrelated asynchronous work first can miss lines. The finally block makes resource cleanup explicit.
2. Skip comments and blank lines
Save this as filter-list-lines.mjs:
import assert from 'node:assert/strict';
import { Readable } from 'node:stream';
import { createInterface } from 'node:readline';
const input = Readable.from(['# shopping\n apples \n\npears\n']);
const lines = createInterface({ input, crlfDelay: Infinity });
const items = [];
try {
for await (const line of lines) {
const item = line.trim();
if (item === '' || item.startsWith('#')) continue;
items.push(item);
}
} finally {
lines.close();
input.destroy();
}
assert.deepEqual(items, ['apples', 'pears']);
console.log(`Items: ${items.join(', ')}`);Run it:
node filter-list-lines.mjsOutput:
Items: apples, pearsTrimming is a rule of this particular list format, not something every text processor should do. Whitespace might be meaningful in other input. Here only a line whose first non-space character is # is a comment; an inline # would remain part of the item. continue skips the current line without ending the loop.
3. Summarize records without retaining every line
Save this as count-status-lines.mjs:
import assert from 'node:assert/strict';
import { Readable } from 'node:stream';
import { createInterface } from 'node:readline';
const input = Readable.from(['OK\nERROR\nOK\nUNKNOWN\n']);
const lines = createInterface({ input, crlfDelay: Infinity });
const counts = { OK: 0, ERROR: 0, other: 0 };
try {
for await (const line of lines) {
if (line === 'OK' || line === 'ERROR') counts[line] += 1;
else counts.other += 1;
}
} finally {
lines.close();
input.destroy();
}
assert.deepEqual(counts, { OK: 2, ERROR: 1, other: 1 });
console.log(JSON.stringify(counts));Run it:
node count-status-lines.mjsOutput:
{"OK":2,"ERROR":1,"other":1}Only three counters are retained rather than an array of all lines. This is useful for summaries, although readline must still buffer an individual unfinished line, so an enormous line can use substantial memory. These in-memory sources do not fail. For real files or sockets, add explicit input-stream error handling: readline’s async iterator does not forward input errors to the loop. Do not assume a loop’s try/catch alone covers them.
Clean up
In the same terminal, remove only the example files, return to your original directory, and remove the now-empty scratch directory:
rm -- read-line-boundaries.mjs filter-list-lines.mjs count-status-lines.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dirAcknowledgements
This post was created with assistance from AI.