Flame Graph for Performance Profiling — Highcharts

A flame graph visualizes hierarchical call stack data from performance profiling, where each horizontal bar represents a function in the call stack and its width is proportional to the time (or samples) spent in that function. Stacks are layered bottom-to-top showing caller-to-callee relationships. Invented by Brendan Gregg, flame graphs are the standard visualization for identifying CPU bottlenecks and hot code paths across all major programming languages and profiling tools.

Flame Graph for Performance Profiling rendered with Highcharts

Renders

JavaScript source (Highcharts)

// anyplot.ai
// flamegraph-basic: Flame Graph for Performance Profiling
// Library: highcharts 12.6.0 | JavaScript 22.23.2
// Quality: 94/100 | Created: 2026-08-20

const t = window.ANYPLOT_TOKENS;

// --- Data: folded call-stack samples (self-time per stack frame) -----------
// Each row is the *own* (exclusive) sample count captured for that exact
// stack path — the standard "folded stack" input format flame graphs use.
const samples = [
  { stack: "main", value: 25 },
  { stack: "main;acceptConnections", value: 15 },
  { stack: "main;acceptConnections;tlsHandshake", value: 8 },
  { stack: "main;acceptConnections;tlsHandshake;certValidation", value: 12 },
  { stack: "main;acceptConnections;tlsHandshake;cipherNegotiation", value: 10 },
  { stack: "main;acceptConnections;parseHeaders", value: 45 },
  { stack: "main;acceptConnections;readSocketBuffer", value: 10 },
  { stack: "main;acceptConnections;readSocketBuffer;parseFrameHeader", value: 18 },
  { stack: "main;acceptConnections;readSocketBuffer;bufferAlloc", value: 14 },
  { stack: "main;routeRequest", value: 12 },
  { stack: "main;routeRequest;authMiddleware", value: 20 },
  { stack: "main;routeRequest;authMiddleware;verifyToken", value: 35 },
  { stack: "main;routeRequest;authMiddleware;loadUserSession", value: 28 },
  { stack: "main;routeRequest;rateLimitCheck", value: 10 },
  { stack: "main;routeRequest;rateLimitCheck;checkBucket", value: 16 },
  { stack: "main;routeRequest;rateLimitCheck;updateBucket", value: 12 },
  { stack: "main;routeRequest;handleApi", value: 8 },
  { stack: "main;routeRequest;handleApi;validateInput", value: 10 },
  { stack: "main;routeRequest;handleApi;validateInput;schemaCheck", value: 30 },
  { stack: "main;routeRequest;handleApi;validateInput;sanitizeFields", value: 22 },
  { stack: "main;routeRequest;handleApi;queryDatabase", value: 6 },
  { stack: "main;routeRequest;handleApi;queryDatabase;acquireConnection", value: 14 },
  { stack: "main;routeRequest;handleApi;queryDatabase;executeQuery", value: 20 },
  { stack: "main;routeRequest;handleApi;queryDatabase;executeQuery;planQuery", value: 55 },
  { stack: "main;routeRequest;handleApi;queryDatabase;executeQuery;fetchRows", value: 40 },
  {
    stack: "main;routeRequest;handleApi;queryDatabase;executeQuery;fetchRows;deserializeRow",
    value: 65,
  },
  {
    stack: "main;routeRequest;handleApi;queryDatabase;executeQuery;fetchRows;rowToObject",
    value: 48,
  },
  { stack: "main;routeRequest;handleApi;queryDatabase;parseResults", value: 70 },
  { stack: "main;routeRequest;handleApi;cacheLookup", value: 5 },
  { stack: "main;routeRequest;handleApi;cacheLookup;cacheHit", value: 18 },
  { stack: "main;routeRequest;handleApi;cacheLookup;cacheMiss", value: 24 },
  { stack: "main;routeRequest;handleApi;businessLogic", value: 8 },
  { stack: "main;routeRequest;handleApi;businessLogic;computeDiscounts", value: 42 },
  { stack: "main;routeRequest;handleApi;businessLogic;applyTaxRules", value: 33 },
  { stack: "main;routeRequest;handleApi;businessLogic;aggregateTotals", value: 27 },
  { stack: "main;routeRequest;handleApi;serializeResponse", value: 12 },
  { stack: "main;routeRequest;handleApi;serializeResponse;jsonEncode", value: 38 },
  { stack: "main;routeRequest;handleApi;serializeResponse;compressGzip", value: 30 },
  { stack: "main;routeRequest;handleStatic", value: 6 },
  { stack: "main;routeRequest;handleStatic;readFile", value: 44 },
  { stack: "main;routeRequest;handleStatic;setCacheHeaders", value: 9 },
  { stack: "main;backgroundJobs", value: 10 },
  { stack: "main;backgroundJobs;garbageCollect", value: 20 },
  { stack: "main;backgroundJobs;garbageCollect;markPhase", value: 32 },
  { stack: "main;backgroundJobs;garbageCollect;sweepPhase", value: 28 },
  { stack: "main;backgroundJobs;logFlush", value: 15 },
  { stack: "main;backgroundJobs;metricsExport", value: 5 },
  { stack: "main;backgroundJobs;metricsExport;pushToStatsd", value: 12 },
  { stack: "main;backgroundJobs;metricsExport;formatMetrics", value: 10 },
  { stack: "main;logRequest", value: 6 },
  { stack: "main;logRequest;formatLogLine", value: 8 },
  { stack: "main;logRequest;formatLogLine;escapeChars", value: 11 },
  { stack: "main;logRequest;writeToDisk", value: 7 },
  { stack: "main;logRequest;writeToDisk;flushDisk", value: 13 },
  { stack: "main;logRequest;writeToDisk;fsync", value: 9 },
];

