跪拜 Guibai
← Back to the summary

The Blank Screen After Every Deploy Is Not a Bug—It's Your Architecture

Why Do Users Always See a Blank Screen After Every Release?

Let me first clarify how to read this article.

It has two halves that can be read separately:

If you don't care about LYStack, you can close the article after reading the first half. If you are following this series, the second half picks up the version.json and offline caching that were held back at the end of the previous article.

The two halves address two faces of the same problem:

A blank screen is not an accidental incident of a specific release, but an inevitable byproduct of the "frontend deployment model"—static resources can be replaced at any time, while the copy running in the user's browser might always be an old one.


1. How a Blank Screen Actually Happens

Let's first thoroughly explain the mechanism. Without understanding how a blank screen occurs, all subsequent solutions are just rote incantations.

The deployment output of a modern frontend project looks like this:

dist/
├── index.html          ← No hash, entirely replaced with each release
├── app.a1b2c3.js       ← Content hash; content changes → filename changes
├── chunk.d4e5f6.js
└── vendor.g7h8i9.css

The intent of this design is good: HTML without a hash facilitates a stable entry point, and resources with hashes can be permanently cached—the name changes when the content changes, so the cache never expires.

The problem arises at the moment of release, when the files on the server are entirely replaced, but what is "alive" in the user's browser is more than just files.

Blank screen scenarios can be exhaustively categorized into four types.

Scenario 1: Old HTML references deleted resources

The HTML is cached by some intermediate layer (proxy, CDN misconfiguration, browser strong cache), and the user receives the previous version's HTML.

It references app.a1b2c3.js, which has already been deleted on the server—the new version is called app.x9y8z7.js.

The script returns a 404, and the page is completely blank.

Scenario 2: New HTML + residual old cached resources

Conversely, the HTML is new, but a resource file retrieved from the cache by the browser or CDN is old.

New and old code run together. This doesn't necessarily cause a blank screen; it's more terrifying—a ghost bug: a feature consistently reproduces on some users' machines, but you can't reproduce it because their browser is running a version combination that no longer exists in the world.

Scenario 3: Old application in a surviving tab

This is the most classic and most underestimated category.

A user opened your application in the morning, and the tab remained open. In the afternoon, you released a new version.

The application in the page is still the old one, running in memory, everything is normal—until the user clicks a menu item they haven't visited yet, triggering a lazy-loaded route:

Old app in memory ──request──> chunk.d4e5f6.js (old filename)
                            │
                            ▼
                     Server: This file no longer exists (404)

Dynamic import() fails, route navigation is interrupted. In Vue, this manifests as a blank screen you can't navigate away from; in React, it's a Suspense error; in the webpack era, it was called ChunkLoadError.

What the user sees is: nothing happens on click, or the page goes blank.

Scenario 4: Version lock-in caused by the Service Worker itself

Projects using a Service Worker (SW) have an additional category: the SW caches the entire set of resources in Cache Storage. If the update strategy is poorly designed, the user might forever receive the old version—after ten releases, they still see the page from their first visit.

This type of blank screen (more accurately, "blank screen on the wrong version") is an inherent risk of the SW solution, discussed separately later.

Summary in One Diagram

                Server (entirely replaced at any time)
                index.html + hashed resources
                    ▲
        ┌───────────┼───────────┬──────────────┐
        │           │           │              │
   Old HTML    New HTML    Surviving Tab   SW Cache
   + 404 res   + old cache + lazy 404      + non-updating strategy
        │           │           │              │
        ▼           ▼           ▼              ▼
     Blank      Ghost Bug   Blank/Stuck    Version Lock-in

Four scenarios correspond to the four layers of mainstream solutions below. No single solution can cover everything alone; the industry consensus is layered defense.


2. First Half: Panorama of Mainstream Solutions

The following solutions are ordered "from foundation to last resort." Each provides the principle, skeleton code, and boundaries, so you can check your own project for gaps.

Solution 1: Caching Strategy—The Foundation of Everything

The first thing is not writing code, but pairing the caching protocols correctly:

index.html        → no-cache (conditional request, ask server for latest each time)
Hashed resources  → Cache-Control: max-age=31536000, immutable

HTML always negotiates; resources are permanently cached. This pair directly eliminates most of Scenarios 1 and 2—provided it is actually enforced.

Common pitfalls:

Capability Boundary: This solution only protects "new visitors." It cannot handle Scenario 3—surviving tabs won't re-request HTML at all. It also can't handle already misconfigured intermediate layers.

