Speed Lab: blocking, deferred and async scripts
The same two scripts placed five ways: blocking in the head, defer, async, a module and the end of the body. First paint, blocking time and when the code runs.
In this guide
The question
A script in the head of a page normally stops the browser from reading any further until the script has downloaded and run. The defer and async attributes, module scripts and the old habit of putting scripts at the end of the page all exist to avoid that. The guide to defer, async and modules explains what each does. This test puts a number on it: how much does the placement of a script change when the first content appears, and what happens to the work the script does?
The page and the scripts
The test page holds only text: a header, a heading, three paragraphs, a short list and a small external style sheet. With no images, the biggest thing on the screen is text, and it is painted together with everything else. Two scripts sit beside it, and every variant uses the same files:
- `lib.js`: 59,409 bytes that do nothing useful when they run. It stands for a library.
- `app.js`: 125 bytes that keep the main thread busy for 120 ms, then set an attribute on the page. It stands for the work a real script does when it starts up.
const t = performance.now();
while (performance.now() - t < 120) {}
document.documentElement.setAttribute('data-ready', '1');I ran it as described in how the Speed Lab tests are run: nine Lighthouse runs per variant, taken in turns, on a local server, with Lighthouse's mobile settings.
The five variants
<!-- a. blocking, in the head -->
<script src="lib.js"></script><script src="app.js"></script>
<!-- b. defer, in the head -->
<script defer src="lib.js"></script><script defer src="app.js"></script>
<!-- c. async, in the head -->
<script async src="lib.js"></script><script async src="app.js"></script>
<!-- d. blocking, at the end of the body -->
<script src="lib.js"></script><script src="app.js"></script>
<!-- e. module, in the head -->
<script type="module" src="lib.js"></script><script type="module" src="app.js"></script>Results
The first and the largest paint were the same moment, so one column shows both. Total Blocking Time is Lighthouse's sum of the time over 50 ms in main-thread tasks after the first paint.
| Variant | First paint, median (lowest to highest) | Total Blocking Time, median (lowest to highest) |
|---|---|---|
| a. Blocking, in the head | 1,706 ms (1,678 to 1,735) | 0 ms (0 to 0) |
b. defer | 1,220 ms (1,211 to 1,236) | 77 ms (74 to 78) |
c. async | 1,223 ms (1,211 to 1,249) | 70 ms (63 to 72) |
| d. Blocking, at the end of the body | 1,220 ms (1,206 to 1,240) | 73 ms (72 to 75) |
| e. Module | 1,218 ms (1,208 to 1,284) | 75 ms (73 to 79) |
The page was 60.4 KiB in every variant, and Cumulative Layout Shift was 0.
Four things stand out.
- The blocking script in the head cost 486 ms, a 40% longer wait for the first content. Its range does not overlap with any other.
- The other four are the same.
defer,async, the module and the end of the body painted within 5 ms of each other, which is noise. - The code ran in every variant. Nothing was lost; the work only moved.
- The slowest page has the best blocking-time score. Variant a shows 0 ms of Total Blocking Time and the others 70 to 77 ms.
When things happened
The Lighthouse timeline and its list of long tasks explain it. These are medians, in milliseconds.
| Variant | lib.js finished | The 120 ms task started | First paint | Priority of the scripts |
|---|---|---|---|---|
| a. Blocking, in the head | 1,512 | 1,528 | 1,706 | High |
b. defer | 1,507 | 1,527 | 1,220 | Low |
c. async | 1,509 | 1,220 | 1,223 | Low |
| d. End of the body | 1,501 | 1,522 | 1,220 | High |
| e. Module | 1,505 | 1,523 | 1,218 | High |
The style sheet finished at about 1,185 ms in every variant. That is when the page could paint.
In variant a, the parser stops at the first script. The page cannot paint until lib.js has arrived at 1,512 ms and app.js has run for 123 ms, which ends around 1,651 ms. The paint follows at 1,706 ms. Everything the visitor could have read at 1,220 ms waited about half a second for code that has nothing to do with reading it.
In the other variants the browser paints as soon as the style sheet is there. The scripts finish downloading at about 1,500 ms, and the 120 ms task runs after the paint, so the visitor already has the text.
Why Total Blocking Time misleads here
Lighthouse counts blocking time only between the first paint and the point where the page is quiet. In variant a the long task ran before the first paint, so it does not count. The metric reports a good number for the page that made visitors wait the longest. This is why a score should never be read alone. See Total Blocking Time and Speed Index.
The async variant
Variant c painted on time, but with a caution. An async script runs the moment it arrives. Here app.js arrived at 1,201 ms, 16 ms after the style sheet, and its task started at 1,220 ms, next to the paint at 1,223 ms. If it had arrived a little earlier, the 120 ms task could have come before the paint and delayed it. That is my reasoning from the timeline, and I did not test it. It also means async scripts run in whatever order they arrive: app.js ran before lib.js had finished here. That is fine for scripts that depend on nothing, and breaks code that expects its library to be loaded first.
The same test on a shaped network
Here are the five variants again on the shaped network of the test of many small files, five runs each, with the first paint of the usual throttling beside it.
| Variant | Usual throttling | Shaped network |
|---|---|---|
| a. Blocking, in the head | 1,706 ms | 1,225 ms (1,205 to 1,279) |
b. defer | 1,220 ms | 588 ms (559 to 611) |
c. async | 1,223 ms | 570 ms (548 to 584) |
| d. Blocking, at the end of the body | 1,220 ms | 567 ms (560 to 609) |
| e. Module | 1,218 ms | 582 ms (548 to 591) |
The picture is the same and the cost is larger: the blocking script in the head delayed the first paint by 637 ms (1,225 ms against 588 ms), where the usual throttling found 486 ms. The other four variants are equal here too. The reason is the size of the script. lib.js is 59 KB and was sent uncompressed. On the shaped network a new connection starts slowly, so the script arrived at about 1,024 ms, roughly 500 ms after the style sheet, against 327 ms after it with the usual throttling, which has no slow start. A smaller script, or a compressed one, would cost less.
What this test does not show
- A stand-in for work. The busy loop is always 120 ms. A real script's cost varies with the page and the device.
- One small library. A larger one makes the blocking delay longer, because the page waits for the whole download.
- A fast style sheet. The paint was ready at about 1,185 ms. With a slower style sheet, the scripts would matter less for the first paint.
- Scripts that must run first. Some tools ask for a blocking script, so that visitors do not see a flash of the original page before the tool changes it. That choice costs the delay measured here.
- Dependencies. The two scripts do not depend on each other, so
asyncwas safe. Real ones often do. - Other browsers and networks. See the method page.
What to do
- Find the blocking scripts. The insight called "Render-blocking requests" in the audit lists them. In my runs it listed three requests for variant a (both scripts and the style sheet) and only the style sheet for
defer. For the script at the end of the body it also namedapp.js, although the first paint did not wait for it, so treat the list as candidates and check the timeline. - Use `defer` for scripts that need the page or each other. It keeps their order, and they run after the page is read.
- Use `async` for scripts that depend on nothing, such as a stand-alone tag. Remember that they run when they arrive.
- Treat a script at the end of the body as a second choice. It painted as well as
deferhere, but it blocks the parser at that point and runs in order only by position. - Look at the work, not only the download. Variants b to e all spent 70 to 77 ms of blocking time. Cutting what a script does is the next step. See break up long tasks.
- Ask what each blocking tag is for. If it protects against a flash, test whether the flash matters more than half a second.
Run it yourself
Make five folders, one per variant, each with the same style.css, lib.js and app.js and an index.html that differs only in the script tags above. Use the script from the lazy-loading lab with one change: read first-contentful-paint instead of largest-contentful-paint. To see the long tasks, open a Lighthouse report's list of long tasks, or record the load in the Performance panel. See Chrome DevTools Performance panel.
Mistakes to avoid
- A normal script in the head because the tool's instructions say so. Check whether
deferworks. - `async` on scripts that depend on one another.
- Reading a good Total Blocking Time as proof of a fast page. Check the first paint.
- Moving every script to the end of the body and stopping there. The work is still done on the main thread.
- Judging by one run.
Check your own page
Run the free audit and open the render-blocking insight. For each script it lists, try defer and run the audit again. Compare the First Contentful Paint and the Total Blocking Time together.
Sources
- The script element on MDN:
async,deferandtype="module", and when each script runs. - Total Blocking Time (TBT) on web.dev: the definition of the metric, and the window it covers.
- The earlier guides linked above, which carry the sources for the attributes and the metrics.
Start here
Free website audit
Live Google data for any public page: mobile and desktop.
Mobile + desktop · Lighthouse lab test · Real-user Chrome data when Google has it
Rather have this done for you? Speed optimization · Book a call