Skip to content

readline: improve performance#64585

Open
mcollina wants to merge 2 commits into
nodejs:mainfrom
mcollina:readline-createinterface-perf
Open

readline: improve performance#64585
mcollina wants to merge 2 commits into
nodejs:mainfrom
mcollina:readline-createinterface-perf

Conversation

@mcollina

@mcollina mcollina commented Jul 18, 2026

Copy link
Copy Markdown
Member

Speed up readline.createInterface(), which is on the hot path of every CLI tool that reads lines from a stream.

A CPU profile of interface construction showed three avoidable costs:

  1. setupHistoryManager() accounted for ~43% of construction self time: every instance ran four ObjectDefineProperty calls, allocating six fresh closures and four descriptor objects each time. The accessors (history, historyIndex, historySize, isFlushing) are now defined from a module-level shared descriptor map with a single ObjectDefineProperties call. They remain own, enumerable, configurable properties, so observable semantics are unchanged.
  2. The constructor assigned size, history and removeHistoryDuplicates onto the user-provided input stream to forward them to the history manager. It now passes a plain options object instead, so the user's stream is no longer observably mutated and doesn't go through extra hidden-class transitions. The legacy positional-arguments path is unchanged.
  3. The Interface wrapper unconditionally read process.env.TERM (which goes through the env interceptor) to bind the dumb-terminal _ttyWrite. The check is now gated on this.terminal, since _ttyWrite is never invoked when the interface is not in terminal mode.

Results with the new benchmark (30 runs, interleaved via benchmark/compare.js):

                                                         confidence improvement accuracy (*)   (**)  (***)
readline/readline-createInterface.js terminal=0 n=100000        ***     63.81 %       ±1.94% ±2.59% ±3.38%
readline/readline-createInterface.js terminal=1 n=100000        ***     38.05 %       ±1.90% ±2.53% ±3.31%

Update: a second commit speeds up line throughput as well. [kNormalWrite] ran a regular-expression exec loop that allocated a match object and two index entries per line, then sliced each line out in JS. It now does a single split pass, and uses a plain string split on "\n" when the chunk contains none of the rare line endings (\r, , ), which takes V8's fast string-split path.

Results for readline/readline-iterable.js (20 runs, interleaved):

                                                   confidence improvement accuracy (*)    (**)   (***)
readline/readline-iterable.js type='new' n=10             ***      5.18 %       ±1.91%  ±2.57%  ±3.38%
readline/readline-iterable.js type='new' n=100              *     13.16 %      ±12.52% ±16.89% ±22.48%
readline/readline-iterable.js type='new' n=1000           ***     32.94 %      ±11.25% ±15.31% ±20.66%
readline/readline-iterable.js type='new' n=10000          ***     47.44 %       ±4.83%  ±6.53%  ±8.70%
readline/readline-iterable.js type='new' n=100000         ***     70.71 %       ±2.18%  ±2.94%  ±3.91%
readline/readline-iterable.js type='new' n=1000000        ***     80.62 %       ±1.51%  ±2.05%  ±2.75%
readline/readline-iterable.js type='old' n=10             ***      4.92 %       ±1.50%  ±2.01%  ±2.66%
readline/readline-iterable.js type='old' n=100                    -5.79 %      ±10.40% ±13.97% ±18.46%
readline/readline-iterable.js type='old' n=1000                    1.39 %       ±6.82%  ±9.14% ±12.03%
readline/readline-iterable.js type='old' n=10000          ***     18.40 %       ±3.62%  ±4.85%  ±6.38%
readline/readline-iterable.js type='old' n=100000         ***     26.02 %       ±2.91%  ±3.93%  ±5.21%
readline/readline-iterable.js type='old' n=1000000        ***     31.42 %       ±2.00%  ±2.67%  ±3.52%

Event-based consumption (rl.on('line')) goes from 8.1M to 13.3M lines/s (+65%) in a microbenchmark of 32-byte lines.

Speed up Interface construction:

- Hoist the history accessor property descriptors to module scope and
  define them with a single ObjectDefineProperties call, instead of
  allocating six closures and four descriptor objects per instance.
- Stop assigning the history options onto the input stream. This avoids
  hidden class transitions on the user provided stream and no longer
  mutates it observably.
- Only check process.env.TERM for a dumb terminal when the interface is
  in terminal mode. Reading process.env goes through the environment
  interceptor and is comparatively expensive, and _ttyWrite is never
  called when terminal is false.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/performance

@nodejs-github-bot nodejs-github-bot added needs-ci PRs that need a full CI run. readline Issues and PRs related to the built-in readline module. labels Jul 18, 2026
Replace the per-line regular expression exec loop with a single split
pass, and use a plain string split on "\n" when the chunk contains none
of the rare line endings (\r, \u2028, \u2029). This avoids allocating a
match object and two index entries per line and lets the common case
use the fast string split path.

readline/readline-iterable.js type='new' improves by up to 80% and
the event based line consumption by up to 65%.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
@mcollina mcollina changed the title readline: reduce createInterface overhead readline: improve performance Jul 18, 2026
@codecov

codecov Bot commented Jul 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.59155% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 90.12%. Comparing base (4f844f4) to head (0fd92a9).

Files with missing lines Patch % Lines
lib/internal/readline/interface.js 98.52% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #64585      +/-   ##
==========================================
- Coverage   90.13%   90.12%   -0.02%     
==========================================
  Files         741      741              
  Lines      241976   241994      +18     
  Branches    45543    45551       +8     
==========================================
- Hits       218116   218101      -15     
- Misses      15379    15410      +31     
- Partials     8481     8483       +2     
Files with missing lines Coverage Δ
lib/readline.js 95.80% <100.00%> (+0.01%) ⬆️
lib/internal/readline/interface.js 93.21% <98.52%> (+<0.01%) ⬆️

... and 30 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mcollina
mcollina requested review from anonrig and jasnell July 19, 2026 10:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-ci PRs that need a full CI run. readline Issues and PRs related to the built-in readline module.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants