Appearance
6.8.2 — The Hard Interactions
A user uploads a 2 GB video on a train. At 94% the connection drops for four seconds. The upload fails, and the interface offers a "Retry" button that starts again from zero.
That is not a bug in any single line of code. It is the consequence of treating an upload as one HTTP request, which is the default and is wrong for anything large. This page works through four interactions that are genuinely hard to build correctly, and in each one the difficulty is in the failure paths rather than the happy one.
1. Resumable, chunked upload
The design principle: a large upload is not one request, it is a session made of many small requests, and the server remembers what it already has.
ts
const CHUNK = 5 * 1024 * 1024; // 5 MB
const CONCURRENCY = 3;
async function upload(file: File, onProgress: (done: number) => void) {
// (1) Create a session. The server returns an id and — crucially — what it already has.
const { uploadId, received } = await startSession({
name: file.name, size: file.size, type: file.type,
});
const total = Math.ceil(file.size / CHUNK);
const missing = Array.from({ length: total }, (_, i) => i)
.filter((i) => !received.includes(i)); // (2) resume
let done = received.length;
// (3) A small worker pool — never all parts at once.
await pool(CONCURRENCY, missing, async (index) => {
const start = index * CHUNK;
const blob = file.slice(start, Math.min(start + CHUNK, file.size)); // (4)
await withRetry(() => putChunk(uploadId, index, blob), { // (5)
attempts: 5,
backoff: (n) => Math.min(1000 * 2 ** n, 30_000) + Math.random() * 400,
});
onProgress(++done / total);
});
// (6) Finalise. The server assembles and verifies before acknowledging.
return finaliseUpload(uploadId);
}Line (1) is the design's core. Creating a session before sending any bytes gives you an identifier that survives a page reload, and asking the server what it already has is what makes resume possible at all. Store uploadId in localStorage (Chapter 6.3.3) and a user who closes the tab can resume tomorrow.
Line (2) makes resume the normal path rather than a special case. A fresh upload is the case where received is empty.
Line (3) limits concurrency, and both directions are wrong. One part at a time wastes bandwidth on a fast connection, because a single TCP stream takes time to reach full speed (Chapter 5.4.3). All two hundred at once exhausts the browser's connection limit, starves every other request on the page, and makes the mobile connection worse for everyone on it. Three to six is the usual sweet spot.
Line (4) File.slice is the mechanism that makes this possible in the browser at all: it returns a Blob that is a view into the file, not a copy, so slicing a 2 GB file costs nothing and uses no memory. The bytes are read lazily as the request streams them.
Line (5) is retry with exponential backoff and jitter — the same ladder as Chapter 10.9, for the same reason. The jitter matters: without it, every stalled part retries at the same instant when the connection returns.
Line (6) finalises. The server must verify a checksum before acknowledging. A part can arrive corrupted or a client can lie, and "the upload succeeded but the file is broken" is discovered weeks later by the person who needs it.
The details that decide whether it actually works
Detecting the drop. navigator.onLine and the online/offline events are unreliable — onLine being true only means there is a network interface, not that anything is reachable, and a captive portal reports true. Treat a failed request as the signal and treat the events as a hint that it is worth retrying sooner.
Pause and resume is an AbortController (Chapter 6.3.3): abort in-flight parts, keep the completed set, resume by re-running the same loop. Because every part is independent, this needs no extra state.
Progress counts completed parts, not bytes in flight. fetch cannot report upload progress at all (Chapter 6.3.3), and chunking makes that limitation irrelevant — with 5 MB parts you get progress granularity of one part, which is fine.
Direct to storage. In production the browser usually uploads straight to object storage using pre-signed URLs, with your server issuing the URLs and receiving only the completion notification. That keeps large bodies off your application servers entirely. Chapter 11.2 designs this end to end.
Deduplication. Hashing the file before upload lets the server say "I already have this" and complete instantly. Hash in a worker (Chapter 6.3.3) — hashing 2 GB on the main thread freezes the page for seconds.
2. Idle timeout and automatic logout
Start with what this protects, because it changes the design: a user who walks away from a shared or unattended machine with a session open. It does not protect against a stolen token, a compromised browser, or anyone with access to the network — those need other mechanisms entirely (Chapter 8.4.2 for session design).
And be clear about the boundary. A JavaScript timer is a courtesy. The real control is that the server's session expires, which happens regardless of what any browser does. A client that never logs out must still find its next request rejected. Everything below is user experience built on top of that server-side truth.
ts
const IDLE_LIMIT = 15 * 60_000; // log out after 15 minutes of no activity
const WARN_AT = 60_000; // warn 60 seconds before
let deadline = Date.now() + IDLE_LIMIT;
const channel = new BroadcastChannel('session'); // (1)
// (2) Throttled: these events fire constantly.
const bump = throttle(() => {
deadline = Date.now() + IDLE_LIMIT;
channel.postMessage({ type: 'activity', deadline }); // (3) tell the other tabs
}, 1000);
for (const evt of ['pointerdown', 'keydown', 'scroll', 'focus'] as const) {
window.addEventListener(evt, bump, { passive: true }); // (4)
}
channel.onmessage = (e) => {
if (e.data.type === 'activity') deadline = Math.max(deadline, e.data.deadline);
if (e.data.type === 'logout') hardLogout(); // (5)
};
setInterval(() => { // (6)
const remaining = deadline - Date.now();
if (remaining <= 0) { channel.postMessage({ type: 'logout' }); hardLogout(); }
else if (remaining <= WARN_AT) showWarningDialog(Math.ceil(remaining / 1000));
else hideWarningDialog();
}, 1000);Line (1) and line (3) fix the bug that every naive implementation has: a user reading a long document in tab two gets logged out because tab one was idle. Broadcasting activity means any tab's activity counts for all of them (Chapter 6.3.3).
Line (2) throttles, because pointerdown and scroll fire at a very high rate and posting a message on each one is wasteful.
Line (4) uses passive so activity tracking never delays scrolling (Chapter 6.3.1).
Line (5) makes logout global, so the user is not left with three tabs in inconsistent states.
Line (6) polls rather than using a single long setTimeout, and the reason is that timers do not run reliably in a backgrounded tab — browsers throttle them heavily, and a laptop that sleeps stops them entirely. Comparing against a wall-clock deadline every second is correct across suspension; a setTimeout for fifteen minutes is not.
The warning dialog is not optional. Logging someone out mid-sentence with no notice loses their work and is the single most complained-about behaviour in enterprise applications. Warn, count down, and offer "Stay signed in", which extends the session with a real request to the server rather than just resetting the local timer.
Two timeouts, not one. An idle timeout resets on activity. An absolute timeout does not — the session ends 8 hours after login regardless. Both are normal, and the absolute one is what stops a session lasting for weeks because a page is polling.
On logout, clear in-memory state and the persisted store (Chapter 6.4.3), then redirect to login with a return URL so the user comes back to where they were. Anything unsaved should be preserved as a draft if that is possible.
3. Dialogs, focus and the top layer
A modal is the interaction people most often build badly, and the platform now does most of it.
html
<dialog id="confirm">
<form method="dialog"> <!-- (1) -->
<h2>Cancel this order?</h2>
<p>Order 8891 will be cancelled and refunded.</p>
<button value="cancel">Keep order</button>
<button value="confirm" autofocus>Cancel order</button> <!-- (2) -->
</form>
</dialog>ts
dialog.showModal(); // (3)
dialog.addEventListener('close', () => {
if (dialog.returnValue === 'confirm') cancelOrder(); // (4)
});Line (3) showModal() — not show() — is what does the work. It renders the dialog in the browser's top layer, a special layer above every stacking context (Chapter 6.2.5), so no ancestor's transform, overflow or z-index can trap it. It also makes everything behind it inert (unclickable and untabbable), traps focus inside, closes on Escape, and provides the ::backdrop pseudo-element for the dimmed background.
Line (2) autofocus sets the initial focus. Line (1)'s method="dialog" closes the dialog on submit and reports which button was used via line (4)'s returnValue.
And the platform returns focus to the element that opened the dialog when it closes, which is the requirement most hand-built modals forget. A keyboard user whose focus is dumped back at the top of the document after closing a dialog has to tab through the whole page again.
What still needs your attention:
Scroll locking. The page behind still scrolls. overflow: hidden on <body> works, and on iOS it does not fully — the standard fix is to also fix the body's position and restore the scroll offset on close. Test on a real device.
A non-modal popover — a dropdown, a tooltip, a hover card — should use the popover attribute rather than a dialog. It also uses the top layer, closes on Escape and on outside click, but does not trap focus or make the page inert, which is right for something that is not demanding a decision.
If you must build one by hand, the checklist is: role="dialog" with aria-modal="true", an accessible name via aria-labelledby, focus moved in on open and returned on close, Tab cycling within the dialog, Escape closing, background content marked inert, and the whole thing rendered at the document root. That is the list Chapter 6.8.1 said is why headless libraries exist.
4. View Transitions
Animating between two states used to require keeping both in the DOM and orchestrating them. The View Transitions API does it with a snapshot.
ts
// Feature-detect: this must degrade to an instant change, not break.
if (!document.startViewTransition) { applyUpdate(); } // (1)
else {
const transition = document.startViewTransition(() => applyUpdate()); // (2)
await transition.finished; // (3)
}Line (2) is the whole API. The browser screenshots the current page, runs your callback to change the DOM, screenshots the new state, and then cross-fades between the two — as a real animation on a pseudo-element tree, so your applyUpdate is a plain synchronous DOM change with no animation code in it.
Shared elements are where it becomes worth using. Give the same view-transition-name to an element before and after, and the browser animates it from its old position and size to its new ones:
css
.product-thumbnail { view-transition-name: product-image; } /* on the list page */
.product-hero { view-transition-name: product-image; } /* on the detail page */
/* Customise the generated animation. */
::view-transition-old(product-image),
::view-transition-new(product-image) { animation-duration: 280ms; }The thumbnail appears to grow into the hero image. Building that by hand means measuring both positions, cloning the element, positioning it absolutely, animating, and cleaning up — perhaps eighty lines with several edge cases. Here it is two CSS declarations.
Two rules. A view-transition-name must be unique on the page at any moment — two elements sharing one makes the browser skip the transition entirely, which is the usual reason "it does nothing". And respect prefers-reduced-motion (Chapter 6.2.5), which for this API usually means shortening to a near-instant cross-fade rather than removing it, so the change is still perceptible.
@view-transition { navigation: auto; } extends this across full page navigations, which brings a single-page-application feel to a multi-page site with no JavaScript at all.
Progressive enhancement, stated honestly
The pattern in line (1) is the general shape: build the thing that works, then add the enhancement behind a check.
Where this genuinely matters is narrower than the slogan suggests, and being honest about it is more useful than repeating it:
- A form should submit without JavaScript where it reasonably can, because scripts fail — a blocked CDN, a parse error from an old browser, a flaky connection.
actionandmethodcost nothing (Chapter 6.2.1) and mean a broken script degrades to a slower checkout rather than no checkout. - A link should be an
<a href>, so it can be middle-clicked, copied, previewed and crawled. - A newer CSS or browser feature should be feature-detected, with
@supportsor a capability check, and the absence should be an acceptable experience rather than a broken one.
What it does not mean is that a collaborative document editor must work without JavaScript. Choose the baseline that matches what the thing is, and make sure the baseline is genuinely usable rather than theoretically present.
5. Progressive web apps, and the service worker
A service worker is a script that runs separately from any page and acts as a programmable proxy for every network request from your origin. That is a large amount of power, and the first thing to understand about it is the risk that comes with it.
The lifecycle, and why the default is cautious
js
// sw.js
const VERSION = 'v7';
const SHELL = ['/', '/offline.html', '/app.css', '/app.js'];
self.addEventListener('install', (event) => { // (1)
event.waitUntil(caches.open(VERSION).then((c) => c.addAll(SHELL)));
});
self.addEventListener('activate', (event) => { // (2)
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)))),
);
});Line (1) install runs once for a new worker and is where you pre-cache the shell. waitUntil keeps the worker alive until the promise settles; if it rejects, installation fails and the old worker stays.
Then the new worker waits. It does not take over while any page controlled by the old worker is still open. This looks like an annoyance during development and is exactly right in production: one page must not be served half by the old worker and half by the new one, which would mean an old HTML shell fetching new chunk names, or the reverse.
Line (2) activate runs when it does take over, and is where old caches are deleted — the only safe moment, because until then the old worker may still be serving from them.
self.skipWaiting() and clients.claim() bypass the wait. They are correct only when your caching is version-safe, and combining them with an unversioned cache is how you serve a user a mix of two deploys. The safer pattern is to detect the waiting worker and let the user choose:
js
// In the page.
registration.addEventListener('updatefound', () => {
const next = registration.installing;
next?.addEventListener('statechange', () => {
if (next.state === 'installed' && navigator.serviceWorker.controller) {
showToast('A new version is available', {
action: () => { next.postMessage('SKIP_WAITING'); location.reload(); },
});
}
});
});The caching strategies
There are four, and each is right for a different kind of resource.
js
self.addEventListener('fetch', (event) => {
const { request } = event;
// (1) Navigations: network first, fall back to a cached offline page.
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).catch(() => caches.match('/offline.html')),
);
return;
}
// (2) Hashed build assets: cache first. The URL changes when content changes.
if (/\.[0-9a-f]{8}\.(js|css|woff2)$/.test(new URL(request.url).pathname)) {
event.respondWith(
caches.match(request).then((hit) => hit ?? fetchAndCache(request)),
);
return;
}
// (3) API GETs: stale-while-revalidate — instant, then quietly fresh.
if (request.method === 'GET' && request.url.includes('/api/')) {
event.respondWith(
caches.match(request).then((hit) => {
const network = fetchAndCache(request);
return hit ?? network;
}),
);
return;
}
// (4) Everything else, including all writes: leave it alone.
});Line (1) network-first is right for HTML because stale HTML is the worst thing to serve — it references asset URLs that may no longer exist.
Line (2) cache-first is right and safe only for content-hashed filenames. The hash is a promise that the URL's content never changes, so a cached copy is valid forever. Cache-first on an unhashed /app.js gives users a version you cannot update.
Line (3) stale-while-revalidate — Chapter 5.6.2's idea again — shows something immediately and refreshes in the background.
Line (4) matters as much as the others. Never intercept a mutation. A POST that a service worker replays or caches is a duplicate order.
Offline writes
For actions taken offline, queue them and replay when the connection returns:
js
// Background Sync: the browser retries this when it judges connectivity is back,
// even if the page has been closed.
self.addEventListener('sync', (event) => {
if (event.tag === 'outbox') event.waitUntil(flushOutbox());
});Two requirements make this safe. Every queued request must carry an idempotency key so a replay cannot double-charge or double-post (Chapter 9.6.3 and Chapter 10.4). And the interface must be honest — show the item as pending, not as done, and show it failing if it eventually fails. An optimistic update that silently never reaches the server is worse than a visible error.
Support for Background Sync is not universal, so the fallback is to flush the queue on the next page load when navigator.onLine is true.
The manifest and installability
json
{
"name": "Field Inspection Tool",
"short_name": "Inspect",
"start_url": "/?source=pwa",
"display": "standalone",
"background_color": "#101828",
"theme_color": "#0b62d6",
"icons": [
{ "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}Installability generally requires HTTPS, a manifest with a name, icons and a display mode, and a registered service worker with a fetch handler. "purpose": "maskable" provides an icon with enough padding that platforms can crop it to their own shape without cutting off your logo.
display: "standalone" removes the browser chrome — including the back button and the address bar. Test the whole application in that mode before shipping it, because a flow that relied on the browser's back button becomes a dead end.
The risk, stated plainly
A service worker is a proxy you deployed to your users' devices, and it persists. A bug that caches the wrong thing, or a fetch handler that throws, can make your site unreachable for a returning user, and they cannot fix it by reloading — the broken worker intercepts the reload.
Two protections are worth having from the first deploy:
A kill switch: an unregister-and-clear path you can trigger, and the discipline that the very first thing a new worker's activate does is check a remote flag.
Never cache-first anything unhashed, which is the mistake that produces the classic "our users are stuck on last month's version and we cannot push a fix".
What the interviewer will push on
"Design an upload that survives a dropped connection." A session created up front, File.slice into parts, limited concurrency, per-part retry with backoff and jitter, resume by asking the server which parts it already has, and a server-side checksum before acknowledging. Volunteer that concurrency wrong in either direction hurts, and that hashing for deduplication must happen in a worker.
"Why not just retry the whole upload?" Because on a poor connection the probability of completing a single large request approaches zero — each retry starts from nothing. Chunking makes the unit of failure small and bounded.
"How do you implement idle logout?" Throttled activity listeners, a wall-clock deadline polled every second (because timers are throttled in background tabs and stop when a laptop sleeps), a warning dialog with a countdown, and cross-tab coordination so activity in one tab counts for all. Then state the boundary: the client timer is user experience, the server session expiry is the control.
"How do you build an accessible modal?" Use <dialog> with showModal() — top layer, inert background, focus trap, Escape, and focus returned to the trigger on close. If asked to hand-roll it, recite the checklist, and mention scroll locking and the iOS quirk, because that is the part everyone discovers in production.
"What does the View Transitions API actually do?" Screenshots the old state, runs your DOM update, screenshots the new one, and animates between them on a pseudo-element tree. Shared elements use a matching view-transition-name, which must be unique at any moment or the transition is skipped.
"Walk me through the service worker lifecycle." Install (pre-cache), wait (so one page is never served by two versions), activate (delete old caches), then fetch handling. skipWaiting is safe only with versioned caches. The better pattern is to prompt the user to reload.
"Which caching strategy for which resource?" Network-first for navigations with an offline fallback, cache-first only for content-hashed assets, stale-while-revalidate for API reads, and never intercept writes.
"What is the risk of a service worker?" It is a persistent proxy on the user's device that can make your site unreachable, and a reload does not fix it because the worker intercepts the reload. Have a kill switch, and never cache-first an unhashed URL.
One thing to volunteer: point out that navigator.onLine being true only means a network interface exists — a captive portal reports online — so a failed request is the real signal and the event is only a hint. It is the detail that separates an offline implementation that works on a train from one that works in a demo.
Recall
- A large upload is a session of independently retryable parts, not one request: create the session first (it survives a reload),
File.slice(a view, not a copy), limited concurrency (one wastes bandwidth, all-at-once starves the page), retry with backoff and jitter, resume by asking the server what it has, and finalise with a server-side checksum. navigator.onLineis unreliable — a captive portal reports true. Treat a failed request as the signal.fetchcannot report upload progress; chunking makes that moot.- Idle logout: throttled
passiveactivity listeners, a wall-clock deadline polled every second (timers are throttled in background tabs and stop on sleep), a warning with a countdown, andBroadcastChannelso activity in any tab counts. Two timeouts — idle and absolute. The client timer is UX; the server session expiry is the control. <dialog>+showModal()puts the dialog in the browser's top layer, above every stacking context, with an inert background, a focus trap,Escape,::backdrop, and focus returned to the trigger on close. You still handle scroll locking, and iOS needs the fixed-body variant. Usepopoverfor non-modal overlays.- View Transitions snapshot before and after and animate between them; a matching
view-transition-namegives shared-element morphing in two declarations. The name must be unique at any moment or the transition is skipped. Feature-detect, and honourprefers-reduced-motion. - Progressive enhancement where it pays: forms with
action/method, real<a href>links, feature-detected CSS. Choose a baseline that matches what the product is. - Service worker lifecycle: install (pre-cache) → wait (so one page is never served by two versions) → activate (delete old caches) → fetch.
skipWaitingis safe only with versioned caches; prompting the user to reload is better. - Strategies: network-first for navigations (stale HTML references dead asset URLs), cache-first only for content-hashed files, stale-while-revalidate for API reads, and never intercept a write. Offline writes need an idempotency key and an honest pending state.
- A service worker is a persistent proxy you deployed. A bad one can brick your site and a reload will not fix it. Keep a kill switch; never cache-first an unhashed URL.
Self-test: Why does chunking change the probability of a large upload succeeding? · Why poll a deadline instead of setting one long timer? · What does showModal() give you that a <div> overlay does not? · When is skipWaiting unsafe? · Which resources may be cache-first, and why only those?
Next: 6.9 leaves the DOM behind for the applications that cannot use it — infinite canvases, spreadsheets and text editors, where the interface is drawn rather than laid out, and where the number of things on screen is decided by you rather than by the data.