Design the asset delivery and deploy strategy for a large single-page app. What happens to a user who has the tab open when you ship?
Cache hashed assets for a year and never cache the entry document, retain several previous builds so a stale tab can still fetch its chunks, and treat a failed dynamic import as the signal to prompt a reload rather than as an error to swallow.
What the interviewer is scoring
- Does the candidate give the entry document a different caching policy from the hashed assets and say why
- Whether the design accounts for a tab that was loaded before the deploy and is still in use after it
- That previous builds are retained on purpose rather than overwritten by each release
- Whether the reload prompt is designed so a cached document cannot produce a loop
- Does the answer treat the edge and any third-party host as dependencies with their own failure modes
Answer
Start from what a deploy changes for someone already using the app
A single-page app is not one artefact. It is a small entry document, a handful of eagerly loaded bundles, a long tail of route and feature chunks fetched on demand, plus fonts, images and whatever runtime configuration the app reads. A deploy replaces all of those at once at the origin, but it cannot replace the copy of the app already running in somebody's browser. That user holds an entry document from before the deploy, and every chunk their session goes on to request will be requested by the name that document knew about.
So the design question is not only how to make assets fast to fetch. It is how to make two versions of the app coexist for as long as sessions last, which on an internal tool that people leave open all week is considerably longer than the interval between releases.
Content-addressed names, and two caching policies
Give every build artefact a filename containing a hash of its contents, and then give those files the most aggressive caching you can: a year, marked immutable, so that browsers and intermediaries never revalidate them. That is safe precisely because the name changes whenever the bytes change, which means you never have to invalidate anything and never have to reason about whether a cached copy is stale. It also means a release that touches one route leaves every other chunk's cached copy valid, so the download cost of a deploy is proportional to what changed rather than to the size of the app.
The entry document is the exception and must be handled in the opposite way. It is the only file whose name is stable, so it is the only file where a cached copy can be genuinely wrong, and it is the file that names all the others. Serve it with revalidation on every request, using an ETag so an unchanged document still costs only a conditional response. Every hour spent debugging a deploy that "did not go out" traces back to an entry document with a long freshness lifetime sitting in a CDN, a corporate proxy or a service worker.
If you do run a service worker, its update behaviour becomes part of this design rather than a detail. A worker that serves the entry document from the cache first will pin users to an old build across several releases, and the interesting decision is whether a new worker takes over immediately or waits for every tab to close.
The chunk that no longer exists
Now the failure that actually pages people. A user loaded the app before the deploy, navigates to a route they have not visited yet, and the dynamic import for that route asks for a chunk whose hashed name belongs to the previous build. If your deploy process replaces the contents of the asset directory, that file is gone, the import rejects, and the user gets a blank region or an error boundary on a route that works perfectly for everybody who loaded the page five minutes later.
flowchart TD
A[Deploy publishes new hashed assets] --> B[Entry document updated at origin]
B --> C{Open tab still holds the old document}
C -- yes --> D[Lazy chunk requested by its old hashed name]
D --> E{Previous build still retained}
E -- yes --> F[Route loads and the session continues]
E -- no --> G[Import rejects so prompt for a reload]The branch worth arguing about in an interview is the retention one, because it is the cheap fix and the one teams skip. Deploys should add files rather than replace a directory, and old builds should be pruned on a schedule measured in releases or days rather than removed as part of shipping the next one. Storage for a few previous builds of a JavaScript bundle is trivially cheap compared with the cost of the failure it prevents.
Retention is not sufficient on its own, because eventually you do delete something, and because an API contract will change under a long-lived old client sooner or later. So the second half is to treat a rejected chunk import as a specific, expected condition: catch it, and rather than showing a generic error, tell the user a new version is available and offer to reload. The trap in that recovery path is reloading automatically and unconditionally, because if the entry document itself is being served stale by an intermediary the reload returns the same old document, which fails on the same import, which reloads again. Guard the reload so it happens at most once per session, and prefer a prompt over a silent reload wherever the user might have unsaved work in the page.
A lightweight version signal makes this proactive instead of reactive. Expose the build identifier somewhere cheap, have the app compare it periodically or when the tab regains focus, and surface the same prompt before the user hits a missing chunk rather than after.
The edge is a dependency, not a utility
In June 2021, a single customer configuration change at Fastly triggered a latent bug in a release that had been deployed some weeks earlier, and a large share of the web became unreachable through its edge for the best part of an hour. It is worth citing in a frontend design discussion because it reframes the CDN as a component with its own failure modes rather than as infrastructure that is simply there. The questions that follow are what your application does when the edge returns errors, whether the critical assets have an origin path that still works, and how much of your interface cannot render at all without a fetch from a host you do not operate.
That last one is where most applications are more exposed than their diagrams suggest. Fonts, an analytics or tag manager script, a maps or charting library, a feature-flag client, an error reporter: each is a separate host with its own availability, and a blocking script tag from any of them puts that host on your critical rendering path. The defences are unglamorous and effective. Self-host what you reasonably can, especially fonts. Load third-party scripts so that failure degrades a feature rather than the page. Pin integrity hashes on anything you load from a host you do not control, so a compromise there cannot execute arbitrary code in your origin. And give the app a defined behaviour for a flag client that never answers, which usually means shipping sensible defaults rather than blocking render on a config fetch.
Rollback is part of the delivery design
Because artefacts are content-addressed and previous builds are retained, rollback is republishing an earlier entry document rather than rebuilding anything, and that is the main reason the scheme is worth the discipline. Two properties make it real: the document must not be cached anywhere long enough to outlive the decision to roll back, and the API the older bundle calls must still be there, which is the constraint that makes backward-compatible API changes a frontend concern rather than somebody else's.
The entry document is the only file you must never cache and the only one you must be able to replace instantly; everything else should be immutable, retained across several releases, and named so that no cache ever has to guess.
Likely follow-ups
- You purge the whole CDN on every deploy. What does that cost you, and what would you do instead?
- How long do you keep old builds around, and what evidence tells you a build is safe to delete?
- A shared component library is loaded from a separate host at runtime. What did that buy, and what did it cost?
- Half your users are on the new bundle and half on the old one, both calling the same API. What has to be true for that to be safe?
Related questions
- Where do you put the cache, and how big does it need to be?hardAlso on caching and cdn6 min
- Design a URL shortener.hardAlso on caching6 min
- Design the frontend for a data-heavy operational dashboard with live updates.hardAlso on caching7 min
- How do you handle cache invalidation, and what goes wrong at scale?hardAlso on caching5 min
- What goes into a cache key, and what happens when two requests that should get different responses collide on one?hardAlso on caching6 min
- A document was updated an hour ago and the assistant is still quoting the old version. Walk me through the diagnosis.hardAlso on caching6 min
- You have 50 ms for a model call in a request path. How do you make that budget?hardAlso on caching5 min
- You have to release a change to the order-routing path. Walk me through the release, and tell me what makes it different from deploying any other service.hardAlso on deployment6 min