// --- Build the call tree: own time per node, then roll up totals -----------
const root = { name: "main", children: {}, own: 0 };
for (const { stack, value } of samples) {
  const parts = stack.split(";");
  let node = root;
  for (let i = 1; i < parts.length; i++) {
    const name = parts[i];
    if (!node.children[name]) node.children[name] = { name, children: {}, own: 0 };
    node = node.children[name];
  }
  node.own += value;
}

let maxDepth = 0;
const rollUp = (node, depth) => {
  maxDepth = Math.max(maxDepth, depth);
  const kids = Object.values(node.children);
  node.total = node.own + kids.reduce((sum, kid) => sum + rollUp(kid, depth + 1), 0);
  return node.total;
};
rollUp(root, 0);
const rootTotal = root.total;

// --- Flatten the tree into left-to-right stacked bars, one series per frame
// A stacked bar chart tiles series strictly by declaration order per row, so
// a depth-first walk reproduces the classic flame-graph layout. Any node that
// doesn't fully cover its own width in the row below it (self time, or a
// leaf ending before the deepest row) needs an invisible spacer series to
// keep every deeper row aligned under the correct parent.
const frames = [];

const padDown = (value, depth) => {
  if (depth > maxDepth) return;
  frames.push({ depth, value, name: null, real: false });
  padDown(value, depth + 1);
};

const walk = (node, depth) => {
  frames.push({ depth, value: node.total, name: node.name, real: true });
  const kids = Object.values(node.children).sort((a, b) => a.name.localeCompare(b.name));
  if (kids.length) {
    for (const kid of kids) walk(kid, depth + 1);
    if (node.own > 0) padDown(node.own, depth + 1);
  } else if (node.total > 0) {
    padDown(node.total, depth + 1);
  }
};
walk(root, 0);

// --- Color: conventional warm flame-graph palette, built from Imprint's ----
// warm-family anchors only (amber -> ochre -> matte-red). The spec calls for
// yellows/oranges/reds by domain convention (Brendan Gregg's original flame
// graph aesthetic), which is the semantic-exception case in the style guide
// — so frames don't follow the usual "first series is brand green" rule.
const warmStops = [t.amber, t.palette[3], t.palette[4]];
const lerp = (hexA, hexB, f) => {
  const a = Highcharts.color(hexA).rgba;
  const b = Highcharts.color(hexB).rgba;
  const mix = [0, 1, 2].map((i) => Math.round(a[i] + (b[i] - a[i]) * f));
  return `rgb(${mix[0]}, ${mix[1]}, ${mix[2]})`;
};
const hash = (str) => {
  let h = 0;
  for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
  return (h % 1000) / 1000;
};
// Blend two signals into the ramp position: `heat` (log-scaled share of total
// samples) pulls hot frames toward the matte-red end, adding a second layer of
// storytelling beyond bar width; `hash(name)` keeps same-share siblings visually
// distinct instead of collapsing them onto one shade.
const warmColor = (name, share) => {
  const heat = Math.min(1, Math.log1p(share * 12) / Math.log1p(12));
  const f = heat * 0.65 + hash(name) * 0.35;
  return f < 0.5 ? lerp(warmStops[0], warmStops[1], f * 2) : lerp(warmStops[1], warmStops[2], (f - 0.5) * 2);
};

