画像アセットの多い壁紙アプリをビルドしていたとき、進捗が9割近くまで進んだところで突然ターミナルが赤く染まりました。FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory。何度やり直しても同じ場所で落ちます。私はこの数年、6本の壁紙アプリを並行して運用していますが、アセット数が一定を超えたアプリだけがこの症状に当たりました。
このエラーは「コードが間違っている」のではなく「Node.js に割り当てられたメモリが足りていない」というサインです。原因がコード以外の場所にあるぶん、エラーメッセージだけ眺めても解決の糸口が見えにくいのが厄介なところです。ここでは、ローカルの Metro と EAS Build の両方で、どこに手を入れれば最短で復旧できるかを順を追って整理します。
まず症状と再現条件を切り分ける
このエラーが出る典型的な場面は3つあります。npx expo start でバンドル中に落ちる、eas build のクラウドビルドで Gradle/Xcode 前のバンドル生成段階で落ちる、expo export で本番バンドルを書き出すときに落ちる、です。
共通しているのは「JavaScript のモジュールを大量に同時展開する瞬間」だという点です。Node.js は既定でヒープ上限が決まっており(バージョンや環境によって概ね2GB〜4GB程度)、巨大なアプリや大量のアセットを抱えるとこの上限に先に到達します。つまり、アプリの規模が育った証拠でもあるわけです。
切り分けの第一歩として、本当にメモリ不足なのかを確認します。ビルドコマンドの前に上限を一時的に引き上げてみて、それで通るなら原因はほぼ確定です。
# 一時的にヒープ上限を8GBに引き上げて再現するか確認
NODE_OPTIONS=--max-old-space-size=8192 npx expo start --clearこれで落ちなくなったなら、メモリ不足が原因だと断定できます。逆にこれでも落ちる場合は、循環参照や無限ループなど別の問題を疑う必要があります。
対処1: ローカルのヒープ上限を恒久的に引き上げる
毎回環境変数を打つのは現実的ではないので、package.json のスクリプトに組み込みます。チームや別マシンでも同じ挙動になり、設定漏れを防げます。
{
"scripts": {
"start": "NODE_OPTIONS=--max-old-space-size=8192 expo start",
"export": "NODE_OPTIONS=--max-old-space-size=8192 expo export"
}
}Windows では NODE_OPTIONS=... の前置きが効かないので、cross-env を挟むと OS をまたいで動きます。
{
"scripts": {
"start": "cross-env NODE_OPTIONS=--max-old-space-size=8192 expo start"
}
}数値は搭載メモリの半分程度を目安にします。16GB のマシンなら8192(8GB)が安全圏で、これより大きくしても物理メモリを超えるとスワップが発生して逆に遅くなります。私は手元の Mac では8192で安定し、それ以上に上げても効果はありませんでした。
対処2: EAS Build はリソースクラスを上げる
ローカルではなくクラウドビルドで落ちる場合、NODE_OPTIONS をローカルで設定しても意味がありません。EAS Build のビルドマシン側のメモリを増やす必要があります。eas.json の各プロファイルに resourceClass を指定します。
{
"build": {
"production": {
"android": { "resourceClass": "large" },
"ios": { "resourceClass": "large" },
"env": {
"NODE_OPTIONS": "--max-old-space-size=8192"
}
}
}
}ここでのポイントは、resourceClass でマシン自体のメモリを増やしつつ、env で Node のヒープ上限も合わせて引き上げることです。マシンのメモリだけ増やしても、Node 側の上限が低いままだと結局同じ場所で落ちます。両方を揃えて初めて効きます。なお large は無料枠の対象外になることがあるため、料金プランは事前に確認しておくと安心です。
対処3: そもそもバンドルに載せる量を減らす
メモリを増やすのは対症療法です。アプリが育ち続ける前提なら、バンドルに載るモジュール量を構造的に減らしておくほうが長く効きます。metro.config.js でインライン展開を有効にすると、起動時に一度に読み込むモジュール数を抑えられます。
// metro.config.js
const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
config.transformer.getTransformOptions = async () => ({
transform: {
experimentalImportSupport: false,
inlineRequires: true,
},
});
module.exports = config;画像アセットについても、巨大な PNG を JS バンドルに巻き込まないよう、require で参照する画像は最適化済みのものに差し替えておきます。私の壁紙アプリでは高解像度画像をリモート配信に切り出した結果、バンドル生成時のメモリ消費が目に見えて下がりました。アセットをローカルに抱え込みすぎないことが、長期的にはいちばん効く予防策だと感じています。
Android側のGradleで落ちる場合は別の上限を疑う
紛らわしいのですが、Androidビルドで似たメモリ不足が出ても、それがNode.jsではなくGradle(JVM)のヒープ不足であることがあります。ログに JavaScript heap ではなく Java heap space や GC overhead limit exceeded と出ていたら、引き上げる場所が違います。この場合は android/gradle.properties のJVM引数を調整します。
# android/gradle.properties
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024mNODE_OPTIONS をいくら上げてもこちらは効きません。エラーメッセージの一語(JavaScript なのか Java なのか)で対処先が分かれる、という点だけ覚えておくと、無駄な試行錯誤を避けられます。私も最初の頃は両者を混同して、Node側ばかり触って時間を溶かしました。ログの主語を最初に読む癖をつけてから、復旧がぐっと速くなりました。
再発させないための習慣
このエラーは、アプリが順調に成長しているときほど不意に現れます。私は2014年から個人でアプリ開発を続け、累計5,000万ダウンロードに育つ過程で、機能やアセットが増えるたびに同じ壁にぶつかってきました。だからこそ、CI のビルドプロファイルには最初から NODE_OPTIONS を入れておき、アセット追加時はバンドルサイズの変化を確認する、という運用を習慣にしています。
次の一手としては、まず手元で NODE_OPTIONS=--max-old-space-size=8192 を付けて再現するかを確かめてください。そこで通るなら、上で紹介した恒久設定に落とし込むだけで、このエラーとはしばらく無縁でいられます。同じ場所で何度も止まって困っている方の、最短の復旧の助けになれば幸いです。