read-last-lines
Read a file's last N lines in Node.js without scanning the whole file, using efficient reverse chunked reads.
Repository Health
Technical Analysis
read-last-lines is a small, focused Node.js utility for grabbing the tail end of a file — the last N lines — without loading or scanning the entire file into memory. It reads the file backward in fixed-size chunks from the end, counting newline boundaries as it goes, and stops as soon as it has collected enough lines. This makes it well suited for tailing large log files, previewing the end of large text output, or building simple log-viewer tooling where reading gigabytes of file just to get the last few lines would be wasteful.
The API is a single async function, read(path, maxLineCount, encoding), returning a promise that resolves to either a string (default utf8, or any Node BufferEncoding) or a raw Buffer when 'buffer' is passed as the encoding. It correctly handles edge cases that trip up naive tail implementations: missing trailing newlines, Windows-style CRLF line endings, multi-byte UTF-8 characters split across chunk boundaries, empty files, and files made up entirely of blank lines. It is used inside larger projects such as Signal Desktop and Ghost CLI for exactly this kind of tail-reading behavior.
What You Get
- A single
read(path, maxLineCount, encoding)async function with a minimal, predictable API - Reverse chunked reads (64KB chunks) so only the tail of a file is ever touched, regardless of overall file size
- String output in any Node
BufferEncoding(defaultutf8), or rawBufferoutput when'buffer'is passed as the encoding - TypeScript type definitions (
src/index.d.ts) included in the package, with overloads for the buffer vs. string return types - Correct handling of missing trailing newlines, Windows CRLF line endings, and multi-byte UTF-8 sequences split across chunk boundaries
- Graceful behavior on edge cases: empty files, all-blank-line files,
maxLineCountof 0 or negative, and non-existent files (rejects with a clear"file does not exist"error)
Common Use Cases
- Tailing the last N lines of an application or server log file for a status page or CLI diagnostic
- Building a lightweight log-viewer or
tail -f-style feature without shelling out to a systemtailbinary - Previewing the end of large generated text files (exports, dumps, reports) in a web UI without downloading the whole file
- Reading the last few entries of an append-only file (audit trail, changelog, event log) for a quick summary
Under The Hood
Architecture
The library is a single exported async function in src/index.js with no internal module boundaries: read() opens a file handle via fs/promises, reads its size via stat(), then walks backward through the file in fixed 64KB chunks using explicit position reads on the same handle, scanning each chunk’s bytes in reverse for newline (0x0a) boundaries and stopping as soon as maxLineCount lines have been counted. Two small helpers, logicalLineCount and finalizeLines, clean up edge cases afterward — trimming an accidental leading newline left over from a chunk boundary and discarding any extra lines the chunked scan overshot. There is no dependency injection or plugin surface; the whole module is self-contained, so the only thing that could break by changing the core abstraction is the byte-scanning loop itself.
Tech Stack
The runtime has zero production dependencies, relying solely on Node’s built-in fs/promises module (requiring Node >=20 per engines). Development tooling includes Mocha and Chai/chai-as-promised for behavioral tests, tsd for type-level testing of the bundled .d.ts file, ESLint (eslint:recommended, tab indentation, double quotes) for linting, and benchmark/benny plus a custom harness under analyze/ for comparing this implementation’s performance against alternate tail-reading strategies (legacy bytewise, chunked reverse, split-slice, and the system tail binary).
Code Quality
The test suite in test/index.spec.js is thorough for a library this size, covering trailing-newline handling, Windows CRLF line endings, multi-byte UTF-8 content split across chunk boundaries, buffer vs. string output, unreadable files (EACCES/EPERM), empty files, newline-only files, non-existent files, and invalid encodings. Error handling is explicit: a try/catch/finally wraps the read loop, file handles are always closed, and ENOENT is normalized into a descriptive "file does not exist" error rather than leaking the raw Node error code. GitHub Actions workflows run tests, linting, and CodeQL analysis on every change, giving the project real CI coverage despite its small surface area.
API Design
The public API is intentionally minimal — one function, three parameters, sensible defaults (utf8 encoding) — which keeps the learning curve close to zero for new consumers. The 'buffer' encoding escape hatch and bundled TypeScript overloads (returning Promise<Buffer> vs Promise<string> depending on the encoding argument) are a thoughtful touch that most single-purpose utility packages skip. The underlying reverse-chunk-scan technique is a well-known tail-reading pattern rather than a novel algorithm, and the project’s own benchmark notes acknowledge that alternate strategies (chunked-reverse, split-slice) can outperform the current implementation on some file-size profiles.