I run a small tool site built on plain PHP and vanilla JavaScript — no framework, no build step, no bundler. The pages are simple: a link box, a quality dropdown, a download button, and a few paragraphs of guide text. So when I started looking at why the pages felt slow to become usable, the answer was embarrassing in its simplicity: my own scripts were in the way.
The problem with scripts in the head
A normal script tag blocks rendering. The browser reads the HTML top to bottom, hits a <script src="...">, and stops painting anything until that file has downloaded and run. On a page with a few hundred words of content and one small script, that is still enough to make the page feel dead on a slow connection: blank screen, then everything at once.
The usual fix is defer. A deferred script still downloads in parallel with the HTML, but it only runs after the document has finished parsing, in the order the scripts appear. The page paints first; the JavaScript wakes up afterwards. It sounds like a one-word fix, and it mostly is — but only if your JavaScript is written to survive it.
What actually broke
My app was written the way a lot of vanilla JS gets written: a script at the bottom of the body that grabs elements by id and wires up listeners immediately.
document.getElementById('download-btn').addEventListener('click', startDownload);
That code assumes the button already exists when it runs. A script at the bottom of the body can rely on that, because everything above it has been parsed. A deferred script in the head can rely on it too, for the same reason — deferred scripts run after parsing. So far, so safe.
What broke was subtler: a second script. I had a small analytics snippet and a language-selector script that both assumed they ran before the main app script, because they appeared earlier in the document. With plain script tags, document order is execution order. With defer, document order is still execution order — but only among deferred scripts. The moment I mixed deferred and non-deferred scripts, that guarantee quietly vanished, and one script started calling a function the other had not defined yet.
The rule I settled on is boring and total: every script on the page is deferred, or none of them is. No mixing.
The one thing defer cannot do
defer does not help with scripts that must run before the page renders. I do not have any of those, but plenty of sites do: a script that sets a theme class on <html> before the first paint, or one that hides a cookie banner's flash. If a script's whole job is to change what the first paint looks like, deferring it just moves the flicker to after the paint. Leave those inline and tiny, in the head, and defer everything else.
The other gotcha is async. It looks like defer's twin, but it is not. An async script runs the moment it finishes downloading, in no particular order, and possibly before the document is parsed. That is fine for an independent tracker. It is a trap for any script that touches the DOM or depends on another script. I use async for exactly one thing — the analytics snippet — and everything else is defer.
What I actually changed
The change itself was three lines. Each script tag on ramrom.io went from this:
<script src="/js/app.js"></script>
to this:
<script src="/js/app.js" defer></script>
Then I checked the things the attribute could have broken, in order:
- Event listeners still attach. They do — deferred scripts run after parsing, so every element exists.
- Scripts still run in order. They do, as long as they are all deferred.
- Nothing reads the DOM before it exists. There was one inline snippet that grabbed an element by id and ran immediately. I moved it into the main app script, which runs after parsing anyway.
- The page works with JavaScript disabled. It never did — a downloader needs JS to call the API — but the guide text and headings now render before any script runs, which is the honest part of the experience.
The honest result
The page paints before the JavaScript runs. That is the whole win, and it is a real one: the first thing a visitor sees is content, not a blank screen waiting on a script. There is no magic here, no framework feature, no build tool. Just a keyword attribute, a rule about not mixing script types, and twenty minutes of checking the assumptions my code had quietly been making about when it runs.
If your site is vanilla JS and your scripts live at the bottom of the body, defer is the cheapest performance improvement you will ever make. Just make sure every script on the page agrees on the rules first.
Top comments (0)