DEV Community

Ishaan Pandey
Ishaan Pandey

Posted on • Originally published at ishaaan.hashnode.dev

Sending a Million Rows from the Backend: Streaming, Batching, Compression & Protocol Buffers

Sending a Million Rows from the Backend: Streaming, Batching, Compression & Protocol Buffers

Your database has a million rows. Your client needs them. What could go wrong?

Everything, actually. If you try to serialize a million rows into a single JSON response, you'll blow up your server's memory, timeout the request, make the client wait forever, and then crash the browser trying to parse a 500 MB JSON blob.

Sending large datasets is a problem that touches every layer: the database, the serialization format, the transport protocol, compression, and how you structure the data flow. Get any of these wrong and your system falls over.

This guide covers every major approach — from pagination basics to gRPC streaming, from backpressure management to Parquet exports. With real code, real benchmarks, and real-world examples.

Let's get into it.


The Problem: Why One Giant JSON Response Is a Terrible Idea

Here's the naive approach:

app.get('/api/users', async (req, res) => {
  const users = await db.query('SELECT * FROM users'); // 1M rows
  res.json(users); // 💀
});
Enter fullscreen mode Exit fullscreen mode

Let's trace what happens:

┌──────────────────────────────────────────────────────┐
│              The Disaster Timeline                     │
├──────────────────────────────────────────────────────┤
│                                                       │
│  1. Database query executes                           │
│     └─ All 1M rows loaded into server memory          │
│     └─ ~500 MB - 2 GB of RAM consumed                 │
│                                                       │
│  2. JSON.stringify() runs                             │
│     └─ Blocks the event loop for 5-30 seconds         │
│     └─ Allocates another ~500 MB for the string       │
│                                                       │
│  3. Response sent over the network                    │
│     └─ Client waits for entire payload                │
│     └─ 500 MB over the wire (even compressed: ~50 MB) │
│     └─ Timeout risk: most proxies cut at 30-60s       │
│                                                       │
│  4. Client receives and parses                        │
│     └─ JSON.parse() on 500 MB string                  │
│     └─ Browser allocates ~1-2 GB for parsed objects   │
│     └─ Tab crashes or becomes unresponsive            │
│                                                       │
│  Peak server memory: ~1-2 GB PER REQUEST              │
│  10 concurrent requests = 10-20 GB = server OOM       │
└──────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The Numbers

Metric 1K Rows 100K Rows 1M Rows
JSON size (uncompressed) ~500 KB ~50 MB ~500 MB
JSON size (gzipped) ~50 KB ~5 MB ~50 MB
Serialization time ~5 ms ~500 ms ~5,000 ms
Parse time (client) ~10 ms ~1,000 ms ~10,000 ms
Server memory ~2 MB ~200 MB ~2 GB
Network time (100 Mbps) ~4 ms ~400 ms ~4,000 ms

At 1M rows, every step is a bottleneck. You need a different strategy.


Technique 1: Pagination

The simplest solution: don't send everything at once.

Offset Pagination

-- Page 1
SELECT * FROM users ORDER BY id LIMIT 50 OFFSET 0;

-- Page 2
SELECT * FROM users ORDER BY id LIMIT 50 OFFSET 50;

-- Page 200
SELECT * FROM users ORDER BY id LIMIT 50 OFFSET 9950;
Enter fullscreen mode Exit fullscreen mode
// Express endpoint
app.get('/api/users', async (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const limit = Math.min(parseInt(req.query.limit) || 50, 200);
  const offset = (page - 1) * limit;

  const [rows] = await db.query(
    'SELECT * FROM users ORDER BY id LIMIT ? OFFSET ?',
    [limit, offset]
  );

  const [countResult] = await db.query(
    'SELECT COUNT(*) as total FROM users'
  );

  res.json({
    data: rows,
    pagination: {
      page,
      limit,
      total: countResult[0].total,
      totalPages: Math.ceil(countResult[0].total / limit),
    },
  });
});
Enter fullscreen mode Exit fullscreen mode

The problem with offset pagination: the database still scans and discards rows. OFFSET 999950 means the DB reads 999,950 rows and throws them away. At scale, this gets slow.

Cursor-Based (Keyset) Pagination

Far more efficient — uses an indexed column as a cursor:

-- First page
SELECT * FROM users ORDER BY id LIMIT 50;

