The problem
If your site is "user types numbers → JavaScript computes a result", your HTML source probably looks like this:
<div id="result"></div>
<script src="calc.js"></script>
Users see nothing wrong. Crawlers download 30KB of HTML containing no answer at all.
Googlebot does execute JavaScript, but only when it decides the page is worth rendering — and the render queue can lag days to weeks. bingbot and most other crawlers do not execute JavaScript at all. They get the blank div, index it, and your page competes for "concrete volume calculator" with zero matching text.
I hit this building jianzhujisuan.com — 168 construction calculators, 380 static pages, every result computed in the browser.
The fix: don't solve it at runtime, solve it at build time
One extra build step: open each page in a real browser with default inputs, wait for the JS to finish, capture #result's innerHTML, write it back into the same container in the HTML file.
const { chromium } = require('playwright');
async function prerender(file) {
const page = await browser.newPage();
await page.goto('file://' + file); // local file, no server needed
await page.waitForSelector('#result');
// Note the (expr)() — see trap 1
const inner = await page.evaluate(`(${config.capture})()`);
injectIntoContainer(file, inner); // into the container, see trap 2
}
Output:
<div id="result">
<!-- prerender:start fp=a3f8c91b2e4d7a60 -->
<table><tr><td>Concrete volume</td><td>3.24 m³</td></tr></table>
<!-- prerender:end -->
</div>
For visitors: calc.js loads and overwrites #result's innerHTML — the static copy disappears, nothing changes for them.
For crawlers: no JS execution, but the table is right there in the source.
Single acceptance test — open the page with javaScriptEnabled: false:
const ctx = await browser.newContext({ javaScriptEnabled: false });
const page = await ctx.newPage();
await page.goto(url);
console.log(await page.textContent('#result')); // must contain real numbers
Trap 1: page.evaluate("() => x") evaluates, doesn't call
I used a "formula fingerprint" to decide whether a page needs re-rendering — hash the compute function's source, compare against a stored fingerprint, skip if unchanged.
// WRONG
const fp = await page.evaluate('() => window.CALC.fp');
The fingerprint came back empty every time. When page.evaluate receives a string, it evaluates the expression and returns the resulting function object — it never calls it.
function callExpr(expr) {
return `(${expr})()`; // becomes (() => x)()
}
const fp = await page.evaluate(callExpr('() => window.CALC.fp'));
The same trap exists in waitForFunction, and it's worse there: a string that evaluates truthy means you waited for nothing and captured a half-rendered result.
Trap 2: injecting before </body> shows users two copies
My first version inserted the block before </body>. Locally, with JS enabled, users saw two result tables — the static one sat outside #result, so the live innerHTML overwrite never removed it.
You have to splice into the container's interior, matching tag depth:
function spliceInner(html, id, inner) {
const open = new RegExp(`<div[^>]*id=["']${id}["'][^>]*>`);
const m = open.exec(html);
if (!m) return null;
let i = m.index + m[0].length;
let depth = 1;
const tagRe = /<div\b|<\/div>/g;
tagRe.lastIndex = i;
let t;
while ((t = tagRe.exec(html))) {
depth += t[0] === '</div>' ? -1 : 1;
if (depth === 0) return html.slice(0, i) + inner + html.slice(t.index);
}
return null;
}
Now the static block lives inside #result and gets replaced along with everything else.
Trap 3: re-running stacks blocks
A build step must be idempotent. Inserting before </body> gives you three copies after three runs. Wrap the block in markers and replace on re-run:
const BLOCK_RE = /<!--\s*prerender:start[^>]*-->[\s\S]*?<!--\s*prerender:end\s*-->/;
const after = BLOCK_RE.test(before)
? before.replace(BLOCK_RE, () => block)
: inject(before, block);
Second run should report 0 rendered, 1 unchanged. If every run reports "all rendered", your fingerprint is empty — go back to trap 1.
One more thing: lastmod ordering
If your sitemap carries lastmod, the prerender injection must run before lastmod is computed.
Otherwise adding prerender marks all 380 pages as "modified today". Run it again the next day — all 380 pages modified again. From the crawler's perspective your site claims a full-site rewrite every single day, which destroys your sitemap's credibility.
If you must run it daily, strip the injected block from the content fingerprint — the block changed, the page's substance didn't.
Results
380 pages, on a single reused browser instance:
- Build time: noticeably longer, but it's a one-off cost with a reused single browser instance — never in the user request path
- Every page returns its full result table with JS disabled
- Median real body content: 1,655 CJK characters (was ~400 characters of template skeleton)
What prerendering does not fix
It's worth being blunt here. Prerendering fixes "can the crawler see the content". It does not fix "does the search engine want to index you".
I assumed fixing the blank-div problem would fix indexing. It didn't. Two weeks later, bingbot had fetched 2 pages out of 380 and indexed zero. The reason had nothing to do with my HTML — it was a new domain with zero inbound links, and no crawler algorithm allocates crawl budget for a site nobody mentions.
Content visibility and site trust are two separate problems. Prerender solves the first one properly. The second one is a marketing problem, and you solve it by writing things people link to.
Code and verification
The code: calc-prerender — MIT licensed, ~180 lines of Node, a runnable example, and the three traps written up in the README. It is not a demo: this is the script that runs in the production build.
Production case: jianzhujisuan.com — 168 bilingual construction calculators, 380 pages, all built this way. The whole implementation (~180 lines) is what runs in that build pipeline.
Quick verification: open a page like brick count calculator, disable JavaScript, refresh. If the result table is still there, it worked. If you see an empty div, prerendering never ran.
Top comments (1)
This is a really practical angle — making the default result visible in the source feels like the simplest win for calculator pages. Thanks for spelling out the idempotency trap too.