
Node.jsでjsdomを使うとメモリリーク - window.close()忘れによる落とし穴と解決策
jsdomでHTMLパーサーを実装した際に発生したメモリリーク問題の原因と解決策を解説。並行処理環境で数千のwindowインスタンスが未解放になる問題をsetTimeout非同期クローズで解決した実例を紹介
公開日2025.11.14
更新日2025.11.14
はじめに
Node.jsでWebスクレイピングやHTML解析を行う際、jsdomは非常に便利なライブラリです。しかし、適切に使用しないとメモリリークが発生し、特にクラウド環境での長時間運用時に深刻な問題を引き起こします。
この記事では、jsdomを使ったHTML解析処理でメモリリークが発生した事例と、その解決方法を紹介します。
目次
対象読者
- Node.jsでjsdomを使用している開発者
- Webスクレイピングやクローラーを実装している開発者
- メモリリークに悩んでいる開発者
- クラウド環境(Railway、Heroku、AWS Lambda等)でバッチ処理を運用している開発者
基本的なNode.jsとJavaScript/TypeScriptの知識を前提としています。
発生した問題
症状
クラウド環境で動作しているバッチ処理システムで、実行時間が経過するにつれてメモリ使用量が増加し続ける現象が発生しました。
- 処理開始時: メモリ使用量 200MB
- 30分後: メモリ使用量 800MB
- 1時間後: メモリ使用量 1.2GB → プロセスがクラッシュ
処理の概要
この問題が発生したシステムは以下のような特徴を持っていました。
- 50件のURLを1バッチとして処理
- 10バッチを並行実行
- 各URLで5〜10回のHTML解析を実行
- 合計: 2,500〜5,000回のJSDOM生成が発生
最悪のケースでは、数千個のwindowオブジェクトがメモリに残り続けることになります。
原因の特定
jsdomのアーキテクチャ
jsdomは内部でブラウザ環境を模倣したwindowオブジェクトを生成します。このwindowオブジェクトは以下のような特徴を持ちます。
- DOMツリー全体を保持
- イベントリスナーの管理
- タイマーやリソースの参照
通常のブラウザでは、ページを離れる際に自動的にこれらのリソースが解放されますが、jsdomではプログラマが明示的にwindow.close()を呼ぶ必要があります。
問題のコード
以下は問題が発生していたコードの抽象化版です。
export const parseHTMLDocument = (html: string, baseUrl?: string): Document => {
const dom = createJSDOM(html, baseUrl)
return dom.window.document // ← windowが解放されない
}
// 使用例
const response = await fetch(url)
const html = await response.text()
const document = parseHTMLDocument(html)
// この時点でdocumentは使えるが、windowオブジェクトはメモリに残り続ける
const title = document.querySelector('h1')?.textContentこのコードの問題点は以下です。
dom.window.documentのみを返却している- 元の
dom.windowへの参照が失われる - そのため
window.close()を呼ぶ機会がない
メモリリークの増幅
この問題は並行処理環境では特に深刻です。
1URLあたりのJSDOM生成回数: 5〜10回
1バッチのURL数: 50件
並行バッチ数: 10
最悪ケース = 10 × 50 × 10 = 5,000個のwindowインスタンス各windowオブジェクトは数MBのメモリを消費するため、これが累積すると数GBに達します。
解決策
非同期自動クローズの実装
解決策は、Documentオブジェクトを返した直後に非同期でwindowを自動的にクローズすることです。
export const parseHTMLDocument = (html: string, baseUrl?: string): Document => {
const dom = createJSDOM(html, baseUrl)
const document = dom.window.document
// DOMを取得した後にwindowを閉じてメモリリークを防ぐ
setTimeout(() => {
dom.window.close()
}, 0)
return document
}なぜこの方法で解決するのか
setTimeout(() => {...}, 0)を使用する理由は以下です。
- 非同期実行: 現在の処理が完了した後にクローズ処理が実行される
- Documentの有効性: 返却されたDocumentオブジェクトは呼び出し側で即座に使用可能
- 自動クリーンアップ: 呼び出し側がwindowのことを意識する必要がない
JavaScriptのイベントループにおいて、setTimeoutはマイクロタスクキューに登録されるため、同期的な処理が完了した後に実行されます。つまり、以下のような順序で処理されます。
// 1. parseHTMLDocumentが呼ばれる
const document = parseHTMLDocument(html)
// 2. Documentが返される(この時点ではwindowはまだ生きている)
const title = document.querySelector('h1')?.textContent
// 3. 同期処理が完了
// 4. イベントループの次のサイクルでwindow.close()が実行される代替案との比較
他にも解決方法がありますが、それぞれにトレードオフがあります。
案1: windowオブジェクトも返す
export const parseHTMLDocument = (html: string, baseUrl?: string) => {
const dom = createJSDOM(html, baseUrl)
return {
document: dom.window.document,
close: () => dom.window.close(),
}
}
// 使用例
const { document, close } = parseHTMLDocument(html)
const title = document.querySelector('h1')?.textContent
close() // 呼び出し側が責任を持ってクローズこの方法の問題点は以下です。
- 呼び出し側がクローズ処理を忘れる可能性がある
- コードが煩雑になる
- 既存のコードベースへの影響が大きい
案2: try-finally パターン
export const withHTMLDocument = async <T>(
html: string,
callback: (document: Document) => T | Promise<T>,
): Promise<T> => {
const dom = createJSDOM(html)
try {
return await callback(dom.window.document)
} finally {
dom.window.close()
}
}
// 使用例
const title = await withHTMLDocument(html, (document) => {
return document.querySelector('h1')?.textContent
})この方法は安全ですが、以下の課題があります。
- 既存のコードの大幅な書き換えが必要
- コールバック形式に慣れていない開発者には分かりにくい
- ネストが深くなりやすい
setTimeout方式の優位性
今回採用したsetTimeout方式は以下の点で優れています。
- 既存のコードへの影響が最小限
- 呼び出し側はクローズ処理を意識する必要がない
- シンプルで理解しやすい
実装上の注意点
Documentオブジェクトの寿命
window.close()が呼ばれた後も、返却されたDocumentオブジェクトは引き続き使用できます。これはDocumentがwindowから独立したオブジェクトとして存在するためです。
ただし、以下の操作は避けるべきです。
const document = parseHTMLDocument(html)
// OK: 同期的な読み取り操作
const title = document.querySelector('h1')?.textContent
// NG: 長時間の非同期操作後の参照
await someAsyncOperation()
setTimeout(() => {
// この時点でwindowは既にクローズされている可能性が高い
const content = document.querySelector('.content')?.textContent
}, 1000)大量データ処理での工夫
さらに大規模なデータを扱う場合、バッチサイズの調整も有効です。
// 並行処理数を制限する例
const CONCURRENT_LIMIT = 5
async function processBatch(urls: string[]) {
for (let i = 0; i < urls.length; i += CONCURRENT_LIMIT) {
const batch = urls.slice(i, i + CONCURRENT_LIMIT)
await Promise.all(batch.map((url) => processUrl(url)))
// バッチ間でガベージコレクションの機会を与える
await new Promise((resolve) => setTimeout(resolve, 100))
}
}効果測定
この修正により、以下の改善が確認されました。
- メモリリークが解消
- メモリ使用量が安定(200〜300MB で推移)
- クラウド環境での長時間運用が可能に
- プロセスのクラッシュが発生しなくなった
jsdom使用時のベストプラクティス
jsdomを使用する際は、以下の点に注意してください。
1. 常にwindowをクローズする
windowオブジェクトを生成したら、必ずクローズ処理を実装します。
2. 並行処理数を制御する
無制限に並行処理を行うとメモリが枯渇する可能性があります。適切な並行数制限を設けましょう。
3. メモリ使用量をモニタリングする
開発環境でメモリ使用量を定期的に確認し、リークの兆候を早期に発見します。
// メモリ使用量のログ出力例
setInterval(() => {
const usage = process.memoryUsage()
console.log(`Memory: ${Math.round(usage.heapUsed / 1024 / 1024)}MB`)
}, 10000)4. ユニットテストでメモリリークを検出
テストコードでもメモリリークをチェックできます。
describe('parseHTMLDocument', () => {
it('should not leak memory', async () => {
const initialMemory = process.memoryUsage().heapUsed
// 大量にパースを実行
for (let i = 0; i < 1000; i++) {
const doc = parseHTMLDocument('<html><body>test</body></html>')
doc.querySelector('body')
}
// ガベージコレクションを強制
if (global.gc) global.gc()
const finalMemory = process.memoryUsage().heapUsed
const diff = finalMemory - initialMemory
// メモリ増加が一定範囲内であることを確認
expect(diff).toBeLessThan(10 * 1024 * 1024) // 10MB未満
})
})まとめ
jsdomは便利なライブラリですが、windowオブジェクトの管理を怠るとメモリリークが発生します。特に並行処理環境では問題が増幅されるため、適切なクリーンアップ処理が必要です。
今回紹介したsetTimeout(() => { dom.window.close() }, 0)パターンは、シンプルかつ効果的な解決策です。既存のコードへの影響を最小限に抑えながら、メモリリークを防ぐことができます。
クラウド環境でのスクレイピングやバッチ処理を行う際は、メモリ管理に注意を払い、安定した長時間運用を実現しましょう。
お問い合わせはこちらから
ご希望に応じて職務経歴書や過去のポートフォリオを提出可能ですので、必要な方はお申し付けください。
また内容とによっては返信ができない場合や、お時間をいただく場合がございます。あらかじめご了承ください。
