Table of Contents
Back to Blog

Node.js Guide

How to Count Lines in a File with Node.js (readline, Streams, and the Modern for await...of Pattern)

Count lines in a file with Node.js using readFileSync, readline, fs.createReadStream, and manual Buffer scanning. Covers trailing newline edge cases, for await...of, callback versus Promise tradeoffs, and large-file streaming in Node.js 22+.

Node.js 22 LTSreadlineStreams
Published: May 14, 2026Updated: May 14, 202611 min readAuthor: Line Counter Editorial Team
Node.jsStreamsreadlineFile I/OPerformance

Node.js gives you three practical ways to solve node js count lines in file:

  1. read the whole file and split it
  2. stream it through readline
  3. scan raw bytes from createReadStream

Most tutorials stop at option 1:

import { readFileSync } from 'node:fs';

const count = readFileSync('file.txt', 'utf8').split('\n').length;

That is easy to type, but it has two real problems:

  • node js split newline count over-counts when the file ends with a trailing newline
  • the whole file is loaded into memory, which is the wrong answer for node js count lines large file

The modern default is node js readline count lines with for await...of:

import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';

const rl = createInterface({
  input: createReadStream('file.txt'),
  crlfDelay: Infinity,
});

let count = 0;
for await (const line of rl) {
  count++;
}

That is the cleanest node js count lines async pattern for most real applications.

There is one more nuance that matters if you care about speed. The current Node.js docs say two things that are easy to miss:

  • callback-based fs APIs are still preferable when maximal performance matters
  • readline async iteration is slower than the traditional 'line' event API

So this guide separates the real jobs:

  • node js count lines in file for small files
  • node js readline count lines for readable streaming
  • node js count lines async with for await...of
  • node js createreadstream count lines for maximum throughput
  • node js split newline count edge cases with empty files and trailing newlines

If you are comparing plain JavaScript and typed Node code side by side, the companion TypeScript file reading guide covers the same stream patterns plus the Buffer-versus-string type trap.

30-Second Cheat Sheet

  • Small file, simple script: readFileSync(path, 'utf8') plus corrected line counting
  • Small file, async code: await readFile(path, 'utf8') plus corrected line counting
  • Streaming default: readline.createInterface() plus for await...of
  • Faster readline path: 'line' event plus once(rl, 'close')
  • Fastest general stream count: createReadStream() plus raw Buffer newline scanning
  • Windows CRLF input: crlfDelay: Infinity

If your main goal is node js count lines in a production app, readline is the readable default and raw byte scanning is the performance default.

Method 1: readFileSync and the split('\n') Trap

This is the easiest node js count lines in file pattern:

import { readFileSync } from 'node:fs';

function countLinesSync(filePath) {
  const content = readFileSync(filePath, 'utf8');
  return content.split('\n').length;
}

It works for demos, but the count is not stable.

Examples:

'a\nb'.split('\n').length;   // 2
'a\nb\n'.split('\n').length; // 3
''.split('\n').length;       // 1

That is the core node js split newline count bug:

  • trailing newline adds an empty final element
  • empty file becomes one empty string instead of zero logical lines

Locally on Node.js 22.22.1, I verified:

  • "a\nb" -> 2
  • "a\nb\n" -> 3
  • "" -> 1

Correct the split-based version

For small files, make the last-line rule explicit:

import { readFileSync } from 'node:fs';

function countLinesSync(filePath) {
  const content = readFileSync(filePath, 'utf8');

  if (content.length === 0) {
    return 0;
  }

  const lines = content.split('\n');
  return lines[lines.length - 1] === '' ? lines.length - 1 : lines.length;
}

This is acceptable for a small trusted file.

A safer cross-platform string helper

If you want one string helper that works across \n, \r\n, and bare \r:

function countLinesText(text) {
  if (text.length === 0) return 0;

  const newlineMatches = text.match(/\r\n|\r|\n/g) ?? [];
  const endsWithNewline = /\r\n|\r|\n$/.test(text);

  return newlineMatches.length + (endsWithNewline ? 0 : 1);
}

That fixes the node js split newline count issue without requiring a streaming rewrite.

Why this still is not the best general answer

Even the corrected version is still a full-file read.

That means:

  • memory grows with file size
  • large files create allocation pressure
  • node js count lines large file becomes a bad fit

Use it for files under roughly 10MB to 50MB, one-off scripts, or places where clarity matters more than scalability.

If you are solving the same problem in browser-side JavaScript, the JavaScript split newline guide covers the same string edge cases in more detail.

Small-file async variant

If you want node js count lines async without switching to streams yet, the small-file Promise version is straightforward:

import { readFile } from 'node:fs/promises';

async function countLinesAsync(filePath) {
  const content = await readFile(filePath, 'utf8');
  return countLinesText(content);
}

This is fine when the file is small. It is still a full-file read, so it is not the right default for node js count lines large file.

Method 2: readline Plus for await...of

This is the clean modern node js readline count lines pattern:

import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';

async function countLines(filePath) {
  const rl = createInterface({
    input: createReadStream(filePath),
    crlfDelay: Infinity,
  });

  let count = 0;

  for await (const line of rl) {
    count++;
  }

  return count;
}

