HomeBlogThe A* Blowup That Upstream Still Has and the Fork Does Not

The A* Blowup That Upstream Still Has and the Fork Does Not

October 12, 2026

On 2026-10-10 dynaum added a comment to upstream lukilabs/beautiful-mermaid#64: a flowchart that is nothing but a chain of 25 edges makes renderMermaidASCII run for about 12 seconds and then throw RangeError: Map maximum size exceeded. At 24 edges it renders in 0.08 s. The issue was filed in March for a different input, a dense top-down graph with several fan-in bundles, and this was a second trigger with no bundles at all.

The generator is short enough to quote:

const chain = (n: number) =>
  'flowchart TD\n' +
  Array.from({ length: n }, (_, i) => `  N${i} --> N${i + 1}`).join('\n')

renderMermaidASCII(chain(24)) // renders
renderMermaidASCII(chain(25)) // upstream 1.1.3: throws

I ran it before deciding what to write. The short version is that it does not reproduce in this fork, upstream still has it, and the fix is a 12-line change that was proposed upstream seven months ago.

What I measured

All of this is one laptop, Node v22.22.1, one run per cell. Single runs, so differences of a few milliseconds are noise. The upstream column is beautiful-mermaid@1.1.3 installed from npm; the fork column is current main (5.0.1).

Direction Edges Upstream 1.1.3 zombie-mermaid 5.0.1
TD 24 renders, 15 ms renders, 15 ms
TD 25 RangeError: Map maximum size exceeded after 18.7 s renders, 5 ms
LR 24 renders, 11 ms renders, 5 ms
LR 25 RangeError: Map maximum size exceeded after 20.2 s renders, 3 ms

dynaum measured about 12.5 s for the TD case; mine is slower, with the same failure. I also ran the fork at 40 and 60 edges in both directions and it renders each in under 100 ms. I did not run those on upstream.

The cliff at exactly 25 edges is real on upstream, and I do not know why the threshold lands there. I did not look into the layout to explain it. What I can say is where it goes away.

Finding the commit

Rather than guess which of our fixes was responsible, I bisected between upstream's 1.1.3 and the fork's main, with "throws or takes more than 30 seconds" as the old behaviour and "renders cleanly" as the new one. About eleven steps, no commits skipped. The first commit that renders the chain is bc455262, merged as #52 on 2026-08-25:

Commit Result on the 25-edge TD chain
upstream 1.1.3 hit the 30 s kill timer
a midpoint before the fix RangeError: Map maximum size exceeded after 23.1 s
parent of bc455262 hit the 30 s kill timer
bc455262 renders in 74 ms
current main renders in 15 ms

The times are longer than the table above because the bisect harness runs the renderer through tsx in a child process, and I only care about the broken-versus-fixed boundary there, not the absolute numbers.

What the fix is

The ASCII renderer routes edges with an A* search over a character grid (getPath() in the pathfinder). Before bc455262 that loop had no bound. If the destination was not reachable through free cells, the search kept expanding the open set and its cameFrom map until V8's Map size limit stopped it, which is the error in the report.

The change adds a counter to the loop:

const MAX_ITERATIONS = 50_000
// ...
if (++iterations > MAX_ITERATIONS) {
  return null
}

null already meant "no path found" to the callers, which fall back to a simpler route, so no caller changed. The commit also adds a 158-line regression test for the dense fan-in graph from the original report.

The change is not our idea. It is a port of lukilabs/beautiful-mermaid#66 by @ktrysmt, who also filed #64, and whose PR adds the same MAX_ITERATIONS = 50_000 guard and a test file. #66 was opened on 2026-03-18, the same day as the issue. As of 2026-10-12 it is still open, and so is #64.

What the cap does not do

The cap is per call. A graph with many edges can still spend up to 50,000 iterations on each of them, which adds up. That is what #89 addressed on 2026-08-26: its description says the per-call cap from #52 could still exhaust the heap across many calls on a dense graph, and adds a render-wide iteration budget shared by every getPath call, plus a shortcut that skips A* when a direct route is unobstructed. A later fix, #1481, makes the search fail fast when the target cell is sealed instead of flooding the open area up to the cap.

I have not measured how much each of those matters for the chain case. The bisect says #52 alone is what moves the 25-edge chain from throwing to rendering. #89 and #1481 matter for the dense graphs and for speed, and I make no claim about them here.

I also have not checked the original report's edge-bundle inputs on upstream with #66 applied. All I tested was the plain chain.

What this means for someone hitting it

  • If you call renderMermaidASCII on upstream 1.1.3 with generated or user-supplied diagrams, a 25-edge chain is enough to lock the process for about 20 seconds and then throw. Terminal tools with a timeout lose the whole document. dynaum works around it by drawing in a child process with a deadline.
  • The fork's renderMermaidASCII is the same entry point under the package name zombie-mermaid. On the inputs above it returns in milliseconds.
  • If you find an input that is slow or throws on the fork, open an issue with the Mermaid source.

Corrections

  • My first reply to dynaum on #64 said I could not say which change made the difference. That was true when I wrote it, before the bisect. A follow-up comment on #64 gives the commit and links #66.