ProductionGENERALPLATFORM-SPECIFIC

Deploying a Frontend

Source to build to artifact to CDN to a browser you do not control — and the deploy that breaks the tabs already open.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

What actually happens between merging a change and a browser running it, and which step is the one that breaks people?

The user intent

Someone is mid-task in a tab they opened this morning. They want the fix that was promised, and they very much do not want the application to come apart underneath them while they are typing.

The obvious build

The build produces a folder of static files. Upload it over the last one and the deploy is done — it is just files on a server.

Why it breaks

Overwriting the folder deletes the previous build's chunks. A tab still running that build asks for Orders.7f3c1a.js when the user opens a modal, gets a 404, and the button silently does nothing (Code Splitting).

How it breaks in a real browser
  • Overwriting the folder deletes the previous build's chunks. A tab still running that build asks for Orders.7f3c1a.js when the user opens a modal, gets a 404, and the button silently does nothing (Code Splitting).
  • The upload is not atomic. For the seconds or minutes it takes, some visitors get an entry document from the new build naming assets that have not landed yet, or an old document naming assets that have just been pruned.
  • The entry document gets cached — by a CDN node, by a browser, by a proxy — so users keep loading a document that names a build you have already replaced (Browser HTTP Caching).
  • A refresh on /orders/123 returns 404, because only the client router ever knew that path existed and the deploy did not carry the rewrite rule with it (Client-Side Routing).
  • An SSR deploy is not a folder at all. It is a process rollout, which means two application versions serve real traffic simultaneously, and the HTML one of them emits may reference assets the other one built (Server-Side Rendering).
  • Configuration baked in at build time turns "one artifact promoted through environments" into "four different builds", and the thing you tested in staging is not the thing production runs.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • The chain is source → install → build → artifact → publish → serve → run. Only the first two steps happen in a repository; everything after artifact is infrastructure, and most deploy incidents live there rather than in the code.
  • The build content-hashes its outputs: the bytes decide the filename. That makes every asset immutable, which is what allows an aggressive far-future cache header without any invalidation logic (Content-Hashed Assets).
  • Exactly one thing stays mutable: the entry document. It is the pointer that names which hashed assets constitute "the current build", and it must therefore be revalidated rather than cached indefinitely.
  • A publish is therefore ordered, not simultaneous: upload the immutable assets first, let them propagate, then flip the pointer. Doing it the other way round guarantees a window in which the document names files that do not exist.
  • "Atomic" is aspirational. A CDN is many nodes in many places, and there is no instant at which all of them change together — so the honest goal is *atomic-ish*: a window narrow enough and ordered such that both ends of it are serviceable (CDN Delivery).
  • Deploying updates what new page loads receive. It does not update any client that is already running. That is the whole of Long-Lived Clients and Version Skew, and it starts here.

What this makes the browser do

And which of it is avoidable.

  • Fetching and revalidating the entry document, then discovering a new set of hashed URLs from it — a fresh dependency graph the browser has never seen.
  • Downloading every chunk whose hash changed. How much that is depends entirely on how the graph was split: a change to one shared utility can invalidate every chunk that inlined it (Bundle Analysis).
  • Parsing and compiling the changed JavaScript with a cold code cache, so the first load after a deploy is measurably heavier than the steady state (The Real Cost of JavaScript).
  • Running the service worker's update check, and possibly swapping the controller under a page whose already-loaded code expects the previous chunk graph (The Service Worker Lifecycle).
  • Re-fetching fonts, CSS and images whose hashes moved — often the largest avoidable part of a deploy, and usually caused by a build that rewrites hashes for files whose content did not change.

From merge to browser, one step at a time

The useful thing about writing the chain out is that each step has its own characteristic failure, and they are not interchangeable. A team that says "the deploy is broken" is usually describing exactly one of these rows, and naming which one is most of the fix.

Note where the boundary sits. Everything up to artifact is reproducible and testable in CI. Everything after it involves caches, propagation and clients — which is precisely why deploy incidents are so much harder to reproduce than build failures.

