DEV Community

Cover image for Prerender is gone — a Playwright-based replacement
Luka Požega
Luka Požega

Posted on

Prerender is gone — a Playwright-based replacement

github.com/prerender/prerender returns a 404. The repository is gone.

The npm package still resolves and still installs — last published September 2024, and still pulling close to 25,000 downloads a month — so existing deployments keep running and fresh installs keep working. What's gone is the path to a fix. No repository means no security patches, no issue tracker, and no way to close the one crash-recovery gap in its own design: if Chrome dies twice within about a second, the old server calls process.exit() and relies on an external supervisor to bring it back, with no retry loop of its own.

So I wrote renderready, a self-hosted prerendering server built on Playwright and headless Chromium. Two runtime dependencies, TypeScript throughout, and a migration guide from the old server that is mostly a URL change and a handful of renames.

It didn't start as a package. The rendering core came out of a production deployment that serves over 200,000 pages a day, and renderready is that core extracted, cleaned up and published on its own. The deployment doesn't run the package verbatim — it wraps the same renderer in infrastructure I'll come back to at the end, because the deployment shape matters more than the package does.

npm install renderready
npx playwright install chromium
Enter fullscreen mode Exit fullscreen mode
import { start } from 'renderready';

await start();
Enter fullscreen mode Exit fullscreen mode
curl 'http://localhost:3000/render?url=https%3A%2F%2Fexample.com%2F'
Enter fullscreen mode Exit fullscreen mode

That's the whole quickstart. The rest of this post is why the interesting parts work the way they do.

The short version, if you're deciding whether to keep reading:

  • What it does. Takes a client-rendered page — React, Angular, Vue, anything that builds its DOM in the browser — runs it in headless Chromium, and hands back the finished HTML. Crawlers that don't execute JavaScript get your actual content instead of an empty <div id="app"></div>.
  • What it replaces. prerender/prerender (repository gone), Rendertron (archived 2022), Rendora (archived 2025).
  • What it costs you. Two runtime dependencies, MIT, Node 22.12+, three functions and a CLI.
  • Migrating from the old server is a URL change and a handful of renames.
  • What it isn't. Not a hosted service, not a cache, not an SSR framework, and not a reason to skip SSR if SSR is available to you.

Serving a client-rendered app to crawlers, in 2026

If you're picking an architecture today, prerendering is not your first choice and I'm not going to pretend otherwise. Server-side rendering or static generation gives crawlers and users the same HTML, which is strictly better than maintaining a second rendering path. Google says as much in its own docs: dynamic rendering is a bridge, not a destination.

Prerendering earns its place when you can't take that route:

  • A large Angular or Vue SPA that predates the frameworks that made SSR easy, where a rewrite is a quarter of engineering time nobody has approved.
  • A build pipeline you don't own, where "add SSR" means renegotiating with another team.
  • Crawlers that don't execute JavaScript at all — which, as of the last two years, is most of the ones that are new. Vercel's analysis of over 500 million GPTBot fetches found no evidence of JavaScript execution, and the same holds for ClaudeBot, PerplexityBot and Bytespider: they read the raw HTML and leave. Googlebot, Gemini and AppleBot render. Nothing else does. SSR fixes this too, obviously — prerendering fixes it without a rewrite.
  • Social crawlers. Even a site with perfect SSR often wants a renderer for Open Graph consumers that execute no JavaScript at all.
  • Scraping and content extraction, which has nothing to do with SEO but needs exactly the same machinery.

If none of that describes you, use Next.js or Nuxt and close the tab. If some of it does, you need a renderer, and the options have quietly rotted.

Alternatives to prerender in 2026, compared

Option Status Notes
prerender/prerender Repository gone (404) npm package still installs; no patches, no issues, no fixes
Rendertron Archived October 2022 Google's own; README says deprecated
Rendora Archived January 2025 Read-only. 2k stars, and the last release was December 2018
prerender.io Commercial From $49/month; hosted, so no infrastructure to run
renderready New What this post is about

Every self-hosted option in that table is either archived or has had its repository deleted. Rendora is the clearest illustration: 2,000 stars, a genuinely good architecture, one release in 2018, archived by its owner seven years later. The category didn't get solved — it got abandoned, because the frameworks moved on and everyone assumed the problem went with them.

