The preview problem I planned for was not the problem
A live preview that renders the real page on every keystroke sounds expensive, so the plan parked per-section rendering as the eventual fix. Then a twelve-section render measured 16ms, the whole optimisation became unnecessary, and the actual difficulty turned out to be somewhere else entirely.
Atelier is a page builder for Laravel. The whole reason it exists rather than being another block-stack editor is the preview: the client types, and the real page changes in front of them.
The plan treated that as the risky part, and gated the entire project on it. Feature 01 was a spike, and the task file said in as many words: if it does not feel live on a page with twelve sections, stop and redesign rather than continuing down the list.
So the spike got built first, and the thing I was afraid of turned out not to matter.
What I thought the problem was
Naively, every keystroke triggers a full server render of the whole page. On a twelve-section page that is twelve Blade views, a layout, and a database read, several times a second while someone types a headline.
The plan had an answer ready for this. Debounce at around 500ms, only refresh on fields marked reactive, and park the real fix in v2: stop rendering the whole page and render only the block that changed, swapping it into the iframe.
That is a sensible-sounding plan built on an assumption nobody had measured.
What it actually costs
A full twelve-section render, ten times, on artisan serve with no opcache tuning:
31ms 11ms 13ms 13ms 14ms
13ms 14ms 19ms 19ms 14ms
---
mean 16ms max 31ms
Sixteen milliseconds. The debounce is 500ms. The render is three per cent of the interval it sits inside, on the slowest PHP server anyone would use.
So per-section rendering, the optimisation the plan had already committed to building, is not needed. Not for now, and on this evidence not for pages several times this size. It stays written down as the answer if a page ever gets genuinely heavy, a gallery pulling hundreds of records or similar, but building it before that happens would have been effort spent on a number nobody checked.
The lesson is not that optimisation is bad. It is that the plan had a fix scheduled for a problem, and measuring the problem took about four minutes.
The bit that was actually interesting
With render cost off the table, the real question became how to get the new HTML into the iframe without it feeling like a page load.
The obvious move is to reload the iframe. That works and it is awful: the document reloads, so scroll position is lost and you have to save and restore it, the stylesheet re-fetches, and there is a flash of unstyled everything on every keystroke pause.
What I ended up with instead:
async refresh() {
const frame = this.$refs.preview
const current = frame.contentDocument.querySelector('[data-atelier-canvas]')
const html = await (await fetch(this.url)).text()
const next = new DOMParser()
.parseFromString(html, 'text/html')
.querySelector('[data-atelier-canvas]')
const y = frame.contentWindow.scrollY
current.innerHTML = next.innerHTML
frame.contentWindow.scrollTo(0, y)
}
Fetch the preview, parse it, and replace the contents of one container inside the iframe. The document never reloads. Scroll position survives for free, because nothing scrolled. The stylesheet is never re-fetched, so there is no flash.
This is less code than reloading and restoring scroll, and it behaves better. That happens occasionally and it is worth noticing when it does, because the instinct is usually that the nicer behaviour costs more.
The preview has to be the real page
The other half of the spike was fidelity, and this is the part that makes the feature worth building at all.
The iframe points at a signed, noindex route that renders the draft tree through the public layout and the public stylesheet. Same Blade views, same CSS, different data source. The rule I wrote into the constraints and would defend hardest:
The preview and the public page render through the same code path. Any second rendering path for the editor is a bug waiting to happen.
Because the moment a client finds one thing that looks different in the preview than on the live site, they stop believing the preview and go back to opening the site in a second tab. Which is the workflow the whole feature exists to remove.
Two things follow from that. The preview loads inside an iframe so the admin panel's own CSS cannot leak in and quietly make things look right that would not be right. And the width switcher constrains the iframe to fixed desktop, tablet and mobile widths rather than to whatever space the middle pane happens to have, because judging whether a headline wraps is worthless at an arbitrary viewport.
What a benchmark cannot tell you
Every number in the spike passed. Render cost, fidelity, scroll preservation, the width switcher, twelve real sections in two languages.
And I still wrote this in the task file when I closed it:
Still unproven: whether it feels live to a person. Nobody has typed into it yet.
Sixteen milliseconds of render sits inside a chain of field debounce, a Livewire round trip, a fetch, and a DOM swap. The numbers say it should feel immediate. Numbers are not the same claim as "it feels live", and the gate was written about the feeling, not the milliseconds.
It does feel live, as it turns out. But that was checked by a person typing into it, not by curl.
Building scalable systems and developer-first tools. Lead Software Engineer at DSRPT.
Frequently asked
-
Measure before deciding. A twelve-section page rendered in 16ms mean and 31ms worst of ten on the PHP built-in dev server with no opcache tuning, against a typing debounce of 500ms. At that ratio the render is a rounding error inside the interval it sits in, and per-block rendering would have been a substantial amount of machinery built for no measurable gain. Per-block rendering is still the right answer for a genuinely heavy page, one pulling large collections or doing real work per section, but that is a decision to make when a page gets slow rather than on the assumption that it will.
-
Do not reload it. Fetch the preview URL, parse the response with DOMParser, and replace the inner HTML of a single container inside the iframe document. Because the document itself never reloads, the scroll position is simply still there, and the stylesheet is never re-fetched so there is no flash of unstyled content. Reloading the iframe and then saving and restoring scrollY is both more code and a worse experience, since the user sees the page blank and rebuild on every pause in typing.
-
Because a separate editor view is an approximation, and an approximation is exactly what the feature is supposed to replace. If the preview uses different markup, different CSS or a different layout, then sooner or later something looks right in the editor and wrong on the live site, and from that point the client stops trusting the preview and opens the real site in another tab. Pointing the preview at a route that loads the same Blade views, the same layout and the same compiled stylesheet, differing only in whether it reads the draft or the published tree, is what makes the preview worth having.
-
Isolation. Rendered inline, the admin panel's stylesheet applies to the preview, so the page can look correct in the editor purely because of styles that will not exist on the public site. An iframe gives the preview its own document, its own stylesheet and its own viewport width, which also makes a device width switcher meaningful: constraining the frame to a fixed mobile width shows real breakpoint behaviour, whereas constraining a div to a narrow column inside a panel shows something that merely resembles it.