m memory, capped at 8GB
const calculateMemoryLimit = (): number => {
const totalBytes = totalmem();
const totalGB = totalBytes / (1024 * 1024 * 1024);
const limitGB = Math.min(Math.floor(totalGB * 0.75), 8);
return limitGB * 1024; // Convert to MB
};
const memoryLimit = process.env.BUILD_MEMORY_LIMIT
? parseInt(process.env.BUILD_MEMORY_LIMIT, 10)
: calculateMemoryLimit();
console.log([Build Runner] Allocating ${memoryLimit}MB to V8 heap.);
const child = spawn('npx', ['turbo', 'run', 'build'], {
stdio: 'inherit',
env: {
...process.env,
NODE_OPTIONS: --max-old-space-size=${memoryLimit},
},
});
child.on('close', (code) => {
exit(code ?? 1);
});
Update `package.json` to use this wrapper:
```json
{
"scripts": {
"build:prod": "tsx scripts/run-build.ts"
}
}
Rationale: This approach ensures that local machines with abundant RAM utilize more memory for faster builds, while constrained CI environments automatically scale down to safe limits. It eliminates cross-platform syntax issues and centralizes memory logic.
2. CI/CD Pipeline Integration
For managed CI environments where script wrappers may not be feasible, environment variables must be injected at the runner level.
GitHub Actions Configuration:
jobs:
production-build:
runs-on: ubuntu-latest
env:
NODE_OPTIONS: "--max-old-space-size=6144"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
Docker Multi-Stage Build:
Use build arguments to allow flexibility without modifying the Dockerfile for every project.
FROM node:20-alpine AS builder
ARG NODE_MEMORY=4096
ENV NODE_OPTIONS="--max-old-space-size=${NODE_MEMORY}"
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]
3. Build Tool Optimization
Allocation limits should be paired with toolchain configurations that reduce memory pressure.
Vite Configuration:
Disable source maps in CI environments or use hidden source maps to reduce AST retention.
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
build: {
sourcemap: process.env.CI ? 'hidden' : false,
rollupOptions: {
output: {
manualChunks: (id) => {
if (id.includes('node_modules')) {
return 'vendor';
}
},
},
},
},
});
Webpack Configuration:
For Webpack-based projects, ensure the TerserPlugin is configured to use multiple workers, as single-threaded minification can cause memory spikes.
// webpack.config.js
const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
parallel: true,
terserOptions: {
sourceMap: false,
},
}),
],
},
};
Pitfall Guide
-
Masking Circular Dependencies
- Explanation: Increasing memory limits can allow builds with circular imports to complete, but the compiler may enter recursive evaluation loops that consume excessive CPU and RAM.
- Fix: Run
npx madge --circular ./src or npx dpdm ./src regularly. Resolve cycles by extracting shared logic into independent modules.
-
Source Map Bloat
- Explanation: Generating inline or full source maps for large third-party dependencies can double memory usage during the build.
- Fix: Use
hidden source maps in production builds if error tracking services support them, or disable source maps entirely in CI if not required for debugging.
-
Hardcoded Limits Across Environments
- Explanation: Setting
--max-old-space-size=8192 on a CI runner with only 4GB of total RAM will cause the container to be killed by the OOM killer, not V8.
- Fix: Ensure the V8 limit is always less than the container's total memory. Leave headroom for the OS and other processes. Use dynamic calculation as shown in the Core Solution.
-
Ignoring Garbage Collection Pressure
- Explanation: A build may not crash but run slowly due to frequent garbage collection cycles. This indicates the heap is too small for the workload, even if it doesn't exceed the limit.
- Fix: Monitor build duration. If GC pauses are frequent, increase the allocation incrementally until build time stabilizes.
-
Windows Path and Syntax Errors
- Explanation: Windows Command Prompt and PowerShell handle environment variable syntax differently than Unix shells. Directly copying
NODE_OPTIONS=... scripts fails on Windows.
- Fix: Use the script wrapper approach or
cross-env in package.json scripts to normalize behavior across platforms.
-
Runtime Leaks Disguised as Build Issues
- Explanation: If the error occurs during a running server process rather than a build, increasing memory is a temporary bandage. The application likely has a memory leak, such as unclosed event listeners or unbounded caches.
- Fix: Use
clinic.js doctor or Chrome DevTools with --inspect to profile heap snapshots and identify objects preventing garbage collection.
-
Monorepo Workspace Overhead
- Explanation: In monorepos, build tools may attempt to process all packages simultaneously, leading to multiplicative memory usage.
- Fix: Configure task runners like Turborepo or Nx to limit concurrency. Use
--concurrency=2 to reduce peak memory usage at the cost of slightly longer total build time.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small Repository (<10k files) | Default V8 Configuration | Memory usage stays well within limits; no tuning required. | Minimal |
| Large Monorepo (>50k files) | Tuned Allocation (6GB–8GB) | Prevents crashes during bundling of large ASTs. | Medium (Requires larger CI runners) |
| CI Runner Memory Constrained | Optimized Config + Dynamic Limit | Reduces peak usage to fit within available RAM. | Low |
| Suspected Memory Leak | Diagnostic Profiling | Identifies root cause; increasing memory is ineffective. | Low |
| Cross-Platform Team | Script Wrapper | Ensures consistent behavior on Windows, macOS, and Linux. | Low |
Configuration Template
GitHub Actions Workflow with Memory Tuning:
name: Production Build
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
env:
NODE_OPTIONS: "--max-old-space-size=6144"
CI: true
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install Dependencies
run: npm ci
- name: Run Build
run: npm run build:prod
- name: Verify Output
run: |
if [ ! -d "dist" ]; then
echo "Build failed: dist directory missing"
exit 1
fi
Dockerfile with Build Argument:
FROM node:20-alpine AS builder
# Default to 4GB, override with --build-arg NODE_MEMORY=8192
ARG NODE_MEMORY=4096
ENV NODE_OPTIONS="--max-old-space-size=${NODE_MEMORY}"
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/index.js"]
Quick Start Guide
- Check Current Limit: Run
node -e "console.log(require('v8').getHeapStatistics().heap_size_limit / 1024 / 1024 + 'MB')" to see your current V8 limit.
- Set Environment Variable: Add
NODE_OPTIONS="--max-old-space-size=4096" to your CI environment settings or run export NODE_OPTIONS="--max-old-space-size=4096" in your terminal.
- Execute Build: Run your build command. If it succeeds, verify memory usage did not approach the limit.
- Optimize: If memory usage is high, disable source maps in your build config and audit for circular dependencies.
- Validate CI: Trigger a pipeline run to ensure the configuration works in the automated environment.