So it's the foundation, not the whole building.

Solution 2: ChunkLoadError Self-Healing—The Last-Minute Rescue

For Scenario 3, the most common industry practice is to catch the load failure and force a single refresh:

// Application entry
window.addEventListener('error', (event) => {
  const message = event.message ?? '';
  if (
    message.includes('Loading chunk') ||
    message.includes('Failed to fetch dynamically imported module')
  ) {
    reloadOnce();
  }
});

window.addEventListener('unhandledrejection', (event) => {
  const message = String(event.reason?.message ?? event.reason ?? '');
  if (message.includes('dynamically imported module')) {
    reloadOnce();
  }
});

function reloadOnce(): void {
  // Prevent infinite loop: if refresh fails again, it's not a version mismatch, stop refreshing
  if (sessionStorage.getItem('__reloaded_for_chunk_error__')) {
    return;
  }
  sessionStorage.setItem('__reloaded_for_chunk_error__', '1');
  location.reload();
}

Projects using Vue Router typically add a route-level guard:

router.onError((error) => {
  if (/dynamically imported module|Loading chunk/i.test(error.message)) {
    reloadOnce();
  }
});

After the refresh, the browser re-requests the HTML, gets the new version, and peace is restored.

This solution has three pitfalls:

  1. State loss. A half-filled form is completely gone after a reload. That's why it's called rescue, not cure.
  2. Must prevent infinite loops. If the blank screen is caused not by version mismatch but by network disconnection, infinite reloading becomes a refresh storm. The sessionStorage flag is the minimum insurance.
  3. It's always one step behind. It only triggers after the error actually occurs and the user genuinely can't click in. The user has already cursed internally before you perform the rescue.

Solution 3: Proactive Version Detection—Turning Rescue into Forewarning

A step beyond "wait for error, then rescue": let the page itself know there's a new version in the world.

The implementation pattern is very fixed, largely similar across different implementations:

Step one, produce a version description file during build:

// version.json, deployed alongside build artifacts
{
  "buildId": "a1b2c3d4",
  "buildTime": "2026-08-20T10:00:00Z"
}

Step two, periodically fetch it at runtime and compare it with the currently running version:

const currentBuildId = __BUILD_ID__; // Injected at build time

async function checkVersion(): Promise<void> {
  const response = await fetch('./version.json?t=' + Date.now(), { cache: 'no-store' });
  const latest = await response.json();
  if (latest.buildId !== currentBuildId) {
    notifyUserToUpdate();
  }
}

setInterval(checkVersion, 5 * 60 * 1000);

Step three, prompt the user. The common practice in mainstream products (Juejin, Feishu, various backend systems) is a non-intrusive toast:

System updated, click refresh to use the new version

The key design point is "prompt to refresh" rather than "force refresh." Forced refresh loses user state, turning an experience optimization mechanism into an incident.

Capability Boundary:

Solution 4: Service Worker—The Strongest but Most Awe-Requiring Solution

SW faces three types of problems simultaneously: resource caching, version updates, and offline availability. The industry's standard answer is Workbox:

// sw.js —— Workbox precaching + update prompt
import { precacheAndRoute } from 'workbox-precaching';
precacheAndRoute(self.__WB_MANIFEST);

self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});
// Registration side —— Detect new SW waiting, prompt user, activate on confirmation
const registration = await navigator.serviceWorker.register('./sw.js');

registration.addEventListener('updatefound', () => {
  const worker = registration.installing;
  worker?.addEventListener('statechange', () => {
    if (worker.state === 'installed' && navigator.serviceWorker.controller) {
      showUpdateToast('New version found', async () => {
        worker.postMessage({ type: 'SKIP_WAITING' });
        navigator.serviceWorker.addEventListener('controllerchange', () => location.reload(), { once: true });
      });
    }
  });
});

It can ensure "the next refresh is definitely the new version" and also solve weak network and offline scenarios.

But SW is the easiest of the four solutions to mess up:

In one sentence: SW upgrades the caching problem from "protocol configuration" to "state machine programming." Used well, it's the ultimate solution; used poorly, it's a blank screen manufacturer.

Solution 5: Blank Screen Monitoring—The Last Resort of Last Resorts

The final piece of the puzzle: no matter how well the previous defenses work, you always need a mechanism to know "a blank screen actually happened."

