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:
- The first half is a universal perspective: independent of any framework or platform, it inventories the mainstream solutions in the industry for solving the "release blank screen" problem. You can apply them directly in any project.
- The second half is a reference implementation: how the LYStack platform assembles these solutions into a default capability requiring "zero business code."
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:
- CDN or nginx configured strong caching for HTML (
max-age=3600), so users within an hour of release all get the old HTML; - Resource filenames lack hashes (e.g.,
app.js), so "permanent cache" equals permanently no update; - In History mode of frontend routing, nginx falls back all paths to
index.html, but the fallback response carries a cache header.
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:
- State loss. A half-filled form is completely gone after a reload. That's why it's called rescue, not cure.
- 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
sessionStorageflag is the minimum insurance. - 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:
- It requires the user to be "actively using" to detect (polling, focus, tab switching);
- After prompting, the decision to refresh rests with the user, and until then they are still running the old version—so it must be paired with Solution 2: detection is forewarning, self-healing is the safety net;
- The detection request itself must be restrained:
no-store, timeout added, fail silently, so a "nice-to-have" mechanism doesn't become a performance burden.
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:
- If the HTML caching strategy is configured as "cache-first" without background update, that's the version lock-in mentioned above—you've personally created Scenario 4;
- Precaching manifests generated by runtime crawling (old-style runtime caching) easily miss resources or cache the wrong version;
- The SW lifecycle (installing → waiting → activating) and multi-tab coexistence issues have a non-trivial debugging cost.
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.
- Keep the hashed resources of the last N versions (don't immediately clean old chunks on CDN/OSS; use rolling deletion);
- HTML always takes effect immediately upon release (no-cache);
- Release via canary deployment, with a rehearsed rollback plan.
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:
- Enhancement capability positioning: When version detection and offline caching fail, silently degrade, absolutely not affecting application startup and business requests;
- 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;
- 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:
- If not a single character of code changes, rebuilding yields the same buildId—because
buildTimedoes not participate in the hash (and version meta is stripped from HTML before calculation). CI reruns, cache rebuilds, re-deploying after a revert won't pop false "System updated" notifications to users; - Any artifact change guarantees a buildId change—no missed reports where "code changed but fingerprint didn't follow."
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:
- load: Just opened, check once—the most common case of "user got the old version" is discovered on the first open;
- online: Network recovered, check—a release might have been missed during the offline period;
- focus / visible: Switched back from another tab, check—the world might have changed in the few minutes the user was away;
- Scheduled polling (5 minutes): Safety net for users who "stay in the foreground without switching away."
The request itself bakes "restraint" into every parameter:
const response = await fetch(VERSION_URL, {
cache: 'no-store',
signal: AbortSignal.timeout(15_000),
});
no-store: Never use cache to answer the question "has the world changed?";- 15-second timeout: Slow networks don't pile up requests;
- Don't check when the page is invisible, don't re-enter if already checking, don't notify repeatedly if already notified;
- The entire try/catch swallows all exceptions—if version detection fails, the application must continue working normally.
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:
- HTML conditional caching + resource permanent caching is the foundation; check it first;
- Version detection handles "forewarning," ChunkLoadError self-healing handles "rescue"; neither is dispensable;
- 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:
- Why README is written for humans, but AGENTS.md needs to be written for AI;
- What categories of content a specification file needs to contain to truly constrain AI;
- How to make implicit knowledge ("we never do it this way") explicit into AI-executable rules;
- How to verify that AI actually read the spec, rather than politely ignoring it.
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/?
https://github.com/liangy0323/LYStack It refers to my open-source project. The bottom of the article also mentions this is a column.
Got it, thanks boss.