It didn't. The apps that needed prerendering in 2019 are still in production, and they still need it.

The other thing the survivors have in common is that they wrap Chrome over hand-rolled DevTools Protocol calls. Playwright already solved browser lifecycle management properly, and building on it removes most of what made the old servers fragile.

The hard part: knowing when the page is done

Everything else in a prerender server is plumbing. This is the actual problem.

You navigate to a URL. At some point the app has fetched its data, rendered its DOM, and the HTML is worth capturing. Capture too early and you serialize a spinner. Capture too late and every render costs you the full timeout.

The obvious answer is Playwright's networkidle, and it's a trap. Any app with long-polling, streaming, a keep-alive connection, a live chat widget, or an analytics beacon on an interval never reaches network idle. Using it doesn't make renders slow — it pins every single render at the timeout ceiling. The standard advice you'll find is "wait for a specific element instead," which is fine advice for a test suite and useless here, because you're rendering pages you don't control and don't have a selector for.

renderready uses two signals.

Network quiet. No requests in flight, and none started or settled for waitAfterLastRequest — 500ms by default. This is the only signal available for a site you haven't instrumented, so it's the fallback rather than the exception. WebSocket and EventSource requests are excluded from the in-flight count outright, because they never finish and counting them means never going quiet.

window.renderReady. If your page defines it as a boolean, it wins:

<script>
  window.renderReady = false;
</script>
Enter fullscreen mode Exit fullscreen mode
// …once your data has loaded and the DOM is final:
window.renderReady = true;
Enter fullscreen mode Exit fullscreen mode

Nothing is captured until it turns true. Once it does, capture happens as soon as the network goes quiet or renderReadyDelay (1000ms) elapses, whichever comes first — so an app that knows it's finished can cut a render short instead of waiting out its own trailing analytics requests. Never define the flag and nothing breaks; network quiet handles it.

And a timeout is not an error. You get whatever had rendered, timedOut: true, and an x-renderready-timed-out header, because a partial capture is usually more useful to a crawler than a 504.

Status codes, which crawlers actually care about

A prerenderer that returns 200 for everything quietly poisons your index. A soft 404 gets indexed as a real page; a moved URL never updates. So the response carries the origin's status code, and the page can override it from <head>:

<!-- serve this route as a 404 so it is not indexed -->
<meta name="renderready-status-code" content="404" />
Enter fullscreen mode Exit fullscreen mode
<!-- serve this route as a redirect -->
<meta name="renderready-status-code" content="302" />
<meta name="renderready-header" content="Location: https://example.com/new" />
Enter fullscreen mode Exit fullscreen mode

Read from <head> only, so body content can't spoof a status code, and stripped from the output.

Redirects are not followed by default. A crawler needs to see the 301 or 302 to update its index, so the 3xx and its Location come straight back without ever fetching the destination. Pass followRedirects=true when you want the destination rendered instead.

What it deliberately doesn't do

Worth stating plainly, because a package's omissions tell you more than its features.

No cache. What to key on, how long to keep it, and where to put it are your decisions. For a real deployment an HTTP cache in front — Varnish, nginx, a CDN — keyed on the full URL beats anything I could bundle. Two hooks are enough if you want one in-process anyway.

No concurrency limiter. Renders run in parallel, each in its own browser context, bounded by what Chromium tolerates. Rate limiting and back-pressure belong in front of the service, where you can size them against your actual traffic. (recycleAfterRenders and recycleAfterMs are not throughput controls — they exist because long-lived Chromium leaks memory, and periodically relaunching it is the cheapest fix. A recycle drains in-flight renders first.)

No crawler detection. Something in front of the service still has to decide which requests get prerendered — a user-agent check in your own server, an nginx map, a CDN rule, or prerender-node, which is still maintained and only needs its URL pointed at your instance. renderready renders whatever you hand it and never inspects the requesting client, because the user-agent list you want depends on which crawlers you care about, and that list changes more often than a package publishes.

No catch-all GET /<url> route. The old server let the whole request path be the target URL. It's ambiguous, and it makes every malformed request look like a render attempt. Use /render?url=, or POST /render with a JSON body when percent-encoding is awkward.