The approach is to sample the rendering result, not catch exceptions—because a blank screen often throws no exception (e.g., the root node renders as empty):

function detectBlankScreen(): boolean {
  // 3 seconds after page mount, perform multi-point sampling of the viewport
  const points = [
    [0.5, 0.2], [0.2, 0.5], [0.8, 0.5], [0.5, 0.8], [0.5, 0.5],
  ];
  const blanks = points.filter(([x, y]) => {
    const element = document.elementFromPoint(
      innerWidth * x,
      innerHeight * y,
    );
    // Sampling point hits html/body, meaning no content rendered here
    return element === document.body || element === document.documentElement;
  });
  return blanks.length >= 3;
}

Upon detecting a blank screen: report to the monitoring platform (with version number, URL, UA), and locally attempt reloadOnce() for self-healing.

It doesn't solve the problem; it solves "the problem happened and you didn't know."

Solution 6: Deployment-Side Strategy—The Engineer's Last Line of Defense

The physical root cause of the first three mismatches is "old resources were deleted." So the simplest reverse operation is: don't delete them.

After retaining old resources, the lazy-load 404 of Scenario 3 physically disappears—the old chunk requested by the old tab is always available until it naturally dies out. This is the invisible infrastructure of many large companies, requiring zero lines of frontend code, but it has storage costs and depends on ops cooperation.

First Half Summary: A Comparison Table

Solution Scenario Solved Cost Positioning
Cache Strategy Pairing Scenarios 1, 2 Low (config) Foundation, mandatory
ChunkLoadError Self-Healing Scenario 3 Low (dozens of lines) Rescue, mandatory
Proactive Version Detection Pre-warning for Scenario 3 Medium Forewarning, strongly recommended
Service Worker All scenarios + offline High Ultimate solution, as needed
Blank Screen Monitoring Discovery for all scenarios Low Last resort of last resorts
Deployment-Side Old Resource Retention Physical elimination of Scenario 3 Low code + High ops Infrastructure coordination

Note one fact: Not a single row in this table is provided by a framework. Neither Vue nor React will do any of these for you. This is why the blank screen defense of most projects is incomplete—not because the solutions are unknown, but because each solution is scattered in different places, and no single role is responsible for assembling them.

This leads to the second half.


3. Second Half: How LYStack Avoids It

LYStack's starting point is not inventing new solutions, but answering one question:

Can the table above become a default capability of the platform, so business code doesn't need to write a single line?

First, look at its design stances, then the implementation. These three stances determine all implementation details:

  1. Enhancement capability positioning: When version detection and offline caching fail, silently degrade, absolutely not affecting application startup and business requests;
  2. Decoupling UI from mechanism: The platform only broadcasts events; what the prompt looks like and whether to force refresh is decided by the application itself;
  3. No Workbox introduced: The precache manifest is generated at build time, and the SW uses a hand-written template under two hundred lines—SW is a state machine, and the smaller the state machine, the easier it is to debug.

1. Build Time: Give Each Build a "Content Fingerprint"

The prerequisite for all detection is that the "current version" and "latest version" are comparable.

LYStack does three things in the final stage of the build (inside the runtime plugins of the two adapters):

Inject version fingerprint into each HTML:

<meta name="lystack-app-name" content="example-rsbuild" />
<meta name="lystack-build-id" content="[email protected]:a1b2c3d4" />
<meta name="lystack-build-time" content="2026-08-20T10:00:00.000Z" />

Produce a version.json:

{
  "appName": "example-rsbuild",
  "packageVersion": "0.0.0",
  "buildId": "[email protected]:a1b2c3d4",
  "buildTime": "2026-08-20T10:00:00.000Z",
  "envMode": "production"
}

Inject the version detection script as preEntry—executed before business code, completely transparent to the business:

source: {
  preEntry: [versionCheckEntry, ...(offlineEnabled ? [offlineRegisterEntry] : [])],
}

Here's the most noteworthy detail: buildId is content-addressed, not time-addressed.

const includedAssets = assets
  .filter(({ name }) => name !== VERSION_FILE_NAME && !name.endsWith('.gz'))
  .sort((left, right) => left.name.localeCompare(right.name));

for (const asset of includedAssets) {
  hash.update(asset.name);
  hash.update(asset.name.endsWith('.html') ? stripVersionMeta(String(asset.source)) : asset.source);
}

return hash.digest('hex').slice(0, 8);

The hash in buildId is calculated from all artifact contents. Two corollaries:

