PR 2841: backpressure for CSV and NDJSON export

A review guide that explains the streaming logic first, then walks the diff with findings under each hunk

Verdict

Blocking1fix before merge (bad)
Nits3optional
Nice3worth keeping (good)

The PR touches 2 files, src/export/stream.js (+15 -4) and test/export/stream.test.js (+21), about 40 changed lines.

Backpressure in one minute

You asked for focus on the streaming logic, so here is the model before the diff.

A fast producer (the database cursor) can emit rows faster than a slow consumer (the HTTP response to a client on a poor connection) can send them. Node does not stop the producer on its own when you use on('data'). Unsent rows pile up in the response's memory buffer. For a 2 GB export that buffer grows until the process is killed.

The protocol has three parts.

  1. res.write(chunk) returns true while the buffer has room and false once it passes its high-water mark (16 KiB by default).
  2. On false, the producer must stop: call cursor.pause().
  3. When the buffer empties, res emits 'drain'. The producer resumes: cursor.resume().
The pause and resume loop
The pause and resume loop4 nodes. Cursor → streamExport (data row); streamExport → HTTP response (write(row)); HTTP response → Client (bytes); HTTP response → streamExport (false means full); streamExport → Cursor (pause()); HTTP response → streamExport (drain event); streamExport → Cursor (resume())db → app: data rowapp → res: write(row)res → client: bytesres → app: false means fullapp → db: pause()res → app: drain eventapp → db: resume()DBCursorproduces rowsFNstreamExportthe code in this PRSVCHTTP responsebuffer, 16 KiB markUSERClientslow linkDATA ROWWRITE(ROW)BYTESFALSE MEANS FULLPAUSE()DRAIN EVENTRESUME()
The pause and resume loop4 nodes. Cursor → streamExport (data row); streamExport → HTTP response (write(row)); HTTP response → Client (bytes); HTTP response → streamExport (false means full); streamExport → Cursor (pause()); HTTP response → streamExport (drain event); streamExport → Cursor (resume())db → app: data rowapp → res: write(row)res → client: bytesres → app: false means fullapp → db: pause()res → app: drain eventapp → db: resume()DBCursorproducesrowsFNstreamExportthe code inthis PRSVCHTTPresponsebuffer, 16KiB markUSERClientslow linkDATA ROWWRITE(ROW)BYTESFALSEMEANSFULLPAUSE()DRAINEVENTRESUME()
DBFNSVCUSER

The same loop as a timeline of one saturation cycle.

One pause and resume cycle
One pause and resume cycleSequence between Cursor, streamExport, HTTP response. 1. Cursor to streamExport: data row; 2. streamExport to HTTP response: write(row); 3. HTTP response to streamExport: true (reply); 4. Cursor to streamExport: data row; 5. streamExport to HTTP response: write(row); 6. HTTP response to streamExport: false (reply); 7. streamExport to Cursor: pause(); 8. HTTP response to streamExport: drain (reply); 9. streamExport to Cursor: resume()DBCursorFNstreamExportSVCHTTP responseDATA ROW1WRITE(ROW)2TRUE3client is slow, buffer fillsDATA ROW4WRITE(ROW)5FALSE6PAUSE()7buffer drains to the clientDRAIN8RESUME()9
One pause and resume cycleSequence between Cursor, streamExport, HTTP response. 1. Cursor to streamExport: data row; 2. streamExport to HTTP response: write(row); 3. HTTP response to streamExport: true (reply); 4. Cursor to streamExport: data row; 5. streamExport to HTTP response: write(row); 6. HTTP response to streamExport: false (reply); 7. streamExport to Cursor: pause(); 8. HTTP response to streamExport: drain (reply); 9. streamExport to Cursor: resume()DBCursorFNstreamExportSVCHTTPresponseDATA ROW1WRITE(ROW)2TRUE3client is slow, buffer fillsDATA ROW4WRITE(ROW)5FALSE6PAUSE()7buffer drains to the clientDRAIN8RESUME()9
termmeaning
write() returns falsethe buffer passed the high-water mark; stop sending
highWaterMarkthe buffer size that triggers false; 16 KiB for a response by default
'drain'the buffer is empty again; safe to send
pause() and resume()stop and restart 'data' events on the cursor

Why the PR was needed

The benchmark in the PR description exports a 2 GB table to a client throttled to 5 Mbps. Memory before the fix grows without limit; after it stays flat.

