Logo Vincent
Back to all posts

Node.js in Practice: Downloading Files

Node.js
Node.js in Practice: Downloading Files

Preface

Downloading files is one of the most common features in Node.js,

but in practice, file downloading can hide various pitfalls.

Original Code

If you search for Node.js file download code online,

you will likely find a code snippet similar to the following.

This article starts from here and progressively optimizes the file download functionality.

const http = require('http');
const https = require('https');
const fs = require('fs');

function downloadFile(url, dest) {
  let protocol = url.startsWith('https') ? https : http;
  let file = fs.createWriteStream(dest);
  protocol
    .get(url, (res) => {
      res.pipe(file);
      file.on('finish', () => {
        file.close();
        console.log('Download complete.');
      });
    })
    .on('error', (err) => {
      fs.unlink(dest);
      console.error(`Download failed: ${err.message}`);
    });
}

The code snippet above shows:

1. Compatibility with Node.js native http and https requests

2. After getting the URL content via a GET request, it pipes to a local file write stream

3. Request errors are caught, and the downloaded file is deleted on error

Validating URL Legitimacy

As you can see, the code above uses the URL directly.

In practice, the URL’s legitimacy should be validated first.

The code is as follows:

// url
import { URL } from 'url';

/**
 * check url
 * @param {*} checkUrl
 * @returns
 */
export const checkUrl = (inputUrl) => {
  // check
  if (!inputUrl) return;

  // check url
  const url = new URL(inputUrl);
  if (!url) return;

  // check protocol
  return url.protocol === 'http:' || url.protocol === 'https:';
};

Here, Node.js’s native url module is used for validation.

Validating Destination Path Legitimacy

Similarly, the code above uses the destination path directly.

Some validation should also be performed here.

The code is as follows:

// fs
import { path, mkdir } from 'qiao-file';

/**
 * check dest
 * @param {*} dest
 * @returns
 */
export const checkDest = async (dest) => {
  // check dest
  if (!dest) return;

  // absolute dest
  const destIsAbsolute = path.isAbsolute(dest);
  if (!destIsAbsolute) dest = path.resolve(__dirname, dest);

  // mkdir
  const dir = path.dirname(dest);
  const mkdirRes = await mkdir(dir);
  if (!mkdirRes) return;

  // return
  return dest;
};

1. This handles the case where dest is a relative path

2. And automatically creates the folder if dest’s directory does not exist

The code after adding URL and dest validation is as follows:

// http
import http from 'http';
import https from 'https';

// fs
import { fs } from 'qiao-file';

// check
import { checkUrl } from './check-url.js';
import { checkDest } from './check-dest.js';

/**
 * download
 * @param {*} url
 * @param {*} dest
 * @returns
 */
export const download = async (url, dest) => {
  // check url
  if (!checkUrl(url)) return Promise.reject(new Error('url is not valid'));

  // check dest
  const newDest = await checkDest(dest);
  if (!newDest) return Promise.reject(new Error('dest is not valid'));

  // p and file
  const file = fs.createWriteStream(newDest);
  const protocol = url.startsWith('https') ? https : http;

  //
  return new Promise((resolve, reject) => {
    protocol
      .get(url, (res) => {
        // pipe
        res.pipe(file);

        // file
        file.on('finish', () => {
          file.close();

          resolve(newDest);
        });
        file.on('error', (err) => {
          fs.unlink(newDest);
          reject(err);
        });
      })
      .on('error', (err) => {
        fs.unlink(newDest);
        reject(err);
      });
  });
};

Validating File Stream Errors

In the code above, the file stream write error listener

is placed after res.pipe.

If writing to a path without permissions,

an uncaught exception will be thrown.

The fix is as follows:

return new Promise((resolve, reject) => {
  // file write stream
  const file = fs.createWriteStream(newDest);
  file.on('error', (err) => {
    reject(err);
  });
  file.on('finish', () => {
    file.close();
    resolve(newDest);
  });

  // get
  const protocol = url.startsWith('https') ? https : http;
  protocol
    .get(url, (res) => {
      // pipe
      res.pipe(file);
    })
    .on('error', (err) => {
      fs.unlink(newDest);
      reject(err);
    });
});

Validating Request Errors

The code above does not validate request errors.

For example, if the response code is not 200,

it won’t trigger the error listener,

but will download an incorrect file instead.

The fix is as follows:

// get
const protocol = url.startsWith('https') ? https : http;
protocol
  .get(url, (res) => {
    // check res
    if (!res || res.statusCode !== 200) {
      return reject(new Error('response status code is not 200'));
    }

    // pipe
    res.pipe(file);
  })
  .on('error', async (err) => {
    await rm(newDest);
    reject(err);
  });

Setting Timeout

Next, let’s set the download timeout.

This is not as simple as setting the timeout directly on the request.

The timeout property in Node.js’s http module

represents the connection establishment timeout.

For example, setting timeout to 100ms

means the connection establishment exceeds 100ms,

not that the entire download process exceeds 100ms.

This needs to be implemented manually.

The code is as follows:

// timeout
let timeoutId;
if (timeout) {
  timeoutId = setTimeout(() => {
    if (res) res.destroy();
    clearFile(newDest);
    reject(new Error('timeout'));
  }, timeout);
}

const file = fs.createWriteStream(newDest);
file.on('error', (err) => {
  clearTimeoutFn(timeoutId, timeout);
  reject(err);
});
file.on('finish', () => {
  file.close();

  clearTimeoutFn(timeoutId, timeout);
  resolve(newDest);
});

// pipe
res.pipe(file);

1. First, if a timeout parameter is passed, start the timeout

2. If completed early, clear the timeout

3. If not completed in time, clean up the file and return an error

Validating File Integrity

After downloading a file, its integrity should be validated.

Here we simply check the file size.

The code is as follows:

export const checkFileSize = (res, dest) => {
  // check
  if (!res || !dest) return;

  // res length
  const resLength = res.headers['content-length'];
  if (!resLength) return;

  // fs length
  try {
    const s = fs.statSync(dest);
    if (!s || !s.size) return;

    return parseInt(resLength, 10) === s.size;
  } catch (error) {
    debug(error);
  }
};

Download Progress

During file download,

real-time feedback on download progress is also important.

This can be achieved by listening to the data event on res.

The code is as follows:

// data
let data = 0;

/**
 * on progress
 * @param {*} res
 * @param {*} onProgress
 * @returns
 */
export const onDownloadProgress = (res, onProgress) => {
  // check res
  if (!res) return;

  // check progress
  if (!onProgress) return;

  // check content
  const contentLength = res.headers['content-length'];
  if (!contentLength) return;

  // total
  const total = parseInt(contentLength, 10);

  // on data
  res.on('data', (chunk) => {
    data = data + chunk.length;

    const p = (data / total).toFixed(3);
    const n = parseFloat(p);
    onProgress(n);
  });
};

qiao-downloader

Here is a packaged npm module for convenience:

https://www.npmjs.com/package/qiao-downloader

1. Basic file download capability

2. Request error validation

3. File stream error validation

4. Custom timeout capability

5. File size validation after download

6. Download progress callback support

© 2026 Vincent. All rights reserved.