One fingerprint eliminates both false positives and false negatives.

2. Runtime: Four Triggers, One Notification, All Silent

The detection script (the preEntry injected at build time) is a textbook implementation of "Solution 3," but with a more complete trigger design:

if (currentBuildId) {
  window.addEventListener('load', () => void checkAppVersion(), { once: true });
  window.addEventListener('online', () => void checkAppVersion());
  window.addEventListener('focus', () => void checkAppVersion());
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'visible') {
      void checkAppVersion();
    }
  });
  window.setInterval(() => void checkAppVersion(), CHECK_INTERVAL);
}

Five trigger points, translated into plain language:

The request itself bakes "restraint" into every parameter:

const response = await fetch(VERSION_URL, {
  cache: 'no-store',
  signal: AbortSignal.timeout(15_000),
});

When a version mismatch is found, it does not pop any UI, only broadcasts an event:

window.dispatchEvent(
  new CustomEvent('app-update-ready', {
    detail: {
      currentBuildId,
      latestBuildId: latest.buildId,
      latest,
    },
  }),
);

Whether to prompt or not, what the toast looks like, whether to include a "Refresh Now" button—all left to the application:

// Application side (example): Use Naive UI to show a persistent notification
window.addEventListener('app-update-ready', (event) => {
  const { latestBuildId } = (event as CustomEvent).detail;
  notification.create({
    content: 'System updated',
    meta: `New version ${latestBuildId} is ready`,
    duration: 0,
    action: () => h(NButton, { onClick: () => location.reload() }, { default: () => 'Refresh' }),
  });
});

The platform manages the facts; the application manages the experience. This is Stance 2.

3. Service Worker: Build-Time Manifest Generation, Fingerprint-Named Caches

Offline caching is disabled by default. Applications declare offlineCache: true to enable it, and it only takes effect in test and production environments—the reason is explained later.

When enabled, the build process generates a sw.js from sw-template.js, filled with the manifest:

Precache manifest (determined at build time, not runtime crawled):
├── All js / css
├── Images, fonts (configurable)
├── All HTML pages
└── offline.html (fallback offline page)

The generated SW has several key behaviors:

Caches named by build fingerprint:

Cache Storage
├── lystack:example-app:a1b2c3d4   ← Current version
└── lystack:example-app:x9y8z7e0   ← Old version (deleted on activation)

When a new version SW installs, it writes new resources into a new cache; upon activation, it clears all caches with old fingerprints. There is no mismatch where "new SW reads/writes old cache"—the cache name carries the version; a version change guarantees a cache change.

Document requests: Cache-first + background revalidation:

function handleDocument(event) {
  return caches.open(CACHE_NAME).then((cache) =>
    cache.match(cacheKey).then((cached) => {
      const network = fetch(event.request)
        .then((response) => {
          /* If it hits the whitelist, update the cache in passing */
          return cache.put(cacheKey, response.clone()).then(() => response);
        })
        .catch(() => cached || caches.match('./offline.html'));

      return cached || network;
    }),
  );
}

Documents are served instantly from cache (guaranteeing open speed and offline availability), while the network response refreshes the cache in the background, so the next load is guaranteed to be the new version. Combined with the version detection event above, the "prompt refresh → refresh is new version" loop is closed.

Resource requests: Cache-first, network backfill. Hashed resources are content-addressed; a cache hit is correct.

Offline fallback: When the network fails and there's no cache, return the built-in offline.html—a static page stating "Network currently unavailable." Better to explicitly tell them there's no network than to leave them with an unexplained blank screen.

Escape hatch: The SW listens for the CLEAR_OFFLINE_CACHE message to clear all offline caches with one click—a regret pill when bizarre caching issues appear online.

4. Why It's Only Enabled by Default in test / production

envModes: options?.envModes ?? ['test', 'production'],

Offline caching is not enabled in the development environment for a very practical reason:

In dev mode, artifacts change with every code edit, and fingerprints differ every second. At this pace, the SW only does two things—caches a bunch of immediately obsolete resources, and constantly triggers "new version found." In a development environment, version detection is meaningless, and version lock-in is purely harmful.

Enabling it in the test environment is precisely to allow the "offline cache update flow" to be genuinely rehearsed under production-like conditions—the previous article discussed the separation of EnvMode and BuildMode: test uses a production-grade build, so the SW's behavior in test is exactly its behavior after going live.

5. Assembly Mapping: Mainstream Solutions → LYStack Implementation

