A Save button's click handler on a web page first serializes the whole draft into localStorage, then builds an analytics payload, and only after that updates the DOM to show the "Saved" state. Users say the button feels laggy. What would you change, and why does the order matter?
answer
- the user is waiting on the frame
- not all of it is urgent
- visible state first, bookkeeping after
- localStorage writes block the main thread
- reordering, not less work
basics
~20 sShow the "Saved" state first, then defer the localStorage write and the analytics payload until after the browser has painted. Only the feedback has to be in that frame; the bookkeeping is what the user is currently waiting behind.
solid answer
~40 sI'd invert the handler: update the DOM to the saved state first, hand control back to the browser so it can paint, and do the persistence and analytics after that. The browser cannot paint while a handler is running, and `localStorage.setItem` is synchronous — serialising a large draft blocks the main thread for as long as it takes. So today the user waits for a write and a payload build before seeing any confirmation, even though neither affects what is on screen. Reordering doesn't make the work cheaper; it stops that work from sitting between the click and the frame the user is waiting for. If the payload build is genuinely expensive I'd move it out of the interaction entirely rather than just later in the same handler.
code
javascript · 15 linesfunction yieldToMain() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function onSave(draft) {
// 1. What the user is waiting to see.
document.getElementById('status').textContent = 'Saved';
// 2. Give the browser a chance to paint that.
await yieldToMain();
// 3. Bookkeeping the frame never needed.
localStorage.setItem('draft', JSON.stringify(draft));
navigator.sendBeacon('/analytics', JSON.stringify({ event: 'save' }));
}go deeper
Be ready to sort the lines of a handler into what the screen needs now and what can wait, and to say the browser only paints once the handler has returned.
Explain that localStorage is a synchronous API whose serialisation cost lands on the main thread, and that reordering only helps if you also hand control back before the deferred work runs.
Discuss the failure modes of deferring — lost writes on unload, deferred work colliding with the next interaction — and when the right move is to batch or shrink the bookkeeping instead of relocating it.
Frame it as a codebase-hygiene problem: handlers accumulate incidental telemetry and persistence over time, so decide where that work belongs architecturally and how reviews keep non-visual work out of interaction paths.
## The browser cannot paint mid-handler While an event handler is running, the main thread is yours and the browser is not rendering. Style, layout and paint happen after the handler returns control. That single fact explains the whole problem: everything the handler does before it returns is time the user spends staring at an unchanged button. So the question to ask of each statement in a handler is not "is this fast?" but **"does the next frame depend on it?"** In the Save handler: - `status.textContent = 'Saved'` — yes. This is the whole point of the interaction. - `localStorage.setItem('draft', JSON.stringify(draft))` — no. Nothing on screen reflects it. - Building and sending an analytics payload — no. Nobody sees it, ever. Two of the three are ahead of the one that matters. ## Why localStorage is the classic offender `localStorage` has a synchronous API. `setItem` does not return until the value is written, and the value first has to be produced by `JSON.stringify`, which walks the whole object graph. For a small preference that is microseconds. For a document draft it can be tens of milliseconds, entirely on the main thread, entirely in front of the user's feedback. The same shape shows up with any synchronous bookkeeping: recomputing a derived structure, re-sorting a cached list, formatting a log line from a large object. None of it is *wrong*; it is just in the wrong place. ## The reordered handler ```javascript async function onSave(draft) { status.textContent = 'Saved'; // paint-critical await yieldToMain(); // let the frame happen localStorage.setItem('draft', JSON.stringify(draft)); sendAnalytics('save'); } ``` Note that this is two changes, not one. Reordering alone is not enough: if you simply move the DOM write to the top and leave everything in the same synchronous block, the handler still does not return until the write and the payload are done, and the paint still waits. You need the DOM write **and** a genuine handback to the browser before the rest. ## Is deferring always safe? No, and an interviewer may push here. Deferred work can be lost if the page goes away between the paint and the continuation — a navigation, a tab close, a crash. For persistence that matters: - If losing the write would be a data-loss bug, deferring by a frame is still fine in practice (a frame is milliseconds), but re-persisting on page-hide as a safety net is cheap insurance. - For analytics, a beacon-style send is designed to survive unload, which is one reason it is preferred over a plain request in a handler. The general rule: defer freely, but know what happens if the continuation never runs, and choose a mechanism whose failure mode you can live with. ## What this does and does not fix It fixes **perceived** responsiveness and the interaction's measured duration, because the frame the user waits for no longer sits behind unrelated work. It does not make the page do less: the serialise still costs what it costs, the thread is still busy for the same total time, and if the user clicks again immediately, that deferred work is now in front of the *next* interaction. If the bookkeeping is large enough for that to matter, the answer escalates — batch it, debounce it, or get it off the main thread — rather than just moving it a few lines down. ## Recognising the pattern This is one of the highest-yield habits in front-end performance because it costs nothing to apply. Handlers accumulate incidental work over time: someone adds telemetry, someone adds a cache update, someone adds a persistence call, and each is individually trivial. Sorting them into "the frame needs this" and "the frame does not" — and putting a yield between the two groups — routinely recovers tens of milliseconds of felt latency without changing a single algorithm.
- Does simply moving the DOM update to the first line of the handler fix it on its own?No. The browser still cannot paint until the handler returns, so if the localStorage write and the analytics build follow in the same synchronous block, the frame waits for them anyway. You need the reorder *and* an actual handback to the browser between the visible update and the rest, otherwise nothing about the timing changes.
- What is the risk of deferring the localStorage write, and how would you mitigate it?If the page is closed or navigated away between the paint and the continuation, the write never happens. The window is only milliseconds, so in practice it is fine, but for anything you'd hate to lose I'd also persist on a page-hide signal as a safety net. For analytics, a beacon-style send is designed to survive unload.
- What would you do differently if the analytics payload takes 80 ms to build?Then deferring it a frame isn't enough — it will just block whatever the user does next. I'd build it lazily or incrementally, send only the identifiers and let the server join the rest, batch several events together on a timer, or move the construction off the main thread. Rescheduling helps small work; large work needs to shrink.
saying these in an interview costs you the question
- Says localStorage is asynchronous so it cannot block
- Assumes the browser paints between statements inside a handler
- Moves the DOM write up but keeps everything synchronous
- Thinks deferring makes the total work cheaper
- Treats analytics as urgent because it is in the handler