-- Next page (cursor = last row's id from previous page)
SELECT * FROM users WHERE id > 12345 ORDER BY id LIMIT 50;
Enter fullscreen mode Exit fullscreen mode
app.get('/api/users', async (req, res) => {
  const limit = Math.min(parseInt(req.query.limit) || 50, 200);
  const cursor = req.query.cursor; // last seen ID

  let query, params;
  if (cursor) {
    query = 'SELECT * FROM users WHERE id > ? ORDER BY id LIMIT ?';
    params = [cursor, limit + 1]; // fetch one extra to check hasMore
  } else {
    query = 'SELECT * FROM users ORDER BY id LIMIT ?';
    params = [limit + 1];
  }

  const [rows] = await db.query(query, params);
  const hasMore = rows.length > limit;
  const data = hasMore ? rows.slice(0, -1) : rows;

  res.json({
    data,
    pagination: {
      nextCursor: hasMore ? data[data.length - 1].id : null,
      hasMore,
    },
  });
});
Enter fullscreen mode Exit fullscreen mode

Pagination Comparison

Aspect Offset Cursor Keyset
Performance at depth O(offset + limit) O(limit) O(limit)
Jump to page N Yes No No
Consistent with inserts/deletes No (rows shift) Yes Yes
Requires unique sort column No Yes Yes
Implementation complexity Simple Moderate Moderate
Works with complex sorts Yes Tricky Tricky

Pagination is the right answer for most use cases. But when the client genuinely needs all the data (exports, analytics, bulk operations), you need streaming.


Technique 2: Streaming Responses

Instead of building the entire response in memory, stream rows as they become available.

NDJSON (Newline-Delimited JSON)

Each line is a valid JSON object. The client processes rows incrementally:

{"id": 1, "name": "Alice", "email": "alice@example.com"}
{"id": 2, "name": "Bob", "email": "bob@example.com"}
{"id": 3, "name": "Charlie", "email": "charlie@example.com"}
Enter fullscreen mode Exit fullscreen mode
// Express + streaming from database
app.get('/api/users/stream', async (req, res) => {
  res.setHeader('Content-Type', 'application/x-ndjson');
  res.setHeader('Transfer-Encoding', 'chunked');

  const stream = db.query('SELECT * FROM users').stream();

  stream.on('data', (row) => {
    res.write(JSON.stringify(row) + '\n');
  });

  stream.on('end', () => {
    res.end();
  });

  stream.on('error', (err) => {
    console.error('Stream error:', err);
    if (!res.headersSent) {
      res.status(500).json({ error: 'Stream failed' });
    } else {
      res.end();
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

Client-side consumption:

async function consumeNDJSON(url, onRow) {
  const response = await fetch(url);
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('\n');
    buffer = lines.pop(); // keep incomplete line in buffer

    for (const line of lines) {
      if (line.trim()) {
        onRow(JSON.parse(line));
      }
    }
  }

  // Process any remaining data
  if (buffer.trim()) {
    onRow(JSON.parse(buffer));
  }
}

// Usage
const rows = [];
await consumeNDJSON('/api/users/stream', (row) => {
  rows.push(row);
  if (rows.length % 1000 === 0) {
    updateProgress(rows.length);
  }
});
Enter fullscreen mode Exit fullscreen mode

Node.js Streams with Transform

A more idiomatic Node.js approach using stream pipelines:

import { Transform, pipeline } from 'node:stream';
import { promisify } from 'node:util';

const pipelineAsync = promisify(pipeline);

app.get('/api/users/stream', async (req, res) => {
  res.setHeader('Content-Type', 'application/x-ndjson');

  const dbStream = db.query('SELECT * FROM users').stream();

  const toNDJSON = new Transform({
    objectMode: true,
    transform(row, encoding, callback) {
      callback(null, JSON.stringify(row) + '\n');
    },
  });

  try {
    await pipelineAsync(dbStream, toNDJSON, res);
  } catch (err) {
    console.error('Pipeline error:', err);
  }
});
Enter fullscreen mode Exit fullscreen mode

Server-Sent Events (SSE)

Good for pushing data to the client with automatic reconnection:

app.get('/api/users/sse', async (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');

  const stream = db.query('SELECT * FROM users').stream();
  let count = 0;

  stream.on('data', (row) => {
    count++;
    res.write(`data: ${JSON.stringify(row)}\n\n`);

    // Send progress events every 1000 rows
    if (count % 1000 === 0) {
      res.write(`event: progress\ndata: ${JSON.stringify({ count })}\n\n`);
    }
  });

  stream.on('end', () => {
    res.write(`event: complete\ndata: ${JSON.stringify({ total: count })}\n\n`);
    res.end();
  });

  // Handle client disconnect
  req.on('close', () => {
    stream.destroy();
  });
});
Enter fullscreen mode Exit fullscreen mode
// Client
const source = new EventSource('/api/users/sse');
const rows = [];

source.onmessage = (e) => {
  rows.push(JSON.parse(e.data));
};

source.addEventListener('progress', (e) => {
  console.log(`Received ${JSON.parse(e.data).count} rows`);
});

source.addEventListener('complete', (e) => {
  console.log(`Done! Total: ${JSON.parse(e.data).total}`);
  source.close();
});
Enter fullscreen mode Exit fullscreen mode

Fastify Streaming

Fastify has great streaming support out of the box:

import Fastify from 'fastify';
import { Readable } from 'node:stream';

const app = Fastify();

app.get('/api/users/stream', async (request, reply) => {
  const dbStream = db.query('SELECT * FROM users').stream();

  const ndjsonStream = new Readable({
    read() {},
  });

  dbStream.on('data', (row) => {
    ndjsonStream.push(JSON.stringify(row) + '\n');
  });

  dbStream.on('end', () => {
    ndjsonStream.push(null);
  });

  reply.type('application/x-ndjson');
  return reply.send(ndjsonStream);
});
Enter fullscreen mode Exit fullscreen mode

Streaming Memory Comparison

Traditional (buffer everything):

  Memory ─┐
     2 GB │          ┌──────────┐
     1 GB │     ┌────┤  Stringify│──┐
  500 MB  │ ┌───┤ DB  │          │  ├──── Send
          │ │   │Query│          │  │
     0    └─┴───┴─────┴──────────┴──┴──→ Time

Streaming:

  Memory ─┐
    20 MB │  ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐
    10 MB │──┤ ├─┤ ├─┤ ├─┤ ├─┤ ├─┤ ├──
          │  └─┘ └─┘ └─┘ └─┘ └─┘ └─┘
     0    └────────────────────────────→ Time
          Each chunk: ~few KB
          Total memory: ~10-20 MB constant
Enter fullscreen mode Exit fullscreen mode

The key insight: with streaming, memory usage is constant regardless of dataset size. You process rows one (or a few) at a time.


Technique 3: HTTP Streaming with Transfer-Encoding: chunked

Under the hood, HTTP streaming uses chunked transfer encoding. This is what makes NDJSON and SSE work.

Normal HTTP Response:
  Content-Length: 524288000  ← Server must know total size upfront
  [... 500 MB of data ...]

Chunked Transfer:
  Transfer-Encoding: chunked

  1a4\r\n                    ← Chunk size in hex (420 bytes)
  {"id":1,"name":"Alice"...}\n
  {"id":2,"name":"Bob"...}\n
  \r\n
  1b2\r\n                    ← Next chunk
  {"id":3,"name":"Charlie"...}\n
  ...
  \r\n
  0\r\n                      ← Zero-length chunk = end
  \r\n
Enter fullscreen mode Exit fullscreen mode

In Express, this happens automatically when you use res.write() instead of res.json():

app.get('/api/data', (req, res) => {
  // res.json(bigData)   — buffers everything, sends Content-Length
  // res.write(chunk)     — streams, uses Transfer-Encoding: chunked

  res.setHeader('Content-Type', 'application/json');
  res.write('[');  // Start JSON array

  let first = true;
  const stream = db.query('SELECT * FROM items').stream();

  stream.on('data', (row) => {
    if (!first) res.write(',');
    first = false;
    res.write(JSON.stringify(row));
  });

  stream.on('end', () => {
    res.write(']');
    res.end();
  });
});
Enter fullscreen mode Exit fullscreen mode

Note: streaming a JSON array like this means the client can't parse until the entire array is received (no incremental JSON.parse). That's why NDJSON is usually better for streaming.


Technique 4: gRPC Server Streaming

gRPC with Protocol Buffers is purpose-built for streaming large datasets efficiently.

Define the Protocol

// users.proto
syntax = "proto3";

package users;

service UserService {
  // Unary — traditional request/response
  rpc GetUser (GetUserRequest) returns (User);

  // Server streaming — server sends many messages
  rpc ListUsers (ListUsersRequest) returns (stream User);

  // Bidirectional streaming
  rpc SyncUsers (stream SyncRequest) returns (stream SyncResponse);
}

message ListUsersRequest {
  int32 batch_size = 1;
  string filter = 2;
}

message User {
  int64 id = 1;
  string name = 2;
  string email = 3;
  double balance = 4;
  int64 created_at = 5;
}
Enter fullscreen mode Exit fullscreen mode

Server Implementation (Node.js)

import grpc from '@grpc/grpc-js';
import protoLoader from '@grpc/proto-loader';

const packageDef = protoLoader.loadSync('users.proto');
const proto = grpc.loadPackageDefinition(packageDef);

const server = new grpc.Server();

server.addService(proto.users.UserService.service, {
  listUsers: async (call) => {
    const batchSize = call.request.batch_size || 1000;
    const dbStream = db.query('SELECT * FROM users').stream();
    let batch = [];

    dbStream.on('data', (row) => {
      call.write({
        id: row.id,
        name: row.name,
        email: row.email,
        balance: row.balance,
        created_at: row.created_at.getTime(),
      });
    });

    dbStream.on('end', () => {
      call.end();
    });

    dbStream.on('error', (err) => {
      call.destroy(err);
    });

    // Handle client cancellation
    call.on('cancelled', () => {
      dbStream.destroy();
    });
  },
});

server.bindAsync(
  '0.0.0.0:50051',
  grpc.ServerCredentials.createInsecure(),
  () => server.start()
);
Enter fullscreen mode Exit fullscreen mode

Client Consumption

const client = new proto.users.UserService(
  'localhost:50051',
  grpc.credentials.createInsecure()
);

const stream = client.listUsers({ batch_size: 1000 });
const rows = [];

stream.on('data', (user) => {
  rows.push(user);
});

stream.on('end', () => {
  console.log(`Received ${rows.length} users`);
});

stream.on('error', (err) => {
  console.error('Stream error:', err);
});
Enter fullscreen mode Exit fullscreen mode

Why gRPC Is Faster

JSON over HTTP:
  {"id":12345,"name":"Alice Johnson","email":"alice@example.com","balance":1234.56}
  = 82 bytes (human-readable, lots of repeated keys)

Protocol Buffers:
  08 B9 60 12 0D 41 6C 69 63 65 20 4A 6F 68 6E 73 6F 6E ...
  = ~45 bytes (binary, no keys, varint encoding)

Savings: ~45% smaller per row
At 1M rows: ~40 MB saved on the wire
Enter fullscreen mode Exit fullscreen mode

Technique 5: WebSockets for Real-Time Large Data

When you need bidirectional communication or real-time updates on large datasets:

import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 8080 });

wss.on('connection', (ws) => {
  ws.on('message', async (message) => {
    const request = JSON.parse(message);

    if (request.type === 'fetch-all') {
      const stream = db.query('SELECT * FROM transactions').stream();
      let batch = [];
      let batchCount = 0;

      stream.on('data', (row) => {
        batch.push(row);

        // Send in batches of 500 to reduce message overhead
        if (batch.length >= 500) {
          ws.send(JSON.stringify({
            type: 'batch',
            data: batch,
            batchNumber: ++batchCount,
          }));
          batch = [];
        }
      });

      stream.on('end', () => {
        // Send remaining rows
        if (batch.length > 0) {
          ws.send(JSON.stringify({
            type: 'batch',
            data: batch,
            batchNumber: ++batchCount,
          }));
        }
        ws.send(JSON.stringify({
          type: 'complete',
          totalBatches: batchCount,
        }));
      });
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

When to Use WebSockets vs HTTP Streaming

Aspect HTTP Streaming WebSocket
Direction Server → Client Bidirectional
Connection overhead New connection per request Persistent connection
Resumability Must restart Can implement resume
Proxy compatibility Excellent Sometimes problematic
Browser support Universal Universal
Backpressure HTTP flow control Manual implementation
Best for One-time data exports Real-time dashboards, live feeds

Technique 6: Batching Strategies

Optimal Chunk Size

Too small = overhead per batch dominates. Too large = high memory usage. The sweet spot depends on row size:

┌────────────────────────────────────────────────────┐
│          Throughput vs Batch Size                    │
│                                                     │
│  Throughput                                         │
│  (rows/s)                                           │
│     │         ┌──────────────┐                      │
│  80K│         │              │                      │
│     │      ┌──┘              └──────────┐           │
│  60K│   ┌──┘                            └──┐        │
│     │┌──┘                                  └──┐     │
│  40K││                                        └──   │
│     ││                                              │
│  20K││                                              │
│     │└─────┬──────┬─────┬──────┬──────┬─────┬───    │
│     0     100    500   1000   5000  10000  50000    │
│                  Batch size (rows)                   │
│                                                     │
│  Sweet spot: 500–5,000 rows per batch               │
│  (depends on row size and network conditions)       │
└────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode
// Adaptive batching based on row size
function calculateBatchSize(avgRowSizeBytes) {
  const TARGET_CHUNK_KB = 256; // target ~256 KB per batch
  const targetBytes = TARGET_CHUNK_KB * 1024;
  const batchSize = Math.floor(targetBytes / avgRowSizeBytes);
  return Math.max(100, Math.min(batchSize, 10000)); // clamp 100–10,000
}
Enter fullscreen mode Exit fullscreen mode

Backpressure-Aware Batching

If you send faster than the client can consume, you'll buffer everything in memory anyway. This is backpressure — and you need to handle it:

app.get('/api/export', async (req, res) => {
  const dbStream = db.query('SELECT * FROM orders').stream();

  dbStream.on('data', (row) => {
    const canContinue = res.write(JSON.stringify(row) + '\n');

    if (!canContinue) {
      // The write buffer is full — pause the database stream
      dbStream.pause();

      // Resume when the buffer drains
      res.once('drain', () => {
        dbStream.resume();
      });
    }
  });

  dbStream.on('end', () => res.end());
});
Enter fullscreen mode Exit fullscreen mode

This is critical. Without backpressure handling, a slow client causes the server to buffer everything in memory — defeating the purpose of streaming.


Technique 7: Compression

Compressing the response can reduce bandwidth by 70-95% for JSON data.

Compression Algorithms Compared

Algorithm Compression Ratio Compress Speed Decompress Speed Best For
gzip Good (70-80%) Moderate Fast General purpose, widest support
Brotli Better (75-85%) Slow Fast Static assets, pre-compressed
zstd Better (75-85%) Fast Very Fast Real-time compression, logs
lz4 Moderate (60-70%) Very Fast Very Fast Speed-critical, large volumes

Benchmarks with 1M Rows of User Data

Original JSON:      487 MB (100%)
┌───────────────────────────────────────────────────┐
│ gzip -6:           52 MB (10.7%)  compress: 4.2s  │
│ gzip -9:           48 MB ( 9.9%)  compress: 8.1s  │
│ brotli -6:         43 MB ( 8.8%)  compress: 6.5s  │
│ brotli -11:        38 MB ( 7.8%)  compress: 42s   │
│ zstd -3:           49 MB (10.1%)  compress: 1.8s  │
│ zstd -9:           42 MB ( 8.6%)  compress: 5.2s  │
│ NDJSON + gzip -6:  54 MB (11.1%)  compress: 4.0s  │
│ Protobuf (raw):   265 MB (54.4%)  serialize: 1.2s │
│ Protobuf + gzip:   31 MB ( 6.4%)  total: 3.1s     │
└───────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Protobuf + gzip is the most compact option. Plain gzip gives the best effort-to-reward ratio.

Enabling Compression in Express

import compression from 'compression';

// Global compression middleware
app.use(compression({
  level: 6,                    // gzip level (1-9)
  threshold: 1024,             // only compress responses > 1 KB
  filter: (req, res) => {
    if (req.headers['x-no-compression']) return false;
    return compression.filter(req, res);
  },
}));

// Or per-route with streaming
import { createGzip } from 'node:zlib';

app.get('/api/export', async (req, res) => {
  res.setHeader('Content-Type', 'application/x-ndjson');
  res.setHeader('Content-Encoding', 'gzip');

  const gzip = createGzip({ level: 6 });
  const dbStream = db.query('SELECT * FROM users').stream();

  const transform = new Transform({
    objectMode: true,
    transform(row, enc, cb) {
      cb(null, JSON.stringify(row) + '\n');
    },
  });

  pipeline(dbStream, transform, gzip, res, (err) => {
    if (err) console.error('Pipeline failed:', err);
  });
});
Enter fullscreen mode Exit fullscreen mode

Streaming Compression Considerations

When compressing a stream, the compressor needs enough data to build its dictionary. With very small chunks, compression ratio suffers:

// Bad: compress each row individually (poor ratio)
stream.on('data', (row) => {
  const compressed = gzipSync(JSON.stringify(row));
  res.write(compressed);
});

// Good: let the gzip stream accumulate data internally
const gzip = createGzip({ level: 6, flush: constants.Z_SYNC_FLUSH });
pipeline(dataStream, gzip, res, () => {});
// Z_SYNC_FLUSH ensures data is flushed periodically so the client
// can start processing without waiting for the entire stream
Enter fullscreen mode Exit fullscreen mode

Technique 8: Serialization Format Comparison

JSON isn't the only option. Here's how formats compare:

Size Comparison (1M Rows)

Format Size Parse Time (client) Schema Required
JSON 487 MB ~10s No
NDJSON 487 MB ~10s (streamable) No
Protocol Buffers 265 MB ~2s Yes (.proto)
MessagePack 310 MB ~4s No
CBOR 320 MB ~4s No
Avro 250 MB ~2s Yes
FlatBuffers 280 MB ~0s (zero-copy) Yes

MessagePack Example

Binary JSON — smaller, faster, drop-in replacement:

import msgpack from '@msgpack/msgpack';

// Server
app.get('/api/users/msgpack', async (req, res) => {
  const users = await db.query('SELECT * FROM users LIMIT 10000');
  const encoded = msgpack.encode(users);

  res.setHeader('Content-Type', 'application/x-msgpack');
  res.send(Buffer.from(encoded));
});

// Client
const response = await fetch('/api/users/msgpack');
const buffer = await response.arrayBuffer();
const users = msgpack.decode(new Uint8Array(buffer));
Enter fullscreen mode Exit fullscreen mode

When to Use What

┌─────────────────────────────────────────────────────┐
│                Format Decision Tree                  │
│                                                      │
│  Do you need browser-native parsing?                 │
│  ├─ Yes → JSON or NDJSON                             │
│  └─ No                                               │
│     │                                                │
│     ├─ Do you control both client and server?         │
│     │  ├─ Yes → Protocol Buffers (best size + speed)  │
│     │  └─ No → MessagePack (schemaless, still small)  │
│     │                                                │
│     └─ Do you need zero-copy access?                  │
│        ├─ Yes → FlatBuffers                           │
│        └─ No → Protocol Buffers                       │
└─────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Technique 9: Database-Level Optimization

The fastest byte is the one you never send. Optimize at the database level first.

Streaming from PostgreSQL

import pg from 'pg';
import QueryStream from 'pg-query-stream';

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

app.get('/api/export', async (req, res) => {
  const client = await pool.connect();

  try {
    const query = new QueryStream(
      'SELECT id, name, email FROM users ORDER BY id',
      [],
      { batchSize: 1000 } // fetch 1000 rows at a time from PG
    );

    const stream = client.query(query);

    res.setHeader('Content-Type', 'application/x-ndjson');

    stream.on('data', (row) => {
      res.write(JSON.stringify(row) + '\n');
    });

    stream.on('end', () => {
      client.release();
      res.end();
    });

    stream.on('error', (err) => {
      client.release();
      res.status(500).end();
    });
  } catch (err) {
    client.release();
    throw err;
  }
});
Enter fullscreen mode Exit fullscreen mode

PostgreSQL COPY Command

For bulk exports, COPY is orders of magnitude faster than SELECT:

import { pipeline } from 'node:stream/promises';
import copyFrom from 'pg-copy-streams';

app.get('/api/export/csv', async (req, res) => {
  const client = await pool.connect();

  try {
    res.setHeader('Content-Type', 'text/csv');
    res.setHeader('Content-Disposition', 'attachment; filename="export.csv"');

    const copyStream = client.query(
      copyFrom.to("COPY users TO STDOUT WITH (FORMAT csv, HEADER true)")
    );

    await pipeline(copyStream, res);
  } finally {
    client.release();
  }
});
Enter fullscreen mode Exit fullscreen mode

COPY bypasses the query executor and reads directly from the storage layer. Benchmarks:

1M rows export:
  SELECT + JSON serialize:  ~12 seconds
  SELECT + stream:          ~8 seconds
  COPY to CSV:              ~2 seconds
  COPY to binary:           ~1.2 seconds
Enter fullscreen mode Exit fullscreen mode

MySQL Streaming

import mysql from 'mysql2';

const pool = mysql.createPool({
  host: 'localhost',
  user: 'root',
  database: 'mydb',
});

app.get('/api/export', (req, res) => {
  res.setHeader('Content-Type', 'application/x-ndjson');

  const stream = pool.query('SELECT * FROM users')
    .stream({ highWaterMark: 1000 });

  stream.on('data', (row) => {
    const ok = res.write(JSON.stringify(row) + '\n');
    if (!ok) {
      stream.pause();
      res.once('drain', () => stream.resume());
    }
  });

  stream.on('end', () => res.end());
  stream.on('error', (err) => {
    console.error(err);
    res.end();
  });
});
Enter fullscreen mode Exit fullscreen mode

Connection Pool Sizing

Long-running streaming queries tie up connections. Size your pool carefully:

const pool = new pg.Pool({
  max: 20,                    // max connections
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 5000,
});

// For export endpoints, consider a separate pool with fewer connections
const exportPool = new pg.Pool({
  max: 5,  // limit concurrent exports
  idleTimeoutMillis: 60000,
  statement_timeout: 300000,  // 5 min timeout for long queries
});
Enter fullscreen mode Exit fullscreen mode

Technique 10: Caching Large Responses

CDN Caching

For data that doesn't change frequently, cache at the edge:

app.get('/api/reports/daily', async (req, res) => {
  res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=86400');
  res.setHeader('CDN-Cache-Control', 'max-age=86400'); // Cloudflare/Vercel
  res.setHeader('Vary', 'Accept-Encoding');

  const data = await generateDailyReport();
  res.json(data);
});
Enter fullscreen mode Exit fullscreen mode

Redis Caching for Computed Results

import Redis from 'ioredis';
const redis = new Redis();

app.get('/api/analytics/summary', async (req, res) => {
  const cacheKey = `analytics:summary:${req.query.date}`;

  // Check cache first
  const cached = await redis.get(cacheKey);
  if (cached) {
    res.setHeader('X-Cache', 'HIT');
    res.json(JSON.parse(cached));
    return;
  }

  // Compute the expensive result
  const result = await computeAnalytics(req.query.date);

  // Cache for 1 hour
  await redis.set(cacheKey, JSON.stringify(result), 'EX', 3600);

  res.setHeader('X-Cache', 'MISS');
  res.json(result);
});
Enter fullscreen mode Exit fullscreen mode

ETags for Conditional Requests

When the client already has data and just needs to check for updates:

import crypto from 'node:crypto';

app.get('/api/dataset/:id', async (req, res) => {
  const data = await getDataset(req.params.id);
  const etag = crypto
    .createHash('md5')
    .update(JSON.stringify(data.lastModified))
    .digest('hex');

  res.setHeader('ETag', `"${etag}"`);

  // If client has the same version, send 304
  if (req.headers['if-none-match'] === `"${etag}"`) {
    res.status(304).end();
    return;
  }

  res.json(data);
});
Enter fullscreen mode Exit fullscreen mode

Technique 11: File-Based Alternatives

Sometimes the best API response is a URL to a file.

Pre-Generated CSV/Parquet Export

import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const s3 = new S3Client({ region: 'us-east-1' });

app.post('/api/export/request', async (req, res) => {
  const exportId = crypto.randomUUID();

  // Queue the export job (could use Bull, SQS, etc.)
  await exportQueue.add('generate-export', {
    exportId,
    query: req.body.query,
    format: req.body.format || 'csv',
  });

  res.json({
    exportId,
    status: 'processing',
    statusUrl: `/api/export/${exportId}/status`,
  });
});

// Background job handler
async function handleExport(job) {
  const { exportId, query, format } = job.data;

  // Stream from DB to S3
  const dbStream = db.query(query).stream();
  const chunks = [];

  for await (const row of dbStream) {
    chunks.push(formatRow(row, format)); // CSV line, etc.
  }

  await s3.send(new PutObjectCommand({
    Bucket: 'my-exports',
    Key: `exports/${exportId}.${format}`,
    Body: chunks.join(''),
    ContentType: format === 'csv' ? 'text/csv' : 'application/octet-stream',
  }));

  await db.query(
    'UPDATE exports SET status = ?, completed_at = NOW() WHERE id = ?',
    ['completed', exportId]
  );
}

// Status + download endpoint
app.get('/api/export/:id/status', async (req, res) => {
  const [export_] = await db.query(
    'SELECT * FROM exports WHERE id = ?',
    [req.params.id]
  );

  if (export_.status === 'completed') {
    const url = await getSignedUrl(s3, new GetObjectCommand({
      Bucket: 'my-exports',
      Key: `exports/${export_.id}.csv`,
    }), { expiresIn: 3600 });

    res.json({
      status: 'completed',
      downloadUrl: url,
      expiresIn: 3600,
    });
  } else {
    res.json({ status: export_.status });
  }
});
Enter fullscreen mode Exit fullscreen mode

When to Use File Exports

Data size → Response strategy:

  < 1 MB        Direct JSON response
  1-50 MB       Streaming NDJSON / compressed JSON
  50-500 MB     Streaming + compression, or file export
  > 500 MB      File export (S3 presigned URL)
  > 5 GB        Parquet/columnar format + file export
Enter fullscreen mode Exit fullscreen mode

Parquet for Analytical Data

Parquet is a columnar format — much more efficient than CSV for analytical queries:

import parquet from 'parquetjs';

async function exportToParquet(query, filePath) {
  const schema = new parquet.ParquetSchema({
    id: { type: 'INT64' },
    name: { type: 'UTF8' },
    email: { type: 'UTF8' },
    balance: { type: 'DOUBLE' },
    created_at: { type: 'TIMESTAMP_MILLIS' },
  });

  const writer = await parquet.ParquetWriter.openFile(schema, filePath);
  const stream = db.query('SELECT * FROM users').stream();

  for await (const row of stream) {
    await writer.appendRow(row);
  }

  await writer.close();
}

// File size comparison for 1M rows:
// CSV:     ~450 MB
// JSON:    ~487 MB
// Parquet: ~85 MB (columnar compression is very effective)
Enter fullscreen mode Exit fullscreen mode

Technique 12: Backpressure Deep Dive

Backpressure is the most important concept in streaming. If you don't handle it, streaming gives you zero benefit.

What Is Backpressure?

Without backpressure:

  Database        Server Buffer        Network        Client
  [100K rows/s]→  [████████████]  →   [slow 10K/s]→  [processing]
                   ↑ growing!
                   Eventually: OUT OF MEMORY

With backpressure:

  Database        Server Buffer        Network        Client
  [paused]    →   [██         ]  →    [10K rows/s]→  [processing]
  [10K rows/s]→   [██         ]  →    [10K rows/s]→  [processing]
                   ↑ stays small!
                   Memory: constant
Enter fullscreen mode Exit fullscreen mode

Implementing Backpressure in Node.js

Node.js streams have backpressure built in — but only if you use them correctly:

import { pipeline, Transform } from 'node:stream';
import { promisify } from 'node:util';

const pipelineAsync = promisify(pipeline);

app.get('/api/stream', async (req, res) => {
  const dbStream = db.query('SELECT * FROM big_table').stream();

  const serialize = new Transform({
    objectMode: true,
    highWaterMark: 100,  // buffer at most 100 objects

    transform(row, encoding, callback) {
      // callback() signals "I'm ready for more"
      // If downstream is full, Node pauses upstream automatically
      callback(null, JSON.stringify(row) + '\n');
    },
  });

  // pipeline() handles backpressure automatically between all streams
  await pipelineAsync(dbStream, serialize, res);
});
Enter fullscreen mode Exit fullscreen mode

Manual Backpressure (When Not Using pipeline)

function streamWithBackpressure(readable, writable) {
  return new Promise((resolve, reject) => {
    readable.on('data', (chunk) => {
      const canWrite = writable.write(chunk);

      if (!canWrite) {
        // Writable buffer is full — stop reading
        readable.pause();
        writable.once('drain', () => {
          // Buffer drained — resume reading
          readable.resume();
        });
      }
    });

    readable.on('end', () => {
      writable.end();
      resolve();
    });

    readable.on('error', reject);
    writable.on('error', reject);
  });
}
Enter fullscreen mode Exit fullscreen mode

Real-World Examples

How Stripe Exports Transactions

Stripe's data export system is a great example of handling massive datasets:

  1. Request an export via API → returns an export ID
  2. Async processing — Stripe generates the file in the background
  3. Polling or webhook — client checks status or receives a webhook
  4. Download via signed URL — the file is hosted on cloud storage
Client                    Stripe API                Background Worker
  │                          │                            │
  ├─ POST /exports ─────────►│                            │
  │◄── 202 { id: "exp_1" }──┤                            │
  │                          ├─ Queue export job ────────►│
  │                          │                            ├─ Query DB
  │                          │                            ├─ Stream to S3
  ├─ GET /exports/exp_1 ────►│                            ├─ Compress
  │◄── { status: "pending" }─┤                            │
  │                          │                            │
  │  ... wait ...            │                            │
  │                          │◄── Job complete ───────────┤
  ├─ GET /exports/exp_1 ────►│                            │
  │◄── { status: "complete", │                            │
  │     download_url: "..." }│                            │
  │                          │                            │
  ├─ GET download_url ──────►│ (S3 presigned URL)         │
  │◄── [CSV file] ──────────┤                            │
Enter fullscreen mode Exit fullscreen mode

This pattern is ideal for large exports because:

  • No request timeout issues (async processing)
  • Server memory stays constant (stream to storage)
  • Client gets a fast download from CDN/S3
  • Exports can be cached and re-downloaded

How Analytics Dashboards Stream Data

Tools like Metabase and Grafana use a hybrid approach:

  1. Initial load: Server-side aggregation (don't send raw rows, send aggregated results)
  2. Drill-down: Stream detail rows on demand with pagination
  3. Real-time updates: WebSocket for live metrics, SSE for feed-style data
  4. Export: Background file generation for full datasets
// Aggregation endpoint — fast, small response
app.get('/api/dashboard/summary', async (req, res) => {
  const summary = await db.query(`
    SELECT
      DATE_TRUNC('day', created_at) as day,
      COUNT(*) as count,
      SUM(amount) as total,
      AVG(amount) as average
    FROM transactions
    WHERE created_at > NOW() - INTERVAL '30 days'
    GROUP BY day
    ORDER BY day
  `);
  res.json(summary); // ~30 rows, < 5 KB
});

// Detail endpoint — streamed, on demand
app.get('/api/dashboard/details/:day', async (req, res) => {
  res.setHeader('Content-Type', 'application/x-ndjson');

  const stream = db.query(
    'SELECT * FROM transactions WHERE DATE(created_at) = $1',
    [req.params.day]
  ).stream();

  pipeline(
    stream,
    new Transform({
      objectMode: true,
      transform(row, enc, cb) { cb(null, JSON.stringify(row) + '\n'); },
    }),
    res,
    () => {},
  );
});
Enter fullscreen mode Exit fullscreen mode

Decision Framework

How much data are you sending?
│
├─ < 1,000 rows (~500 KB)
│   └─ Just send JSON. Don't overthink it.
│       Add gzip compression and call it a day.
│
├─ 1,000 – 50,000 rows (~500 KB – 25 MB)
│   └─ Paginate (cursor-based) with reasonable page sizes.
│       Add compression. Consider pagination + caching.
│
├─ 50,000 – 500,000 rows (~25 MB – 250 MB)
│   └─ Stream with NDJSON + gzip compression.
│       Use database-level streaming (QueryStream).
│       Handle backpressure properly.
│       Consider server-side aggregation first.
│
├─ 500,000 – 5,000,000 rows (~250 MB – 2.5 GB)
│   └─ File export (async job → S3 presigned URL).
│       Or gRPC streaming with Protocol Buffers.
│       Parquet format for analytical data.
│       Never try to send this as a single HTTP response.
│
└─ > 5,000,000 rows
    └─ Always file export. Consider Parquet/columnar format.
        Use database COPY command for extraction.
        Split into multiple files if needed.
        Consider whether the client really needs all this data
        (hint: they probably need an aggregation).
Enter fullscreen mode Exit fullscreen mode

Quick Reference

Technique Complexity Best For Memory Usage
Pagination Low Most CRUD APIs Constant (page size)
NDJSON streaming Medium Real-time processing Constant (~10-20 MB)
SSE Medium Live feeds, progress Constant
gRPC streaming High Microservices, binary data Constant
File export (S3) Medium-High Large exports, async Constant (stream to S3)
WebSocket batching High Real-time dashboards Batch size dependent
COPY + CSV Low Bulk DB exports Constant

Putting It All Together

Here's a production-grade export endpoint that combines multiple techniques:

import { pipeline } from 'node:stream/promises';
import { Transform } from 'node:stream';
import { createGzip } from 'node:zlib';
import QueryStream from 'pg-query-stream';

app.get('/api/v2/export', async (req, res) => {
  const format = req.query.format || 'ndjson'; // ndjson | csv
  const compress = req.accepts('gzip');

  // Set response headers
  const contentTypes = {
    ndjson: 'application/x-ndjson',
    csv: 'text/csv',
  };
  res.setHeader('Content-Type', contentTypes[format]);

  if (compress) {
    res.setHeader('Content-Encoding', 'gzip');
  }

  if (format === 'csv') {
    res.setHeader('Content-Disposition', 'attachment; filename="export.csv"');
  }

  // Database stream with cursor
  const client = await pool.connect();
  const query = new QueryStream(
    'SELECT id, name, email, amount, created_at FROM orders ORDER BY id',
    [],
    { batchSize: 2000 }
  );
  const dbStream = client.query(query);

  // Serialize to chosen format
  let isFirst = true;
  const serialize = new Transform({
    objectMode: true,
    transform(row, enc, cb) {
      if (format === 'csv') {
        if (isFirst) {
          this.push('id,name,email,amount,created_at\n');
          isFirst = false;
        }
        this.push(`${row.id},"${row.name}","${row.email}",${row.amount},${row.created_at}\n`);
      } else {
        this.push(JSON.stringify(row) + '\n');
      }
      cb();
    },
  });

  // Build pipeline
  const streams = [dbStream, serialize];
  if (compress) streams.push(createGzip({ level: 6 }));
  streams.push(res);

  try {
    await pipeline(...streams);
  } catch (err) {
    if (err.code !== 'ERR_STREAM_PREMATURE_CLOSE') {
      console.error('Export error:', err);
    }
  } finally {
    client.release();
  }
});
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

Sending a million rows efficiently comes down to a few principles:

  1. Don't buffer everything in memory — stream from source to destination
  2. Compress the wire format — gzip alone saves 90%+
  3. Use the right serialization — JSON is fine for most cases, Protobuf for performance-critical paths
  4. Handle backpressure — or your streaming is just buffering with extra steps
  5. Consider whether you need to send all the data — aggregation, pagination, and server-side filtering solve most problems before they start
  6. Use file exports for truly large datasets — async job + presigned URL is battle-tested

The most common mistake is trying to optimize the transport when you should be questioning whether you need to send that much data in the first place. A well-crafted aggregation query that returns 50 rows will always beat streaming a million.

But when you genuinely need to move a million rows — now you know how.


Let's Connect!

If you found this guide helpful, I'd love to connect with you! I regularly share deep dives on system design, backend engineering, and software architecture.

Connect with me on LinkedIn — let's grow together.

Drop a comment, share this with someone who needs this, and follow along for more guides like this!

Top comments (0)