Mainstream Solution LYStack Implementation Enhancement
Cache Strategy Pairing Adapter built-in hashed artifacts + assetPrefix Contractualized, not reliant on self-discipline
ChunkLoadError Self-Healing —— (Application-layer safety net, not built-in) Honest omission
Proactive Version Detection version.json + four-trigger detection + app-update-ready Content-addressed fingerprint, no false positives on rebuild
Service Worker Hand-written SW template, build-time precache generation Fingerprint-named caches, physically isolated versions
Blank Screen Monitoring —— (Delegated to monitoring platform) Honest omission
Deployment-Side Old Resource Retention offline.html fallback + CLEAR_OFFLINE_CACHE Partial coverage

Note the two rows of "honest omission."

ChunkLoadError self-healing and blank screen monitoring were not built into the platform—the former involves a product decision on "whether to accept state loss," and the latter depends on a specific monitoring platform. Hardcoding them into the platform would instead hijack the application. Default capability does not equal total capability; knowing what not to do is as important as knowing what to do. The application side needs to supplement these two pieces; the skeleton code from the first half can be copied and used directly.


4. Stringing the Entire Defense Line Together

The complete defense line after a LYStack release, walked through on a timeline:

T0  Release, server entirely replaced, version.json gets new fingerprint
      │
T1  Surviving tab switches back to foreground / timer fires / reconnects
      │  fetch version.json (no-store, 15s timeout)
      ▼
T2  buildId mismatch → broadcast app-update-ready
      │                └→ SW registration.update() synchronously pre-warms new cache
      ▼
T3  Application layer decides: toast prompt / silent / custom app strategy
      │
T4  User clicks refresh → New SW is ready, new HTML served instantly from new cache
      │
T5  (Straggler) Lazy-load old chunk fails → Application-layer self-healing reload (Solution 2)
      │
T6  (Extreme case) Network disconnected → offline.html gives clear prompt, not a blank screen

From "forewarning" to "rescue" to "offline fallback," no link assumes the previous link worked perfectly.


Finally

Back to the opening statement: A blank screen is not a bug; it's an inevitable byproduct of the deployment model.

As long as the contradiction exists between static resources being "replaceable at any time" and the "old version running" in the user's browser, the risk of a blank screen exists. All solutions do the same thing: shorten the dangerous survival window of the old version, and ensure every failure has a clear, definitive fallback path.

If you can only remember three things:

  1. HTML conditional caching + resource permanent caching is the foundation; check it first;
  2. Version detection handles "forewarning," ChunkLoadError self-healing handles "rescue"; neither is dispensable;
  3. SW is the strongest solution, and also the only one that can make the problem worse—before using it, think clearly about cache naming and update timing.

One more word for series readers: This series has discussed directories, request layers, layering, package boundaries, configuration, and build tools—the protagonist has always been "development-time architecture." This article is the first to discuss "the world after deployment." All the elegance of development time ultimately faces its test at the moment of release.

LYStack Project Address:

https://github.com/liangy0323/LYStack

Corresponding Source Code Locations:

packages/build-config/src/features/version.ts        # Fingerprint calculation & meta injection
packages/build-config/src/features/offline.ts        # SW generation & precache manifest
packages/build-config/src/runtime/version-check.ts   # Four-trigger version detection
packages/build-config/src/runtime/offline-register.ts# SW registration & update notification
packages/build-config/src/runtime/sw-template.js     # Service Worker template

Next Article

Next, we'll switch from the "engineering mainline" of this series to a new branch, a problem I've been thinking about a lot recently:

AI-generated code can run, but why doesn't it look like "team-written" code?

Models are getting stronger, and the generated code runs better and better. But when placed into a real team project, AI code always falls a bit short: directories are placed wrong, naming follows intuition, error handling casually uses console.log, and it doesn't know which conventions must not be touched.

It's not that the model isn't good enough; it's that no one fed it the team's implicit conventions.

In the next article, I'll combine the AGENTS.md and .rules files in the LYStack repository to discuss:

Comments

Top 1 of 3 from juejin.cn, machine-translated. The original thread is authoritative.

少说话_多学习

Does the LYStack solution mentioned in the article refer to https://lynx-stack.dev/zh/?

Liora_Yvonne

https://github.com/liangy0323/LYStack It refers to my open-source project. The bottom of the article also mentions this is a column.

少说话_多学习  → Liora_Yvonne

Got it, thanks boss.