Skip to content
Consultation

Code Blocks Rendering Twice: How a JavaScript Regex Can Race

A double code block bug on the blog: a module-level /g regex's lastIndex gets clobbered across parallel async calls. The fix is eight lines.

Adityo Guni Waluyo3 min read

Code blocks rendering twice

A fresh article had just gone live, I opened the page, and the first code block appeared twice. Not exactly a double render, worse: one block showed up fine in its editor window, then a raw copy of it sat right below. The HTML segments around it were sliced at almost random points. Refresh once, sometimes gone. Refresh again, back on a different block.

For context: this blog highlights every code block server-side with Shiki. The highlightCodeBlocks function slices the article HTML, finds each <pre><code>, and swaps in the highlighted version. At the time, the finder regex was declared at module level:

// shared across the whole process — one regex object
const CODE_RE =
  /<pre><code class="language-([^"]+)">([\s\S]*?)<\/code><\/pre>/g;

// each article runs this in parallel:
while ((m = CODE_RE.exec(html)) !== null) { ... }

My first suspect: cache. Sitemaps and pages on this blog revalidate every ±120 seconds, and the bug came and went, so caching was the natural first theory. Waited for the cache to expire, tested again, still broken. Only then did the pattern click: it only ever happened while several articles were being rendered together.

That's exactly where the problem lives. JavaScript regexes are stateful when used with the g flag. Every exec() stores the last position in the lastIndex property, and the next call resumes from there, not from the start. This behavior is documented on MDN. A module-level regex object means one lastIndex shared by every caller.

And highlightCodeBlocks is async, run through Promise.all across multiple blocks at once. Article A calls exec(), gets a match, lastIndex moves to, say, 800. Before it can continue, article B (a different call, same regex) runs exec() from position 800 on its own string. Both overwrite each other's trail. The result: blocks get skipped, segments get cut at the wrong offsets, or one match gets read twice and pushed to the output array twice.

A textbook race condition. The only contested resource is a single small number inside a regex object.

The fix that feels almost too small

Make the regex local per call instead of shared:

// keep only the source at module level
const CODE_RE_SRC =
  '<pre><code class="language-([^"]+)">([\\s\\S]*?)<\\/code><\\/pre>';

function highlightCodeBlocks(html) {
  // fresh instance per call — lastIndex is private
  const codeRe = new RegExp(CODE_RE_SRC, "g");
  while ((m = codeRe.exec(html)) !== null) { ... }
}

Eight lines changed. The module-level regex becomes an inert source string, and a fresh RegExp instance is created locally each time the function runs. No locks, no queues, no library. Two parallel calls each hold their own object, and therefore their own lastIndex.

What makes this bug funny: the code looked correct. A while loop plus exec() is the standard pattern for iterating matches, exactly like MDN's own examples. The problem is the pattern was written for synchronous code, then wrapped in async without anyone noticing the regex carried state. The failure was inconsistent too: one article rendering alone, fine. Two articles rendering at the same moment, broken. That's why it sailed through early manual testing.

The lesson I keep: a regex with the g flag is a shared resource. In a module that can be called in parallel, declaring it at the top scope is no different from a global variable being overwritten with no queue. If you want to avoid the allocation, keep just the source string and let the cheap new RegExp pay a tiny cost per call. That cost is far cheaper than a bug that only shows up when two pages happen to render close together.