Bleeding-edge stack on legacy hardware#
Development host: ASUS X450LC (same host documented in the kernel/NVIDIA audit) CPU: Intel Core i5-4200U (Haswell, dual-core HT, 1.6 - 2.6 GHz) RAM: 10 GB DDR3 OS: Linux Mint 22.2 (Kernel 6.8 LTS, i915-only GPU after nouveau blacklist) Original package manager: pnpm Original runtime: Node.js 20 LTS
Project stack:
{
"dependencies": {
"next": "16.x (Turbopack enabled)",
"react": "19.0.0-rc",
"tailwindcss": "^4.0",
"@prisma/client": "^5.x",
"auth.js": "^5 (ex-NextAuth)",
"@clerk/nextjs": "^5",
"@dnd-kit/core": "^6",
"@tiptap/react": "^2",
"framer-motion": "^11",
"@react-pdf/renderer": "^3",
"fabric": "^5"
}
}
The research question: Does Bun as a drop-in replacement for pnpm + Node.js deliver measurable development performance gains on a dual-core machine with 10 GB of RAM?
Safe migration strategy: zero lock-in#
The migration was structured to be completely reversible. Bun respects package.json and reads pnpm-lock.yaml as dependency resolution source, generating its own bun.lockb without destroying the original lockfile.
Executed sequence:
# Step 1: Preserve the pnpm lockfile (do not delete)
# pnpm-lock.yaml remains untouched in the repository
# Step 2: Clear node_modules (Bun needs to install with its own engine)
rm -rf node_modules
# Step 3: Install with Bun - reads package.json and pnpm-lock.yaml
bun install
bun install output (time comparison):
bun install v1.1.x (...)
+ 847 packages installed [4.23s] ← With warm Bun cache
+ 847 packages installed [12.8s] ← Clean installation (no cache)
For reference, the same pnpm install on the same hardware took between 35 to 55 seconds on a clean install. The 3 to 4x gain in dependency installation is immediate and requires no code changes.
Rollback plan if anything broke:
rm -rf node_modules bun.lockb
pnpm install
# Back to original state in ~45 seconds
The first error: ghost prisma client after node_modules removal#
When attempting to start the development server with memory limit injection (necessary on hardware with 10 GB shared):
NODE_OPTIONS='--max-old-space-size=4096' next dev -H 127.0.0.1
Turbopack started the compiler but aborted with a fatal error:
⨯ ./node_modules/@prisma/client/default.js:2:6
Module not found: Can't resolve '.prisma/client/default'
1 | module.exports = {
> 2 | ...require('.prisma/client/default'),
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
3 | }
Import trace:
Edge Middleware:
./node_modules/@prisma/client/default.js
./src/lib/db-client.ts
./src/lib/auth.ts
./middleware.ts
Immediate resolution: regenerate the prisma client#
The rm -rf node_modules erased the generated artifact at node_modules/.prisma/client/. The @prisma/client npm package is an empty shell: the actual Query Engine binary and generated TypeScript types must be recreated locally:
# Clear aggressive Next.js 16 cache (Turbopack has its own cache in .next/cache)
rm -rf .next
# Regenerate the Prisma Client with Bun as executor
bun prisma generate
Success output:
Prisma schema loaded from prisma/schema.prisma
✔ Generated Prisma Client (v5.x.x) to ./node_modules/@prisma/client in 0.9s
bun prisma generate works identically to npx prisma generate or pnpm prisma generate - Bun serves as a binary package executor without additional configuration.
The architectural danger revealed by the import trace#
Beyond the immediate module error, the Import trace exposed a latent architectural problem:
Edge Middleware: ← Execution context: Edge Runtime (V8 Isolated)
./src/lib/db-client.ts ← PrismaClient being instantiated here
./src/lib/auth.ts ← auth.ts imports db-client.ts
./middleware.ts ← middleware.ts imports auth.ts
Next.js middleware.ts executes exclusively in the Edge Runtime, an isolated V8 environment without Node.js APIs (fs, net, child_process). The standard Prisma Client depends on a compiled Rust binary that communicates with the database via TCP, exactly the APIs absent in the Edge.
Behavior in local vs. production environments:
| Environment | Behavior |
|---|---|
next dev local | May work silently (Next.js partially simulates Edge in dev) |
| Vercel / Cloudflare Production | Guaranteed runtime failure with PrismaClient not supported in Edge |
Applied refactoring: Session validation logic in the middleware was moved to pure JWT verification (no database query), and heavy user queries were migrated to Server Components (layout.tsx) where the Node.js Runtime is available:
// middleware.ts BEFORE (problematic)
import { validateSession } from '@/lib/auth' // ← Drags PrismaClient into Edge
// middleware.ts AFTER (correct)
import { jwtVerify } from 'jose' // ← Pure cryptography, Edge-compatible
The speed triangle: why legacy hardware survived#
After bun prisma generate and server restart, the performance difference was immediately noticeable. HMR responded in under 200ms for component changes. The technical explanation for hardware with only 2 physical cores:
Vertex 1 - Bun (zig runtime)#
Node.js, by design, loads the V8 runtime with all its security validations, full JIT pipeline, and heavyweight initialization cycle. Bun, written in Zig, uses the JavaScriptCore engine (Safari/WebKit's engine) and eliminates entire layers of startup overhead:
Node.js startup sequence:
V8 heap initialization (~60-80ms)
→ Module resolver bootstrap
→ Native bindings loader
→ Event loop initialization
Total: ~150-300ms per child process
Bun startup sequence:
JavascriptCore initialization (~5-10ms)
→ Direct module resolution (no CommonJS negotiation)
→ Native API layer (Zig, no N-API ABI overhead)
Total: ~10-30ms per child process
On a dual-core where every ms of CPU is precious during incremental recompilation, the difference accumulates.
Vertex 2 - turbopack (Rust bundler)#
Webpack (Next.js default bundler before Turbopack) is single-threaded JavaScript. On a dual-core, it monopolizes one thread for module transformations while the other OS thread sits underutilized.
Turbopack is natively multithreaded in Rust, using rayon for work parallelism. On a dual-core + HyperThreading (4 logical threads), Turbopack distributes compilation so all 4 logical threads contribute:
Webpack on dual-core HT:
Thread 0 (physical): ████████████████ 100% bundle work
Thread 1 (physical): ████ ~25% (I/O wait)
HT threads: ░░░░░░░░ ~5% utilization
Turbopack on dual-core HT:
Thread 0 (physical): ████████████████ 100% (modules A-M)
Thread 1 (physical): ████████████████ 100% (modules N-Z)
HT threads: ██████████ ~65% (smaller tasks)
Turbopack RAM consumption for this project stayed between 800 MB to 1.4 GB, versus 2.1 to 3.2 GB for equivalent Webpack on the same project. With 10 GB of available RAM, the 1+ GB difference completely changes swapping dynamics (especially considering the vm.swappiness=10 adjustments already applied on this host).
Vertex 3 - prisma query engine (Rust binary)#
The Prisma Engine is natively compiled for the host architecture. On a 4th-gen Haswell i5, the libquery_engine-linux-musl.so.node binary executes queries on the processor without a JavaScript interpretation layer:
Pure JavaScript ORM (e.g., Knex + pg driver):
JS → V8 JIT → N-API bindings → TCP driver → database
Prisma Client:
JS → PrismaClient.findMany() → IPC → Query Engine (native Rust) → database
The difference is most noticeable in queries with complex joins or bulk mutations: Rust eliminates V8 Garbage Collector pressure during intensive database operations.
Compatibility audit: the 3 risk zones in package.json#
With the environment working, each heavy dependency was audited to classify the breakage risk on Bun:
Green zone - full compatibility#
@radix-ui/* → Pure JS/React, zero native bindings
lucide-react → SVG components, pure TypeScript
zod → Pure TypeScript, validation in pure JS
framer-motion → Pure JS with Web Animations API
@dnd-kit/core → Pure JS, pointer events API
@tiptap/react → Pure TypeScript
bcryptjs → Pure JS implementation (vs. `bcrypt` in C++ which would have risk)
tailwindcss v4 → CSS toolchain, CLI separate from runtime
Critical note on bcryptjs vs bcrypt: The deliberate choice of bcryptjs over bcrypt is architecturally correct for Bun compatibility. The bcrypt library (without "js") uses node-gyp to compile C++ bindings. Bun has partial compatibility with node-gyp, but results in intermittent failures on Haswell architectures. bcryptjs runs in pure JS - 3-4x slower in hash computation, but universally compatible.
Yellow zone - compatibility with caveats#
nodemailer → Uses Node.js `net` and `tls` modules
auth.js v5 → Uses Node `crypto` for session cookies
@clerk/nextjs → Heavy SDK, some modules with Node APIs
Bun implements a compatibility layer (Bun.polyfill) for Node.js net, tls, and crypto. In practice, SMTP email sending via nodemailer worked correctly in tests, but requires validation in a staging environment before going to production, especially with TLS connections where handshake behavior may differ.
Red zone - high breakage risk#
@react-pdf/renderer:
Risk: HIGH
Reason: Depends on `zlib` and `streams` with Node.js-specific behavior
Bun's streams implementation is partial (v1.1.x still has gaps)
Internal canvas rendering uses heavy `buffer` operations
Expected symptom: Timeout or buffer corruption in PDFs with images
Mitigation: Isolate PDF generation in an API Route with explicit runtime:
→ export const runtime = 'nodejs'
fabric (server-side canvas):
Risk: VERY HIGH
Reason: Depends on `node-canvas` which compiles C++ via node-gyp
node-canvas requires Cairo, Pango, libjpeg installed on the system
Bun's binding compilation for node-gyp is experimental
Expected symptom: Installation failure or SIGSEGVs at runtime
Mitigation: Use fabric exclusively client-side (browser) via dynamic import:
→ const { fabric } = await import('fabric') only in client components
Hybrid strategy: Bun for dev, Node.js for specific cases#
The operational conclusion is not "Bun or Node.js": it is Bun as default, Node.js as escape hatch:
# Daily development - Bun (maximum speed)
bun dev
bun add <package>
bun prisma generate
# Isolated test of problematic route (e.g., PDF generation)
NODE_OPTIONS='--max-old-space-size=4096' node --experimental-vm-modules \
node_modules/.bin/next dev -H 127.0.0.1 --port 3001
# CI/CD - keep Node.js as build runtime (more predictable)
{
"scripts": {
"dev": "bun run next dev",
"build": "node node_modules/.bin/next build",
"build:bun": "bun run next build",
"test:pdf": "node scripts/test-pdf-generation.js"
}
}
Observed metrics on legacy hardware#
| Operation | pnpm + Node.js | Bun + Turbopack | Gain |
|---|---|---|---|
install (clean) | ~45s | ~13s | 3.5x |
install (cached) | ~8s | ~4s | 2x |
dev startup | ~12s | ~4s | 3x |
| HMR (simple component) | ~800ms | ~150ms | 5x |
| HMR (page with heavy imports) | ~2.4s | ~600ms | 4x |
RAM idle (dev running) | ~2.8 GB | ~1.3 GB | -54% |
Was this article helpful?
Leave a quick reaction to help prioritize future technical guides:
This post is licensed under CC BY-NC.



Comments
Join the discussion below.
0 comments