Process memory during a 2 GB export
Process memory during a 2 GB exportLine chart, 9 values of minute. before goes from 120 MB (m0) to 1,130 MB (m8); highest 1,130 MB at m8, lowest 120 MB at m0. after goes from 118 MB (m0) to 134 MB (m8); highest 134 MB at m8, lowest 118 MB at m0.0 MB250 MB500 MB750 MB1,000 MB1,250 MBm0 · before: 120 MBm1 · before: 210 MBm2 · before: 340 MBm3 · before: 480 MBm4 · before: 610 MBm5 · before: 760 MBm6 · before: 890 MBm7 · before: 1,010 MBm8 · before: 1,130 MBm0 · after: 118 MBm1 · after: 126 MBm2 · after: 131 MBm3 · after: 129 MBm4 · after: 133 MBm5 · after: 130 MBm6 · after: 132 MBm7 · after: 131 MBm8 · after: 134 MBbeforeafterpod limitm0m1m2m3m4m5m6m7m8
Process memory during a 2 GB exportLine chart, 9 values of minute. before goes from 120 MB (m0) to 1,130 MB (m8); highest 1,130 MB at m8, lowest 120 MB at m0. after goes from 118 MB (m0) to 134 MB (m8); highest 134 MB at m8, lowest 118 MB at m0.0 MB500 MB1,000 MB1,500 MBm0 · before: 120 MBm1 · before: 210 MBm2 · before: 340 MBm3 · before: 480 MBm4 · before: 610 MBm5 · before: 760 MBm6 · before: 890 MBm7 · before: 1,010 MBm8 · before: 1,130 MBm0 · after: 118 MBm1 · after: 126 MBm2 · after: 131 MBm3 · after: 129 MBm4 · after: 133 MBm5 · after: 130 MBm6 · after: 132 MBm7 · after: 131 MBm8 · after: 134 MBpod limitm0m2m3m4m5m6m8
Data
minutebeforeafter
m0120 MB118 MB
m1210 MB126 MB
m2340 MB131 MB
m3480 MB129 MB
m4610 MB133 MB
m5760 MB130 MB
m6890 MB132 MB
m71,010 MB131 MB
m81,130 MB134 MB

The diff, annotated

Each hunk is followed by the findings that apply to it. Severity is marked by tag and callout level: BLOCKING is a red alert, NIT is a warning, NICE is a note.

src/export/stream.js

Before, every row was written and the return value ignored. After, the function pauses on false and resumes on 'drain'. Read the three hunks in order.

Hunk 1: the signature and the flag

@@ -1,10 +1,21 @@
 'use strict';

-function streamExport(cursor, res) {
-  cursor.on('data', (row) => {
-    res.write(JSON.stringify(row) + '\n');
-  });
+function streamExport(cursor, res, { highWaterMark = 16 * 1024 } = {}) {
+  let paused = false;
+

Hunk 2: the pause and resume

@@ -5,3 +6,10 @@ function streamExport(cursor, res, { highWaterMark = 16 * 1024 } = {}) {
+  cursor.on('data', (row) => {
+    const ok = res.write(JSON.stringify(row) + '\n');
+    if (ok || paused) return;
+    paused = true;
+    cursor.pause();
+    res.once('drain', () => {
+      paused = false;
+      cursor.resume();
+    });
+  });

Hunk 3: end and error

@@ -7,2 +17,4 @@ function streamExport(cursor, res, { highWaterMark = 16 * 1024 } = {}) {
   cursor.on('end', () => res.end());
-}
+  cursor.on('error', (err) => res.destroy(err));
+}
cursor.on('end', () => res.end());
cursor.on('error', (err) => res.destroy(err));
res.once('close', () => cursor.destroy()); // client left: release the connection

test/export/stream.test.js

Hunk 4: the slow-consumer test

@@ -0,0 +1,21 @@
+const test = require('node:test');
+const assert = require('node:assert');
+const { once } = require('node:events');
+const { Readable, Writable } = require('node:stream');
+const { streamExport } = require('../../src/export/stream');
+
+test('keeps the response buffer bounded for a slow consumer', async () => {
+  const cursor = Readable.from(Array.from({ length: 1000 }, (_, i) => ({ i })));
+  let peak = 0;
+  const res = new Writable({
+    highWaterMark: 256,
+    write(_chunk, _enc, cb) { setTimeout(cb, 1); },
+  });
+  const timer = setInterval(() => {
+    peak = Math.max(peak, res.writableLength);
+  }, 5);
+  streamExport(cursor, res);
+  await once(res, 'finish');
+  clearInterval(timer);
+  assert.ok(peak < 1024, `peak buffer ${peak} B`);
+});
write(chunk, _enc, cb) {
  peak = Math.max(peak, this.writableLength);
  setTimeout(cb, 1);
},

All findings

idseveritywheresummaryaction
B1blockingstream.js 17 to 18cursor never released when client disconnects while pausedadd res.once('close', ...) and a test
N1nitstream.js 3highWaterMark option is unusedremove it
N2nitstream.js 4paused name hides its meaningrename to waitingForDrain
N3nittest 13 to 15interval sampling can miss the peakrecord inside write()
Y1nicestream.js 7uses the write() return valuekeep
Y2nicestream.js 11 to 14once('drain') self-cleanskeep
Y3nicetest 9 to 12slow Writable exercises drainkeep

Before you approve

  1. 1
    Reproduce B1 with curl --limit-rate 1k and Ctrl-Cnow
    Reviewer
  2. 2
    Author applies the close handler and test2026-10-20
    PR author
  3. 3
    Re-review the delta only2026-10-21
    Reviewer

Back to the examples