The deployment chain and how each step fails
  1. 1
    Source

    A commit on a branch that CI can check out reproducibly.

    fails by Building from a mutable ref, so "the same deploy" twice produces different artifacts.

  2. 2
    Install

    Resolves the dependency graph from a lockfile.

    fails by Resolving a floating version, which makes the build non-reproducible and the supply chain unpinned.

  3. 3
    Build

    Bundles, transpiles, splits and content-hashes the outputs; emits a manifest (The Module Graph).

    fails by Baking environment configuration into the artifact, so it can no longer be promoted.

  4. 4
    Artifact

    An immutable, identified set of files plus the manifest that wires them together.

    fails by Existing only as "whatever is in the bucket right now", which makes rollback a rebuild.

  5. 5
    Publish

    Uploads assets, waits for propagation, then flips the entry document.

    fails by Flipping the document first, or deleting the previous build's assets in the same operation.

  6. 6
    Serve

    Applies cache headers, security headers, the SPA fallback and compression at the edge.

    fails by Caching the entry document as aggressively as the assets, pinning users to a build you replaced.

  7. 7
    Run

    A browser fetches the document, discovers the graph and executes it.

    fails by Being a tab that was opened before any of this and is still running the previous build (Long-Lived Clients and Version Skew).

The last row is the one no pipeline dashboard can show you, and it is where most user-visible deploy damage happens.

Immutable assets, exactly one mutable pointer

The caching model that makes frontend deployment work is a two-tier one, and it is worth stating explicitly because the two tiers want opposite headers. Hashed assets are immutable by construction — the URL is a function of the bytes — so they can be cached for as long as any cache is willing to hold them, and never revalidated. The entry document is the version pointer, so it must be revalidated on every load or the pointer never moves.

Getting this backwards is the most common deployment misconfiguration there is, and it is asymmetric: an under-cached asset costs bandwidth, while an over-cached document costs you the ability to ship at all. Users on that document will keep loading a build you cannot change, sometimes for as long as the header said.

FileMutable?Cache policyWhat goes wrong if you get it backwards
index.htmlYes — it names the buildRevalidate every loadUsers pin to an old build and cannot be moved off it until the header expires
app.7f3c1a.jsNo — the hash is the contentCache indefinitelyEvery deploy re-downloads every chunk, whether or not it changed
logo.4b1c22.svgNoCache indefinitelySame, and it is usually the largest wasted transfer
/config.json (runtime)Yes — per environmentShort, revalidatedA config change requires a rebuild, which defeats the point of having it
manifest.jsonYesShort, revalidatedInstall prompts and icons drift a release behind the app (Manifest and Installability)
Previous build's assetsNo — but deletableCache indefinitely, retain for weeksOpen tabs 404 on their next dynamic import
Two policies, one deploy
1# The pointer. Mutable, so it must be checked every time.
2GET /index.html
3Cache-Control: no-cache # revalidate, do not serve blind
4ETag: "build-4192"
5
6# The graph it names. Immutable, so never check again.
7GET /assets/app.7f3c1a.js
8Cache-Control: public, max-age=31536000, immutable
9
10# Same policy, previous build. Still published, because a tab
11# opened before the deploy will ask for exactly this URL.
12GET /assets/app.b20e94.js
13Cache-Control: public, max-age=31536000, immutable

no-cache does not mean "do not cache" — it means "cache it, but revalidate before use". The header that means never store it is no-store, and using it on the document throws away a cheap conditional request for no benefit.

The deploy that breaks the tabs already open

Here is the sequence that produces the module's signature bug. A user opens the app. Their document names build N and the browser has parsed its entry chunk. They leave the tab open and go to a meeting. You deploy build N+1, and the publish step removes build N's files. The user comes back, clicks a link that triggers a dynamic import, and the request for a chunk that no longer exists returns a 404 — or worse, the SPA fallback rewrite returns an HTML page with a 200, and the module parser fails on <.

Nothing in this sequence is exceptional. It happens on every deploy to every user who had a tab open, and the only variables are how many such users there are and whether they happen to navigate. The mitigations are all about widening the window rather than closing it, which is the correct framing: this is a race you manage, not one you win.

One deploy, two clientsrelative units — ordering and overlap only, nothing here was measured
Tab A loads document (build N)
Tab A parses entry chunk N
Tab A idle, user elsewhere
Publish: upload build N+1 assets
Publish: flip document to N+1
Publish: prune build N assets
Tab B loads document (build N+1)
Tab A requests chunk from build N
Tab A: retry, then offer reload
  • Publish: prune build N assetsthe mistake — this step is what breaks Tab A
  • Tab A requests chunk from build N404, or a 200 of HTML from the SPA fallback

Move the prune step weeks to the right and Tab A survives. That is the entire mitigation, and it is why retention policy is a user-experience decision rather than a storage one.

