The perf tool, when pointed at a Node.js process, can give you a surprisingly low-level view of V8’s Just-In-Time (JIT) compilation, revealing how your JavaScript is being translated into machine code.

Let’s see it in action. First, you need a Node.js application. For this example, we’ll use a simple one that does some heavy computation:

// heavy_computation.js
function fibonacci(n) {
  if (n <= 1) {
    return n;
  }
  return fibonacci(n - 1) + fibonacci(n - 2);
}

let start = Date.now();
fibonacci(40); // A moderately large number to trigger JIT
let end = Date.now();
console.log(`Fibonacci took ${end - start}ms`);

Now, let’s run this script and profile it with perf. We’ll need to know the process ID (PID) of the Node.js application.

node heavy_computation.js &
NODE_PID=$!
echo "Node.js PID: $NODE_PID"

Once you have the PID, you can start recording performance data. The key is to tell perf to record V8 JIT symbols. This is often done by enabling perf_event_paranoid to a level that allows it, and then using specific perf options.

sudo sysctl kernel.perf_event_paranoid=1
sudo perf record -F 99 -g -p $NODE_PID --call-graph dwarf --v8-perf

Let’s break down those perf record options:

  • -F 99: Sample at 99 Hertz (99 times per second). Higher frequency means more detail but more overhead.
  • -g: Record call graphs (stack traces). Essential for understanding function calls.
  • -p $NODE_PID: Profile the specific Node.js process ID.
  • --call-graph dwarf: Use DWARF debugging information for call graphs. This is crucial for getting accurate stack traces from native code and JIT-compiled code.
  • --v8-perf: This is the magic flag that tells perf to look for and interpret V8’s JIT-compiled code. Without it, you’d just see generic C++ or assembler.

After perf record finishes (you’ll likely need to Ctrl+C it after it runs for a bit), you can view the results:

sudo perf report

This will open an interactive TUI. Navigate through the functions. You’ll start seeing functions like fibonacci from your JavaScript code, but also a lot of V8 internal functions like v8::internal::OptimizedCodeGenerator::GenerateCode and functions with names that look like v8::internal::Builtin_.... These represent the machine code V8 generated for your JavaScript.

The real power comes from looking at the call graphs for these JIT-compiled functions. When you select a JIT-compiled function and press Enter, you’ll see the stack trace. This allows you to trace execution from your JavaScript function down into the highly optimized machine code that V8 produced.

The problem this whole system solves is understanding why your JavaScript is slow, beyond just identifying the JavaScript function. It answers: "Is my JavaScript function slow, or is the machine code V8 generated for it inefficient, or is it spending time in V8’s internal machinery?" perf with --v8-perf bridges the gap between high-level JavaScript and low-level machine code execution, providing a unified view.

Internally, --v8-perf works by instructing perf to look for specific memory regions that V8 marks as containing JIT-compiled code. When perf samples within these regions, it can then use V8’s internal metadata (often exposed via debug symbols or specific V8 tracing mechanisms) to map those machine code addresses back to the original JavaScript source code or V8 internal operations. The -g and --call-graph dwarf options ensure that perf can traverse the stack frames correctly, even across the boundary of interpreted and JIT-compiled code.

The exact levers you control are primarily sampling frequency (-F), call graph generation method (-g, --call-graph), and ensuring perf has the necessary permissions (kernel.perf_event_paranoid). The --v8-perf flag itself is a direct instruction to perf to engage with V8’s JIT output.

One thing most people don’t realize is that perf can also show you unoptimized code generation paths. If V8 decides not to optimize a piece of JavaScript (perhaps because it’s rarely called or has complex deoptimization triggers), perf might show you samples within V8’s interpreter or baseline compiler. This can be a clue that your code isn’t benefiting from V8’s full optimization potential.

The next thing you’ll likely encounter when diving deeper is understanding how to interpret the V8 internal functions that appear in your perf reports, especially those related to garbage collection or speculative optimization.

Want structured learning?

Take the full Perf course →