const categories = Array.from({ length: maxDepth + 1 }, (_, i) => (i === 0 ? "Depth 0 · root" : `Depth ${i}`));

const series = frames.map((frame) => {
  const data = new Array(categories.length).fill(null);
  data[frame.depth] = frame.value;
  const widthFraction = frame.value / rootTotal;
  return {
    type: "bar",
    name: frame.real ? frame.name : undefined,
    data,
    stack: "flame",
    color: frame.real ? warmColor(frame.name, frame.value / rootTotal) : "transparent",
    borderWidth: frame.real ? 1 : 0,
    borderColor: t.pageBg,
    enableMouseTracking: frame.real,
    showInLegend: false,
    // Fixed dark ink, not theme-adaptive: the label is only ever shown once
    // the post-render fit check below (chart.events.render) confirms it sits
    // fully inside the warm data fill, which is constant across themes — it
    // never sits on the theme-flipped page background/spacer border.
    dataLabels: {
      enabled: frame.real && widthFraction > 0.02,
      inside: true,
      align: "center",
      verticalAlign: "middle",
      format: frame.name,
      style: { color: "#1A1A17", fontSize: "13px", fontWeight: "500", textOutline: "none" },
    },
  };
});

// Highcharts doesn't hide inside bar/column labels that are wider than their
// own point box — it just lets them overflow into whatever sits next door
// (here, the transparent spacer/border, which renders as the theme's page
// background). A width-fraction heuristic can't predict that reliably because
// label pixel width depends on the actual rendered glyphs. Instead, measure
// each label against its bar's real rendered box after every render and hide
// any label that doesn't fit — the only way to guarantee it never bleeds past
// the warm fill it depends on for contrast.
// `type: "bar"` charts render inverted: each point's SVG rect keeps its
// pre-rotation local coordinates, so `getBBox().width` is the constant row
// thickness and the bar's actual on-screen length is `getBBox().height`.
const FIT_PADDING = 12;
function hideOverflowingLabels() {
  for (const s of this.series) {
    for (const point of s.points) {
      if (!point.dataLabel || !point.graphic) continue;
      const fits = point.dataLabel.getBBox().width + FIT_PADDING <= point.graphic.getBBox().height;
      point.dataLabel.attr({ visibility: fits ? "inherit" : "hidden" });
    }
  }
}

// --- Chart -------------------------------------------------------------
Highcharts.chart("container", {
  chart: {
    type: "bar",
    backgroundColor: "transparent",
    animation: false,
    style: { fontFamily: "inherit" },
    events: { render: hideOverflowingLabels },
  },
  credits: { enabled: false },
  title: {
    text: "flamegraph-basic · javascript · highcharts · anyplot.ai",
    style: { color: t.ink, fontSize: "22px", fontWeight: "600" },
  },
  subtitle: {
    text: "CPU profile of a request-handling call stack — bar width is share of total samples",
    style: { color: t.inkSoft, fontSize: "14px" },
  },
  xAxis: {
    categories,
    reversed: false,
    lineColor: t.inkSoft,
    tickColor: t.inkSoft,
    labels: { style: { color: t.inkSoft, fontSize: "14px" } },
    title: { text: "Call stack depth", style: { color: t.inkSoft, fontSize: "16px" } },
  },
  yAxis: { visible: false, min: 0, max: rootTotal, reversed: true },
  legend: { enabled: false },
  plotOptions: {
    series: { animation: false, stacking: "normal", pointPadding: 0.02, groupPadding: 0, borderRadius: 0 },
  },
  tooltip: {
    backgroundColor: t.elevatedBg,
    borderColor: t.grid,
    style: { color: t.ink, fontSize: "14px" },
    formatter() {
      const pct = ((this.y / rootTotal) * 100).toFixed(1);
      return `<b>${this.series.name}</b><br/>${this.y.toLocaleString()} samples (${pct}%)`;
    },
  },
  series,
});

Part of Flame Graph for Performance Profiling on anyplot.ai.

Other implementations