javascript 109 lines · 3 tabs

Stream and Validate Large CSV Uploads Row-by-Row with Node Streams and Backpressure

Shared by codesnips Aug 2026
3 tabs
const express = require('express');
const multer = require('multer');
const os = require('os');
const fs = require('fs/promises');
const { parseCsvStream } = require('./csvStreamParser');

const upload = multer({
  storage: multer.diskStorage({ destination: os.tmpdir() }),
  limits: { fileSize: 2 * 1024 * 1024 * 1024 } // 2GB
});

const router = express.Router();

router.post('/imports/contacts', upload.single('file'), async (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: 'file field is required' });
  }

  try {
    const result = await parseCsvStream(req.file.path, req.app.locals.db);
    res.status(result.errors.length ? 207 : 200).json(result);
  } catch (err) {
    res.status(422).json({ error: err.message });
  } finally {
    await fs.unlink(req.file.path).catch(() => {});
  }
});

module.exports = router;
3 files · javascript Explain with highlit

Parsing a multi-gigabyte CSV upload by buffering it into memory is a classic way to crash a Node process. The approach shown here treats the upload as a pipeline of streams so that at any moment only a few rows and one insert batch are held in memory, regardless of file size. The uploadRoutes tab wires an Express endpoint with multer configured for disk storage rather than memory storage, which is the key first decision: multer.diskStorage writes the incoming request body to a temp file, so the HTTP body never has to fit in RAM.

Once the file is on disk, parseCsvStream in the csvStreamParser tab builds the real work. It opens a fs.createReadStream, pipes it through csv-parse configured with columns: true so each record arrives as an object keyed by header, and wraps the whole thing in pipeline from stream/promises. Using pipeline matters because it propagates errors and destroys every stream in the chain if any stage fails, avoiding leaked file descriptors on a malformed file.

The heart of the pattern is the Writable stream BatchInserter in the BatchInserter stream tab. Each parsed row flows into its _write method, which validates the row, pushes valid ones into an internal this.buffer, and only touches the database once the buffer reaches batchSize. Crucially, _write's callback is not invoked until the batch flush resolves. This is how backpressure works: the parser cannot push the next record until the writable signals readiness, so a slow database naturally throttles the fast file reader instead of letting rows pile up in memory.

Invalid rows are collected in this.errors rather than aborting the whole import, which suits bulk ingestion where partial success is acceptable. _final flushes the last partial batch so no rows are lost at end-of-file. The controller reports counts and always removes the temp file in a finally block. A subtle trade-off: this design commits batches incrementally, so a mid-stream failure leaves earlier batches persisted; when full atomicity is required, wrapping the pipeline in a single transaction or staging table is the alternative. This structure scales to arbitrarily large files with flat, predictable memory use.


Related snips

Share this code

Here's the card — post it anywhere.

Stream and Validate Large CSV Uploads Row-by-Row with Node Streams and Backpressure — share card
Link copied