This is the most readable node js count lines async answer for large files because:

  • it streams instead of loading the whole file
  • it works naturally with async and await
  • crlfDelay: Infinity treats \r\n as one line break
  • it counts logical lines instead of making you reason about byte chunks

Why crlfDelay: Infinity matters

The Node.js docs use this exact option in the read-file-line-by-line example and explain why:

  • it recognizes all \r\n pairs as a single line break
  • it is a good default for file reading

Locally, I verified that CRLF file input produced clean lines "a" and "b" with this option.

The last-line behavior that makes readline attractive

This is where node js readline count lines beats the naive split approach.

The Node.js docs explicitly say the 'line' event is also emitted when a stream ends without a final end-of-line marker.

That means both of these count as 2:

a
b
a
b

Locally on Node.js 22.22.1, I verified:

  • empty file -> 0
  • "a\nb" -> 2
  • "a\nb\n" -> 2

That is why readline is such a strong baseline for node js count lines in file.

The version baseline

rl[Symbol.asyncIterator]() was added in Node.js v11.4.0 and v10.16.0, and its support stopped being experimental in later releases.

So node js for await of lines is not brand-new anymore, but it is still the modern style most developers want to write.

Method 3: for await...of Versus the 'line' Event

This is the performance caveat the Node.js docs state directly:

  • for await...of is convenient
  • it is slower than the traditional 'line' event API

The docs even show the mixed approach for people who want async control flow without paying the full iterator overhead.

Event-based readline

import { once } from 'node:events';
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';

async function countLinesEvent(filePath) {
  const rl = createInterface({
    input: createReadStream(filePath),
    crlfDelay: Infinity,
  });

  let count = 0;
  rl.on('line', () => {
    count++;
  });

  await once(rl, 'close');
  return count;
}

This is still a clean node js count lines async function, but it follows the performance-sensitive guidance from the docs more closely.

readline/promises

Node.js also ships a Promises API:

import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline/promises';

async function countLinesPromises(filePath) {
  const rl = createInterface({
    input: createReadStream(filePath),
    crlfDelay: Infinity,
  });

  let count = 0;
  for await (const line of rl) {
    count++;
  }

  return count;
}

readline/promises.createInterface() was added in Node.js v17.0.0.

This is a nice fit when the rest of your codebase already leans on Promise-based APIs, but it does not remove the underlying iterator cost.

The callback-versus-Promise nuance

The current node:fs docs say callback-based fs APIs are preferable over promise APIs when maximal performance in execution time and memory allocation is required.

That official note is about the fs module broadly, not specifically a guarantee that every callback-shaped line counter always wins. But it is still relevant to node js count lines large file because:

  • the stream itself comes from node:fs
  • the callback-style event APIs are usually the lower-overhead path

So the practical rule is:

  • normal code: use Promise-based patterns freely
  • hot path: prefer streams first, then benchmark event-driven code

Method 4: createReadStream Plus Raw Byte Scanning

If all you need is the count, not the line content, this is the strongest node js createreadstream count lines pattern:

import { createReadStream } from 'node:fs';

function countLinesFast(filePath) {
  return new Promise((resolve, reject) => {
    const stream = createReadStream(filePath);

    let count = 0;
    let sawAnyByte = false;
    let lastByte = 0x0a;

    stream.on('data', (chunk) => {
      sawAnyByte = true;

      for (let i = 0; i < chunk.length; i++) {
        if (chunk[i] === 0x0a) {
          count++;
        }
      }

      lastByte = chunk[chunk.length - 1];
    });

    stream.on('end', () => {
      if (!sawAnyByte) {
        resolve(0);
        return;
      }

      resolve(lastByte === 0x0a ? count : count + 1);
    });

    stream.on('error', reject);
  });
}

Why this is usually the fastest general node js count lines large file approach:

  • it counts \n bytes directly
  • it does not allocate one string per line
  • it avoids readline interface overhead

This is the right answer when you care about throughput more than elegance.

The edge case you still need to handle

If the final line does not end with \n, the last logical line was never counted as a newline byte.

That is why the code checks:

  • did we read any bytes at all?
  • was the last byte a newline?

Without that correction, the raw byte scanner would under-count the same file shape that readline handles correctly by default.

stream/promises pipeline

If you prefer pipeline-shaped code, Node.js also gives you stream/promises:

