Node.jsを使用してファイルをダウンロードする処理は、一見すると単純なWebリクエストのように思えます。

しかし、数GBを超える大容量ファイルや大量のリクエストを処理する場合、実装方法によってはサーバーのメモリを急激に消費し、システム全体のパフォーマンスを低下させる原因となります。

2026年現在のNode.js開発においては、標準搭載されたFetch APIや強力なストリーム(Stream)機能を活用し、メモリ効率を最大化する実装が強く求められています。

本記事では、Node.jsでファイルをダウンロードする際の標準的な手法から、ストリーム処理を用いた高度な最適化手法まで、実務で役立つテクニックを詳しく紹介します。

Node.jsにおけるファイルダウンロードの基本概念

ファイルダウンロードを実装する際、まず理解しておくべきは「Buffer(バッファ)」と「Stream(ストリーム)」の違いです。

Buffer方式では、ダウンロードしたデータの全量を一度メモリ上に展開してからファイルとして保存します。

この方法は小規模なファイルであれば問題ありませんが、大容量ファイルの場合、実行環境のメモリ上限を超えてプロセスがクラッシュするリスクがあります。

対して、ストリーム方式はデータを小さな断片(チャンク)として順次処理し、読み込みと書き込みを同時に行います。

ストリーム処理を採用することで、ファイルサイズに関わらず一定の低メモリ消費量で安定したダウンロードが可能になります。

標準HTTPSモジュールを使用した基本的なダウンロード

Node.jsの標準モジュールである https モジュールを使用すると、外部ライブラリに依存せずにダウンロード機能を実装できます。

以下のコードは、指定したURLからデータを取得し、ローカルファイルとして保存する基本的な例です。

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

const url = 'https://example.com/sample-image.jpg';
const file = fs.createWriteStream('sample-image.jpg');

https.get(url, (response) => {
  // レスポンスをファイルストリームにパイプでつなぐ
  response.pipe(file);

  file.on('finish', () => {
    file.close();
    console.log('ダウンロードが完了しました');
  });
}).on('error', (err) => {
  console.error(`エラーが発生しました: ${err.message}`);
});
実行結果
ダウンロードが完了しました

この実装のポイントは、response.pipe(file) という記述です。

pipe() メソッドを使用することで、読み込みストリームから出力ストリームへデータを直接受け渡し、効率的な処理を実現しています。

メモリ負荷を抑えるストリーム処理の実装

より堅牢なシステムを構築する場合、前述の pipe() よりも、エラーハンドリングに優れた stream.pipeline 関数の使用が推奨されます。

pipe() は途中でエラーが発生した場合にストリームが適切にクローズされない可能性がありますが、pipeline は自動的にリソースをクリーンアップします。

JavaScript
const { pipeline } = require('stream/promises');
const fs = require('fs');
const https = require('https');

async function downloadFile(url, dest) {
  return new Promise((resolve, reject) => {
    https.get(url, async (response) => {
      if (response.statusCode !== 200) {
        reject(new Error(`ステータスコード: ${response.statusCode}`));
        return;
      }

      try {
        // pipelineを使用して安全にストリームを結合
        await pipeline(
          response,
          fs.createWriteStream(dest)
        );
        resolve();
      } catch (err) {
        reject(err);
      }
    });
  });
}

downloadFile('https://example.com/large-video.mp4', 'video.mp4')
  .then(() => console.log('安全にダウンロードが完了しました'))
  .catch(err => console.error('エラー:', err));
実行結果
安全にダウンロードが完了しました

stream/promisespipeline を活用することで、非同期処理(async/await)との親和性が高まり、コードの可読性が大幅に向上します。

現代的なFetch APIによるダウンロード手法

Node.js 18以降、ブラウザ標準の fetch APIが標準搭載されており、2026年現在では主流の選択肢となっています。

fetch を使用すると、従来の https モジュールよりも直感的かつ簡潔にHTTPリクエストを記述できます。