Deploy-time failures a browser actually reports
TriggerSymptomCauseResponse
Old assets pruned on publishDynamic import fails; a control does nothing, error rate spikes for minutes after each releaseThe running document names build N and only N+1 existsRetain several builds; retry the import once, then offer a full reload (Code Splitting).
SPA fallback answers a missing chunkUnexpected token '<' in the console, not a 404The rewrite returns the app shell with a 200 for any unmatched pathExclude the asset path prefix from the fallback rule so a missing chunk fails as a 404.
Entry document cached aggressivelyUsers report the fix is missing for days; a hard refresh fixes itThe pointer was given an asset's cache policyRevalidate the document on every load; reserve long caching for hashed URLs.
Reload fallback served the same stale documentReload loop — the page reloads repeatedly and never recoversThe recovery path trusted the cache it was recovering fromMake the recovery reload revalidate, and cap the attempts before showing an honest error.
Headers dropped by a hosting changeCSP violations stop being reported; the page becomes framableServing configuration lived in a dashboard, not in the repositoryVersion the serving config and assert the headers in a post-deploy smoke test (Content Security Policy).
Service worker activated mid-sessionChunk errors in sessions that never navigated across a deployskipWaiting() swapped the controller under a running pagePair skipWaiting() with a reload, or wait for all clients to release control (The Service Worker Lifecycle).

How to build it

Most important first.

  • Content-hash every asset and cache it effectively forever; keep the entry document short-lived and revalidated. This is the single decision that makes the rest of deployment tractable (Content-Hashed Assets).
  • Publish in order — assets, propagate, then the document — and never delete the previous builds' assets on publish. Retain several releases; the storage is trivial next to the support cost of a 404 chunk.
  • Build one artifact and promote it. Anything environment-specific is fetched at runtime or injected at serve time; only genuinely public values may be baked in, because everything in the bundle is readable (The Browser Security Model).
  • Version the serving rules with the application: the SPA fallback rewrite, the security headers, the redirect table. Hosting configuration that lives only in a dashboard is configuration that a migration will quietly drop (Content Security Policy).
  • Handle a failed dynamic import explicitly — retry once, then offer a full reload — because the deploy-skew case is not an exceptional error, it is a scheduled one (Lazy Loading).
  • Emit a build identifier into every error report, every performance beacon and every analytics event, so "did the deploy cause this" is answerable rather than argued about (Release Health).
  • Smoke-test the served result, not the build output: headers, the fallback rule, the document's lang, one real route. The build can be perfect and the deploy still wrong.

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • A deploy is a config change as much as a code change, and the accessibility-critical parts of a document — lang, the skip link, the landmark structure of the shell, the title — are exactly the parts a hosting or template migration silently drops. Assert them in the post-deploy smoke test (Document Structure and Reading Order).
  • When a chunk fails to load, the failure must be perceivable to someone not watching for a visual change. A button that does nothing is indistinguishable from a slow one; an announced, focusable error message is not (Live Regions and Announcement).
  • Any "a new version is available" affordance must be operable by keyboard and must not move focus on its own. Someone may be mid-sentence in a text field when it appears (Focus Management).
  • If the deploy changes the shell — navigation landmarks, heading order, the tab order of the header — a returning user's mental map of the page changes with it. That is a real cost of shipping, not a reason not to ship, but it belongs in the release notes (Keyboard Operability).

What can go wrong

Failure modes
  • The asset retention window expires while a tab is still open. Retention narrows the race; it does not close it, and a tab left open over a long weekend outlives most policies.
  • The retry-then-reload fallback becomes a reload loop: the reload is served the same stale cached document, which names the same missing chunk, which fails again. Reloading has to bypass or revalidate the document, or the fallback is a trap.
  • A rollback restores the previous assets but the CDN is still serving the newer document from some nodes, so the deploy is inconsistent in both directions at once.
  • skipWaiting() activates a new service worker under a running page, replacing the fetch handler for a document whose JavaScript belongs to the previous build (The Service Worker Lifecycle).
  • Pre-compressed variants and their originals are published non-atomically, and for a window the origin serves one file with the other's encoding header (Minification Is Not Compression).
  • The SSR process and the static assets deploy through different pipelines and drift, so server-rendered HTML references a build the asset host has already pruned (Hydration Mismatch).
What can arrive out of order
  • The signature race of this module: a deploy prunes build N's chunks while a tab still running build N is about to request one. The tab wins or loses depending on when the user clicks.
  • A CDN with partial propagation serves the new document from one node and the old assets from another to the same user across two requests.
  • A service worker update lands between the document fetch and the first chunk fetch, so the two are answered by different builds (Intercepting Fetch).
  • An SSR process rollout renders HTML from build N+1 while the asset host is mid-publish, producing a document that references assets not yet readable.