import { createReadStream } from 'node:fs';
import { Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';

async function countLinesPipeline(filePath) {
  let count = 0;
  let sawAnyByte = false;
  let lastByte = 0x0a;

  const counter = new Transform({
    transform(chunk, encoding, callback) {
      sawAnyByte = true;

      for (let i = 0; i < chunk.length; i++) {
        if (chunk[i] === 0x0a) {
          count++;
        }
      }

      lastByte = chunk[chunk.length - 1];
      callback(null, chunk);
    },
  });

  await pipeline(createReadStream(filePath), counter);

  if (!sawAnyByte) return 0;
  return lastByte === 0x0a ? count : count + 1;
}

The stream/promises API was added in Node.js v15.0.0.

This is still fundamentally the same node js createreadstream count lines idea, just packaged around pipeline.

Part 5: Trailing Newlines, Empty Files, and CRLF

This is the edge-case map that matters for node js count lines.

Naive split

For these file contents:

"a\nb"
"a\nb\n"
""

split('\n').length yields:

  • 2
  • 3
  • 1

That is why node js split newline count goes wrong so easily.

readline

For the same logical inputs, local verification on Node.js 22.22.1 gave:

  • "a\nb" -> 2
  • "a\nb\n" -> 2
  • "" -> 0

That is exactly the behavior you want in most node js readline count lines code.

Raw byte scanning

For raw stream scanning:

  • each \n increments the counter
  • a final non-newline byte means one extra logical line
  • an empty file must stay 0

That is why the manual scanner needs explicit final-byte logic while readline does not.

CRLF summary

For file reading, crlfDelay: Infinity is the most conservative choice:

const rl = createInterface({
  input: createReadStream(filePath),
  crlfDelay: Infinity,
});

The Node docs explicitly call this out as the file-reading setting that recognizes \r\n as one line break.

Part 6: A Production-Ready Node.js Line Counter

If you want one reusable node js count lines in file helper for real code, this is a practical baseline:

import { createReadStream, statSync } from 'node:fs';
import { createInterface } from 'node:readline';

export async function countLines(filePath, options = {}) {
  const { countEmpty = true } = options;

  const stat = statSync(filePath);
  if (!stat.isFile()) {
    throw new TypeError(`Not a regular file: ${filePath}`);
  }

  if (stat.size === 0) {
    return 0;
  }

  const rl = createInterface({
    input: createReadStream(filePath),
    crlfDelay: Infinity,
  });

  let count = 0;

  for await (const line of rl) {
    if (countEmpty || line.trim() !== '') {
      count++;
    }
  }

  return count;
}

This keeps the default node js count lines async path readable while still handling:

  • empty files
  • CRLF input
  • optional blank-line skipping

If you want to go lower level for peak throughput, swap the readline loop for the raw createReadStream scanner from the previous section.

Compatibility Notes

These are the version boundaries that actually matter:

  • readline 'line' event: ancient baseline, available everywhere that matters
  • rl[Symbol.asyncIterator](): added in Node.js v11.4.0 and v10.16.0
  • readline/promises.createInterface(): added in Node.js v17.0.0
  • stream/promises and pipeline(): added in Node.js v15.0.0
  • rl[Symbol.dispose](): added in Node.js v23.10.0 and v22.15.0

One correction is worth making explicit: rl[Symbol.dispose]() is not a Node.js 26-only feature.

On this machine, running Node.js 22.22.1, the method already exists:

Symbol.dispose in rl; // true

But do not confuse that with safe baseline support for using syntax in all deployed Node.js environments. In current LTS code, explicit rl.close() is still the clearer assumption.

Local Benchmark Notes

I measured the main methods locally on:

  • Node.js 22.22.1
  • Linux
  • SSD
  • a hot-cache text file of about 105 MB
  • 5,000,000 fixed-width lines

Results on this machine:

  • readFileSync + split -> about 1.52s
  • readline with 'line' event -> about 1.43s
  • readline with for await...of -> about 2.13s
  • readline/promises -> about 2.09s
  • raw createReadStream byte scan -> about 0.57s

These are local numbers, not universal laws. Different file systems, antivirus, cache state, line length, and CPU behavior will move the totals.

Still, the stable ranking is useful:

  • raw byte scan is the fastest general answer
  • readline events beat for await...of here, which matches the docs
  • full-file reads are simple, but they stop scaling first

Sources Checked

I also verified the edge cases locally on Node.js 22.22.1:

  • split('\n').length over-counts "a\nb\n" and ""
  • readline counts "a\nb" and "a\nb\n" as 2
  • readline/promises.createInterface() is available
  • rl[Symbol.asyncIterator]() and readable[Symbol.asyncIterator]() both exist
  • rl[Symbol.dispose]() exists, but using syntax is not a safe assumption for this Node.js baseline

Need to count lines without Node.js streaming boilerplate?

Try the Line Counter. No readline setup, no trailing-newline surprises, just the number.

Frequently Asked Questions

How do I count lines in a file with Node.js?

For small files, read the file as utf8 text and count carefully. For large files, use readline with createReadStream or scan newline bytes directly.

What is the best Node.js readline count lines pattern?

Use createInterface with createReadStream and crlfDelay Infinity. Choose for await...of for readability or the line event for a somewhat faster path.

Why does split('\n').length give the wrong count?

Because a trailing newline produces an extra empty string after the final separator, and an empty file splits into one empty string instead of zero logical lines.

Does readline count the last line without a trailing newline?

Yes. Node.js documents that the line event is also emitted when the input ends without a final end-of-line marker.

Should I use callback APIs or Promise APIs in Node.js?

Use Promises for normal application code. When performance is the priority and you have measured the bottleneck, the Node.js fs docs still prefer callback APIs.

What is the fastest Node.js count lines large file approach?

A createReadStream byte scan is usually the fastest general Node.js approach because it counts newline bytes directly without allocating one string per line.

Related Guides