Google called our article a 404. For a moment it was three words.

Two fresh articles came back from Google as Soft 404 though the HTML was fine. Our app was swapping 1,408 words for a loading screen. The test and the fix.

Ilyos Olimov · · 8 min read

On 7 October we rewrote two articles on the AppX blog, published them and asked Google to look again. Google crawled each one a few minutes later. Both came back with the same verdict: Soft 404. In Google's words, a page that answers "OK" but looks like an error page or has no real content.

One of them was 1,408 words long.

This is the story of how a page can be 1,408 words and three words at once, why nothing we checked showed it, and the short test that finally did.

Everything said the page was fine

We checked the obvious things first, and every one of them passed.

  • The server answered 200, with index, follow and a canonical link to itself.
  • Fetched as Googlebot, the HTML carried the whole article: the heading and all 1,408 words.
  • Google's own live test said "URL is available to Google" and "Page can be indexed".
  • For one of the two we opened the rendered HTML and the screenshot inside that test. The full article was there.
  • A third article, rewritten the same afternoon in the same way, was indexed without complaint.

So the page was fine every time we looked. The verdict was about one moment when we were not looking, and Search Console keeps no copy of what it saw for a Soft 404. The "view crawled page" button is simply greyed out.

The clue was the clock

The only thing the two failures shared was timing. Each crawl came about ten minutes after we had shipped a new version of the site.

A new version means new script files. Our site sends finished HTML from the server, and then a React app starts in the browser and takes the page over. If the scripts are new, nobody has them cached. Not the visitor, and not Google's renderer.

That pointed at the handover between the server's page and the app. So we slowed the handover down and watched.

The three-word page

Here is what our app did when it started, on every public page except the home page:

  1. The server's HTML arrives. The article is on screen.
  2. The app's main script runs and renders the app into the page. That replaces the server's HTML.
  3. The article page is a lazy route, so its code is a separate file. Until that file arrives, the app shows its loader.
  4. The file arrives. The article is on screen again.

Step 3 is the problem. For as long as that one file is on its way, the page says this, and nothing else:

Loading Please wait...

On a fast connection with warm caches the gap is too short to notice. It is still there on every single load.

The test

We wrote a small script that opens a page in a headless browser, delays every script file by three seconds, and counts the words on screen while it waits.

// Usage: node gap-check.mjs https://your-site.com/some-page
import { chromium } from 'playwright';

const url = process.argv[2];
const HOLD = 3000;
const browser = await chromium.launch();
const page = await browser.newPage();
await page.route(/\.m?js(\?|$)/, async (route) => {
  await new Promise((done) => setTimeout(done, HOLD));
  await route.continue();
});
await page.goto(url, { waitUntil: 'commit' });

let lowest = Infinity;
let previous = -1;
const started = Date.now();
while (Date.now() - started < HOLD * 4) {
  const words = await page
    .evaluate(() => document.body.innerText.split(/\s+/).filter(Boolean).length)
    .catch(() => previous);
  if (words !== previous) console.log(`${Date.now() - started} ms: ${words} words`);
  if (words > 0 && words < lowest) lowest = words;
  previous = words;
  await page.waitForTimeout(250);
}
console.log(`Lowest word count while loading: ${lowest}`);
await browser.close();

A healthy page never goes down. Ours did this:

PageWords from the serverWords in the gap
The rewritten article1,4083
Blog index2,2363
Gallery8533
Changelog8013
Pricing3443
Home page1,656no loader

The home page was the odd one out, and that was the embarrassing part. Four days earlier, when we rebuilt the home page, we had solved exactly this for it, because a flash of loader looked bad to visitors. We loaded its code before the app's first render, wrote a comment explaining why, and never applied it anywhere else.

Even the home page was not clean. It skipped the loader, but for a moment it showed only its top section: 188 words of 1,656, until the rest of the page arrived.

The fix

Three changes, all small.

Load the page's code before the first render. For every page the server renders, the app now fetches that page's file first and only then renders. The server's HTML stays on screen until the real page can replace it in one step.

export function preloadableRoute(load) {
  const Lazy = lazy(load);
  let Loaded = null;
  return {
    preload: async () => { Loaded = (await load()).default; },
    Route: () => createElement(Loaded ?? Lazy),
  };
}

At startup the app asks which page it is on, waits for that page's preload(), then renders. Navigation inside the app still uses the lazy version.

Do not fetch what the server already had. Two pages passed the first fix and then dropped anyway, and the home page still had its 188-word moment. The changelog asked the API for its list and showed "Loading release notes" while it waited. The gallery showed a spinner while it asked for a list that, today, is empty. The server now hands the changelog the data it rendered from, the gallery shows its own content from the first render, and the home page loads its lower sections together with the top.

Make it impossible to forget. A test reads our routing file and fails if any server-rendered page is missing from the preload list. The original mistake was fixing one page and not the others, so this is the part that matters most.

After the three changes, the same test on the live site:

PageWords from the serverLowest while loading
The rewritten article1,4081,408
Blog index2,2362,236
Gallery853833
Changelog801801
Pricing344344
Home page1,6561,656

The gallery's 833 is the app's own header replacing the server's link list. The apps, the table and the FAQ stay.

What we still do not know

We cannot prove this is what Google saw. Google does not show the page it judged, and a renderer that waits long enough would have seen the article. What we can say is narrower:

  • For a moment on every load, the page really was three words.
  • Both failures came minutes after a deploy, when that moment is longest.
  • Nothing else we checked was wrong.

Two days later, as we publish this, Google has not come back to either page, so the verdict still stands. We will update this article when it changes, whichever way it goes.

Check your own site

  1. Run the script above against your most important pages. Any page that sends HTML from the server and then starts an app is a candidate.
  2. Look at the lowest number, not the last one.
  3. If it drops, find what the app renders before the page's own code and data arrive. That is what a crawler may be reading.
  4. After a deploy, wait a few minutes before asking a search engine to re-crawl.
  5. Fix every page, then add a test that fails when a new page is left out.

FAQ

What is a Soft 404?

It is Google's label for a page that returns a success status but looks like an error page or an empty page. Google leaves such pages out of its index.

Does rendering on the server prevent it?

Only until your app starts. If the app replaces the server's HTML with a loader while it fetches code or data, the page is empty again for that time.

Why did the live test in Search Console pass?

The live test loaded the page at a calm moment, with every file available, and saw the finished article. The failed crawls happened minutes after a deploy.

How long does the empty moment last?

As long as the page's own script takes to arrive. With a warm cache that is a blink. Right after a deploy, on a slow connection, or for a crawler fetching files on its own schedule, it can be much longer.


Ready to try your idea?

Start with a clear description. AppX will help you turn it into an app you can test.

Start describing your app →

← Back to Blog