ただし、大容量ファイルを扱う場合は、response.arrayBuffer() ではなく、response.body (ReadableStream) を使用する必要があります。

JavaScript
const fs = require('fs');
const { Readable } = require('stream');
const { finished } = require('stream/promises');

async function downloadWithFetch(url, path) {
  const response = await fetch(url);
  
  if (!response.ok) {
    throw new Error(`リクエスト失敗: ${response.statusText}`);
  }

  const fileStream = fs.createWriteStream(path);
  
  // Node.jsのReadableストリームに変換してパイプ処理
  await finished(Readable.fromWeb(response.body).pipe(fileStream));
  
  console.log('Fetch APIによるダウンロードが完了しました');
}

downloadWithFetch('https://example.com/data.zip', 'data.zip');
実行結果
Fetch APIによるダウンロードが完了しました

ここで重要なのは Readable.fromWeb(response.body) の部分です。

Web標準のストリームをNode.js固有のストリーム形式に変換することで、ファイルシステム(fsモジュール)へのシームレスな書き込みが可能になります。

進捗状況の表示とモニタリング

ユーザーインターフェースを持つツールや、長時間かかるダウンロード処理では、進捗状況(プログレス)の表示が重要です。

レスポンスヘッダーの content-length を取得し、現在までに受信したバイト数を計算することで、進捗率を算出できます。

JavaScript
const fs = require('fs');

async function downloadWithProgress(url, path) {
  const response = await fetch(url);
  const totalBytes = parseInt(response.headers.get('content-length'), 10);
  let downloadedBytes = 0;

  const reader = response.body.getReader();
  const writer = fs.createWriteStream(path);

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

    downloadedBytes += value.length;
    writer.write(value);

    // 進捗率の計算と表示
    const progress = ((downloadedBytes / totalBytes) * 100).toFixed(2);
    process.stdout.write(`\rダウンロード中: ${progress}%`);
  }

  writer.end();
  console.log('\nダウンロード完了');
}
実行結果
ダウンロード中: 100.00%
ダウンロード完了

この手法により、ユーザーに対して現在の処理状況をリアルタイムで通知することが可能になり、UXの向上に寄与します。

エラーハンドリングと再試行(リトライ)ロジック

ネットワークの状態は不安定であるため、ダウンロード処理にはエラーハンドリングと再試行ロジックが不可欠です。

特に一時的な接続タイムアウトなどに対しては、数秒待機してから再度試行する仕組みが効果的です。

エラー種類原因の例対処方法
404 Not FoundURLの誤り、ファイル削除URLを確認し、再試行を中止する
503 Service Unavailableサーバー負荷、一時停止指数バックオフを用いて再試行する
ETIMEDOUTネットワーク遮断タイムアウト値を調整して再試行する

再試行の実装時には、単純なループではなく、試行回数ごとに待ち時間を増やす「指数バックオフ(Exponential Backoff)」の採用を検討してください。

セキュリティ上の考慮事項

ファイルをダウンロードしてローカルに保存する際、パス・トラバーサル(Path Traversal)攻撃への対策は必須です。

外部から指定されたファイル名をそのまま保存先のパスに使用すると、意図しないディレクトリにファイルを上書きされる危険があります。

ファイル名は必ずサニタイズするか、サーバー側で生成したランダムな文字列を使用するようにしましょう。

また、ダウンロード先のディレクトリ権限を最小限に設定し、実行権限を与えないこともセキュリティ強化のポイントです。

まとめ

Node.jsにおけるファイルダウンロードの実装は、ストリーム処理を軸に考えることが最も重要です。

2026年のモダンな開発環境では、標準の fetch APIと stream/promises を組み合わせることで、効率的かつ安全なコードを記述できます。

「メモリ消費を一定に保つこと」および「エラー発生時のリソース解放を確実に行うこと」の2点を常に意識してください。

本記事で紹介した手法を活用し、パフォーマンスと信頼性の高いアプリケーション構築を目指しましょう。