Security
  • The artifact is public. Source, comments, endpoint paths, every value in a bundled config object, and every source map you publish alongside it. Minification is not obfuscation and obfuscation is not secrecy (Source Maps).
  • Security headers are deployment output, not application code. Content-Security-Policy, frame-ancestors, Strict-Transport-Security and the rest are emitted by whatever serves the document, and a platform change is the most common way a team loses them without noticing (Content Security Policy).
  • The SPA fallback rewrite means your origin returns a 200-status application page at *any* path. That is a deliberate, security-relevant choice: it makes plausible-looking URLs on your own domain trivially constructible (Clickjacking and Framing).
  • The build pipeline is a supply chain with write access to what every user executes. A compromised dependency or a compromised CI step ships to the entire population at once, and the browser will run it with your origin's full authority (Third-Party Scripts and the Supply Chain).
Misreads
  • "It is static hosting, so there is no deploy risk." Static hosting removes the server, not the ordering problem, the cache problem, or the open tab.
  • "Content hashes solve version skew." They solve *asset identity* — a given URL always means the same bytes. They say nothing about whether the API that build talks to still exists (How API Shape Drives UI Complexity).
  • "Rollback is instant." Rollback changes what new loads receive. Every client already running the bad build stays on it until it reloads, and some cached documents will keep pointing at it.
  • "The deploy succeeded, so the release is fine." The deploy is the pipeline's opinion. The release is a population of browsers adopting it over hours (Long-Lived Clients and Version Skew).
  • "We tested the build." The build is not what is served. Headers, rewrites, compression and the CDN all sit between them.

Measuring it, and what changes in the field

How you would see this
  • Chunk-load failure rate, segmented by build identifier and by time since deploy. A spike in the minutes after each release is the signature of the open-tab race, and it is invisible in any server-side view (Frontend Error Tracking).
  • Error rate and field performance per release, compared against the previous one on the same population rather than against yesterday overall (Release Health).
  • Deploy markers on every dashboard, so the question "what changed at that moment" has an answer that does not depend on someone remembering (Real User Monitoring).
  • Edge cache hit ratio for assets and for the document separately — they should look completely different, and if they do not, one of the two cache policies is wrong.
  • Adoption curve: the share of live sessions running the newest build, plotted over the hours after a release. It is the most direct measurement of how non-atomic your client population is.
Slow device, slow network, large data, old tab
  • On a slow network, the propagation window is wider from the user's side: a document fetched just before the flip and assets fetched just after can straddle a deploy that looked instant from the dashboard.
  • With a service worker, the update model changes completely — the page is served by a cached build until the worker decides otherwise, which can be several sessions later (Caching Strategies).
  • In an app people keep open all day, the adoption curve has a long, flat tail. Support tickets will arrive from builds you shipped weeks ago.
  • In a micro-frontend deployment, units ship on independent schedules, so "the current build" is not one thing and skew is a permanent steady state rather than a transient (Micro Frontends).
What this costs
  • Retaining old builds costs storage and makes "what is deployed" a set rather than a value. It is still far cheaper than the alternative, which is a class of error your users hit and you cannot reproduce.
  • Runtime configuration costs a request before the app can do anything useful, or an inlined blob in the document that the CDN can no longer cache as aggressively. Build-time configuration costs you the one-artifact property.
  • Ordered, propagate-then-flip publishing makes deploys slower and more procedural. That slowness is the mitigation; a faster deploy is a wider inconsistency window.
  • Emitting a build identifier everywhere is a small cardinality cost in every telemetry system you own, and it is the single field that makes release questions answerable.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe ordering rule — immutable hashed assets published before the mutable entry document — follows from HTTP caching semantics and holds for any host, CDN or framework. What differs is only which tool performs each step.
  • PLATFORM-SPECIFICWhere headers, rewrites and retention are configured is entirely platform-dependent: a managed static host may express them in a repo-level config file, an object-store-plus-CDN setup in bucket metadata and edge rules, and a self-hosted origin in the web server config. The rules are the same; the place they live, and the ease with which a migration loses them, is not.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Distributed Systems — a CDN is a replicated store with no global instant, so "atomic deploy" is a consistency question: what a client may observe during propagation, and how narrow you can make the window.
  • Testing & Reliability Engineering — post-deploy smoke tests, progressive delivery and the rollback drill, as a reliability practice rather than a pipeline feature.