You shipped a fix to your JavaScript bundle three weeks ago. The browser shows the new behavior. The live test in Search Console looks fine. But the rendered HTML Google has on file still reflects the old code — missing product data, an old canonical injected by the client, a template that never finished hydrating.
The usual explanation offered is "the render queue is slow." That's rarely it. The more common mechanism is that Google's Web Rendering Service keeps its own cache of your JavaScript, CSS, and XHR responses, and that cache does not obey your HTTP cache headers. If the URL of a resource didn't change, Google may still be executing the version it fetched weeks ago.
What the Web Rendering Service actually caches
Google's JavaScript SEO documentation states it plainly: WRS caches resources aggressively and ignores cache headers. That includes Cache-Control, max-age, no-store, and Expires. The stated reason is efficiency — rendering millions of pages means refetching the same bundle for every URL on a site would be enormous waste. From Google's side it's a sensible optimization. From yours it means the renderer can be running code you deleted.
Two consequences follow that people underestimate:
- The cache is keyed by URL. A change to the bytes behind
/static/app.jsdoes not invalidate anything. Only a new URL does. - It applies to anything fetched during rendering, not just script tags. Google's docs call out that this caching can affect API responses as well, which means a
fetch('/api/products')call your page makes during hydration can be answered from Google's cache with data that no longer matches your database.
The cache lifetime is not documented. Don't build process around a specific TTL number — nobody outside Google knows it, and it is almost certainly not a fixed constant.
How browser caching and WRS caching differ
| Behavior | Normal browser | Google's WRS |
|---|---|---|
Honors Cache-Control / max-age | Yes | No — documented to ignore |
| Cache lifetime | Controlled by your headers | Internal, undocumented |
| Reliably invalidated by | Expiry, revalidation, hard reload | A different resource URL |
| Service workers | Supported | Not used; WRS does not register them |
localStorage / sessionStorage across page loads | Persists | Cleared between page loads |
| ETag / 304 revalidation on render resources | Yes | Unclear — do not rely on it |
The service worker and storage rows matter more than they look. If your cache-busting or data-freshness strategy lives in a service worker, it simply doesn't exist from Google's perspective. And if your app reads bootstrap data out of localStorage on second page load, Googlebot always behaves like a first-time visitor.
How a stale resource turns into a wrong render
Stale JavaScript rarely produces a subtly-out-of-date page. It usually produces a broken one, because old code and new backend don't agree:
- The old bundle calls an endpoint you removed. The fetch 404s, the promise rejects, an unhandled error stops execution, and the component that writes your main content never mounts. Google indexes the shell.
- The old bundle expects an old response shape. The API now returns
{ items: [...] }instead of{ products: [...] }. The render throws on an undefined property. Same outcome. - Code-split chunks mismatch. The entry file is cached, so it requests chunk names that no longer exist on your server. Every lazy-loaded route fails.
- Client-injected head tags go stale. If your canonical, title, or structured data are written by JavaScript, the cached bundle writes the old values — and Google uses the rendered DOM for those signals.
This is why a stale-resource problem often looks identical to a "Google can't render our site" problem. The distinguishing symptom is that it's intermittent across URLs and resolves without you changing anything.
Why content fingerprinting is the actual fix
Since the cache key is the URL, the only reliable invalidation is to change the URL whenever the bytes change. That's what content hashing in filenames does, and it's what Google's own documentation recommends.
// vite.config.js
export default {
build: {
rollupOptions: {
output: {
entryFileNames: 'assets/[name].[hash].js',
chunkFileNames: 'assets/[name].[hash].js',
assetFileNames: 'assets/[name].[hash][extname]'
}
}
}
}
Three practical notes:
- Fingerprint the path, not a query string.
app.js?v=7is technically a different URL, but query strings get stripped or normalized by intermediate caches and sometimes by crawler-side URL handling. A hashed path segment has no such ambiguity. - Don't fingerprint the HTML. The HTML is the thing that points at the new asset URLs; it must stay at a stable address.
- Deploy atomically. If new HTML goes live before new assets, the renderer fetches a 404 for a hashed file and you've traded a stale render for an empty one. Upload assets first, flip HTML second, and keep the previous build's assets available for a window.
For API responses consumed during render, the same logic applies: if freshness matters for indexing, the response URL needs to change when the data changes, or the data needs to be in the server-rendered HTML instead of fetched client-side. Server-rendering the content you care about sidesteps this whole class of bug.
How to tell whether this is what's happening to you
There's no report that says "stale resource." You infer it from three sources.
Compare the indexed render to a live test. In URL Inspection, look at the crawled page's rendered HTML, then run a live test. If the live test is correct and the stored version is wrong, you have a staleness problem of some kind. If both are wrong while your browser is right, suspect resource caching or a blocked/erroring fetch rather than the render queue.
Check your logs for asset fetches. Rendering fetches come from Googlebot; live tests come from Google-InspectionTool. If Googlebot requests your HTML constantly but hasn't touched /assets/*.js in weeks, you are being rendered from cache.
grep Googlebot access.log \
| grep -E '\.(js|css)(\?|$| )' \
| awk '{print $7, $9}' | sort | uniq -c | sort -rn | head -20
Verify nothing blocks the resources. If /assets/ or your API path is disallowed in robots.txt, WRS won't fetch it at all and will render with whatever it has — or nothing. That failure mode looks like staleness but has a one-line fix.
What to do
- Confirm every JS, CSS, and font URL in your build output carries a content hash in the path. If any entry file is stable-named, that's your prime suspect.
- Make deploys asset-first, HTML-second, and retain the previous build's hashed assets for at least a crawl cycle.
- Audit robots.txt for anything that blocks bundles, APIs, or CDN paths used during render.
- Move anything Google must read — canonical, title, meta robots, structured data, primary content — into the server-rendered HTML, so no cached bundle can get it wrong.
- Add a defensive error boundary so a failed API call degrades to partial content instead of an empty DOM.
- After a significant frontend change, spot-check a handful of URLs by diffing the stored rendered HTML against a live test, and log the result. One screenshot at the time of the incident is worth more than a month of guessing later.