No screenshots, PDF, or HAR. Out of scope for v1. createRenderer() gives you the render loop with no HTTP layer, and Playwright makes them easy to add on top.

That list reads like a lot of missing features until you see what it looks like assembled.

What 200,000 pages a day actually looks like

The deployment this came out of wraps the renderer in three things the package deliberately doesn't ship:

Kafka in front. Crawl traffic is bursty and rendering is expensive, so render requests become messages on a topic rather than blocking HTTP calls. Consumers pull at whatever rate the renderer fleet can actually sustain, and a traffic spike grows consumer lag instead of thrashing Chromium. Partition count sets your effective parallelism, which means capacity is a configuration change rather than a code change. This is exactly why there's no built-in concurrency limiter — the topic is the limiter, and it's sized against real traffic in a way a library default never could be.

DynamoDB as the cache. Keyed on the full URL, TTL matched to how often that content actually changes. Rendering is the expensive part; once a page is rendered, serving it again should never touch a browser. Partial captures are never written — timedOut: true on a response means skip the cache, because a cached spinner is worse than a slow render. Two hooks wire this up:

createRenderer({
  hooks: {
    onRequest: async ({ url }) => {
      const hit = await cache.get(url);
      if (hit) throw new CacheHit(hit); // your own control-flow error
    },
    onPageLoaded: async ({ url, html, statusCode, timedOut }) => {
      if (!timedOut && statusCode === 200) await cache.put(url, { html, statusCode });
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Horizontal renderer instances, each recycling its browser on the default schedule. A single render holds a browser context, roughly 30–60MB of Chromium memory, so capacity planning is arithmetic rather than guesswork. /health returns 503 during a relaunch, which is what makes rolling instances in and out safe.

None of those three belong in the package. What to key a cache on, how long to hold it, and where to put it are decisions that depend on your content and your bill — and nobody wants a prerender library with an opinion about Kafka. What the package owes you is a renderer that behaves predictably enough to build those on top of, and hooks in the right four places.

The 200k number is the reason I'm reasonably confident about the parts of this post that sound like opinions. The networkidle argument, the never-cache-a-partial-capture rule, the zombie process problem below — none of that came from reading the Playwright docs.

Migrating from the old server

Behaviour-compatible with almost everything prerender/prerender did. The renames:

- GET /http://localhost:8000/products/1
+ GET /render?url=http%3A%2F%2Flocalhost%3A8000%2Fproducts%2F1

- window.prerenderReady = false;
+ window.renderReady = false;

- <meta name="prerender-status-code" content="404" />
+ <meta name="renderready-status-code" content="404" />

- X-Prerender: 1
+ X-RenderReady: 1
Enter fullscreen mode Exit fullscreen mode

Most environment variables are unchanged — PORT, PAGE_LOAD_TIMEOUT, WAIT_AFTER_LAST_REQUEST, ALLOWED_DOMAINS and friends all mean what they did.

The two defaults worth double-checking: script stripping and meta-directive handling used to require registering a plugin, and they're on unless you turn them off. The old nine-plugin system maps onto configuration flags, with four hooks for anything a package can't anticipate. Full mapping table is in the migration guide.

One thing that will bite you in Docker

If you write your own Dockerfile, use an init process — tini, docker run --init, or Kubernetes' shareProcessNamespace.

Every browser recycle kills Chromium, and its five or so child processes — renderer, GPU, zygote, crashpad — reparent to PID 1. Node does not wait() on them. Without an init process to reap orphans you leak a set of zombies per recycle until the PID table fills, and the failure looks nothing like its cause. At a few hundred recycles per instance per day, you find this one fast. The bundled Dockerfile handles it.

While you're there: treat the render endpoint as privileged. Anything that can reach it can make your server fetch arbitrary URLs from wherever it's deployed, which is a textbook SSRF position. Set allowedDomains to the hosts you actually want rendered — it's the single most effective control — and don't run it anywhere with reach into cloud metadata endpoints.

Where it is

Node 22.12+, MIT, and the API surface is three functions and a CLI.

Bug reports and focused pull requests welcome. If you're on the old server and something in the migration guide doesn't cover your setup, open an issue — that's the gap I most want to close.

Top comments (0)