Preventing UI Flash with Blocking Inline Scripts
Deferred scripts run too late to prevent a flash of wrong UI state on page load. A small inline script fixes it by running synchronously before the first paint.
I was building a view toggle on this blog: list view and tile view, persisted to localStorage. When I reloaded the page, the buttons would flash to their default state before correcting to the saved one. It was brief but visible. I had assumed the module script handling the toggle would run early enough to prevent it. It does not, and understanding why changed how I think about script execution timing.
The Problem
When you persist UI state to localStorage, a selected theme, a view preference, a sidebar toggle, you encounter a timing problem on page load. The HTML renders with its default state. Then JavaScript runs, reads localStorage, and updates the UI. The user sees the wrong state for a moment before it corrects itself. This is called a flash of wrong state, or FOWS.
The reason is in how modern scripts are loaded. JavaScript bundlers emit <script type="module"> tags. Module scripts are deferred by default, meaning they execute only after the HTML has been fully parsed and the page has been laid out. Adding async to a module script changes this, causing it to execute as soon as it is fetched, but the standard module behavior is deferred. By the time the script reads localStorage and applies the correct class, the browser has already painted the default state.
<!-- This runs too late. The page has already painted. -->
<script type="module">
const view = localStorage.getItem('preferred-view') ?? 'grid';
applyView(view);
</script>
The defer attribute on a classic script produces the same result: execution is pushed to after parsing. Note that defer only applies to external scripts with a src attribute. On an inline classic script it has no effect, but the point stands: any script that runs after layout has already lost the race against the first paint.
The Fix
A plain <script> tag without type="module", defer, or async runs synchronously as the browser parses the HTML. It blocks rendering for its entire duration. That blocking behaviour, which is normally something to avoid for performance reasons, is exactly what prevents the flash.
Place the script immediately after the element it controls. By the time the parser reaches the script tag, the preceding element exists in the DOM. The browser has not yet painted, so any classes applied are reflected in the first paint.
<ul id="results">
<!-- list items -->
</ul>
<script>
(function () {
var view = localStorage.getItem('preferred-view') ?? 'grid';
if (view === 'list') {
var el = document.getElementById('results');
if (el) el.classList.add('view-list');
}
})();
</script>
The same pattern applies to buttons that reflect the active state:
<button id="btn-grid" class="view-btn view-btn--active">Grid</button>
<button id="btn-list" class="view-btn">List</button>
<script>
(function () {
var view = localStorage.getItem('preferred-view') ?? 'grid';
var gridBtn = document.getElementById('btn-grid');
var listBtn = document.getElementById('btn-list');
if (!gridBtn || !listBtn) return;
gridBtn.classList.toggle('view-btn--active', view === 'grid');
listBtn.classList.toggle('view-btn--active', view === 'list');
})();
</script>
In Astro
Astro processes every <script> tag as a module by default, bundling it, enabling TypeScript, and deferring its execution. To opt out entirely, use the is:inline directive:
<script is:inline>
(function () {
var view = localStorage.getItem('preferred-view') ?? 'grid';
var gridBtn = document.getElementById('btn-grid');
var listBtn = document.getElementById('btn-list');
if (!gridBtn || !listBtn) return;
gridBtn.classList.toggle('view-btn--active', view === 'grid');
listBtn.classList.toggle('view-btn--active', view === 'list');
})();
</script>
is:inline tells Astro to emit the script exactly as written: no bundling, no TypeScript processing, no deferral, and no deduplication if the component is used multiple times. It runs synchronously and reads localStorage before the browser paints.
The equivalent in other frameworks follows the same principle. In Next.js Pages Router, a raw <script> tag can be placed in _document.tsx via dangerouslySetInnerHTML. In the App Router, it goes in the root layout.tsx inside <head>. In SvelteKit, it goes in app.html before %sveltekit.head% to ensure it runs before the framework’s own scripts.
Rules
Keep the script small. It blocks rendering for its entire duration. Any expensive computation here directly delays the first paint. The script should do nothing more than read a value and apply a class or attribute.
Use var instead of const or let. Inside a type="module" script, even var is scoped to the module. But in a plain inline script, var declarations become properties of the global object. An IIFE prevents that. const and let are block-scoped and do not leak globally, but using var inside an IIFE keeps the intent clear: this is not a module, it is a synchronous patch.
Wrap in an IIFE. Without a module scope, var declarations in an inline script attach to window. An immediately invoked function expression contains them within its own function scope.
Place the script immediately after the elements it controls. Elements that appear after the script tag in the HTML have not yet been parsed and are not available in the DOM when the script runs.
Do not make asynchronous calls. Any asynchronous operation inside a blocking script still runs asynchronously. Only synchronous reads, localStorage, cookies, attributes, have any effect before the first paint.
Where This Already Exists
The most common application of this pattern is dark mode. Any site with a theme toggle that does not flash on load uses a blocking inline script in <head> to set the correct attribute before any styled content is parsed:
<head>
<script>
(function () {
var stored = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var theme = stored ?? (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
</head>
Theme scripts go in <head> because document.documentElement must be available and the attribute must be set before any styled content is parsed. For elements further down the page, place the script immediately after the element rather than in <head>.
Before I understood this, I assumed all scripts ran early enough to influence the first paint. The assumption was wrong. The browser paints what it has, and deferred scripts arrive after that moment. The blocking inline script is not a workaround, it is the correct tool for synchronising stored state with the initial render. If you have a UI toggle persisted to localStorage and it flashes on load, add one now and see if that resolves it.