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.
5 min read
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.
Last updated 17 Aug 2026 · filed under Laravel, blade, filamentphp, plugin, alpinejs, livewire, ui, ux