Drop the font preload and defer app.js

Measured on a quiet machine behind a gzip proxy, three interleaved rounds
per variant: with the preload FCP 1.8 s, LCP 2.6 s, perf 0.95-0.96;
without it FCP 0.97 s, LCP 0.97 s, perf 1.00.

Lantern splits bandwidth between in-flight requests, so preloading the
132 KB regular face starved the 12 KB render-blocking stylesheet — and
the stylesheet, not the font, is what first paint waits on. The
metric-matched fallbacks already make the wait for the real face
invisible, so the preload bought nothing that first paint could see.

app.js gets `defer` for the same reason: Lighthouse counts the sync tag
among the render-blocking requests. It stays at the end of body, and it
is plain top-level DOM code with no readyState or DOMContentLoaded
dependence, so execution order is unchanged.

theme.js keeps its place: still synchronous, still after the stylesheet
link, so Gecko cannot paint unstyled.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
This commit is contained in:
2026-09-04 20:08:42 +00:00
co-authored by Claude Fable 5.1
parent 62803d73a6
commit 2388d7bd02
2 changed files with 22 additions and 15 deletions
+16 -8
View File
@@ -236,8 +236,9 @@ const NEWSREADER_ITALIC: &[u8] = include_bytes!("static/fonts/Newsreader-italic.
/// ///
/// So the URLs stay URLs, carrying `?v=<ASSET_VERSION>` so the immutable /// So the URLs stay URLs, carrying `?v=<ASSET_VERSION>` so the immutable
/// one-year `max-age` on `/static/*` is safe across deploys. The fonts are part /// one-year `max-age` on `/static/*` is safe across deploys. The fonts are part
/// of the `ASSET_VERSION` hash, so a new face mints a new URL. `layout.html` /// of the `ASSET_VERSION` hash, so a new face mints a new URL. Neither face is
/// preloads the regular face, and the faces use `font-display: swap` behind /// preloaded — that only takes bandwidth from this sheet, which is what first
/// paint actually waits on. Instead both use `font-display: swap` behind
/// metric-matched local fallbacks, so first paint is immediate and the swap /// metric-matched local fallbacks, so first paint is immediate and the swap
/// shifts nothing. /// shifts nothing.
pub static APP_CSS: LazyLock<String> = LazyLock::new(|| { pub static APP_CSS: LazyLock<String> = LazyLock::new(|| {
@@ -1123,14 +1124,21 @@ mod tests {
let expected = format!("/static/app.css?v={}", ASSET_VERSION.as_str()); let expected = format!("/static/app.css?v={}", ASSET_VERSION.as_str());
assert!(html.contains(&expected), "{html}"); assert!(html.contains(&expected), "{html}");
assert!(!html.contains(&format!("/static/app.css?v={}", crate::VERSION))); assert!(!html.contains(&format!("/static/app.css?v={}", crate::VERSION)));
// The regular face is preloaded so it starts downloading alongside the // Nothing is preloaded. A preload of the 132 KB regular face shares
// stylesheet that declares it; the italic face is left to load on demand. // bandwidth with the 12 KB render-blocking stylesheet, which is what
let preload = format!( // first paint actually waits on: it pushed simulated FCP from ~1.0 s to
"<link rel=\"preload\" href=\"/static/Newsreader.woff2?v={}\" as=\"font\" type=\"font/woff2\" crossorigin>", // ~1.8 s on Lighthouse mobile, for a face the metric-matched fallbacks
// already stand in for.
assert!(!html.contains("rel=\"preload\""), "{html}");
// The italic face is never referenced from the HTML either.
assert!(!html.contains("Newsreader-italic.woff2"), "{html}");
// `defer` keeps app.js out of Lighthouse's render-blocking list; it has
// no readyState or DOMContentLoaded dependence, so deferring is safe.
let app_js = format!(
"<script defer src=\"/static/app.js?v={}\"></script>",
ASSET_VERSION.as_str() ASSET_VERSION.as_str()
); );
assert!(html.contains(&preload), "{html}"); assert!(html.contains(&app_js), "{html}");
assert!(!html.contains("Newsreader-italic.woff2"), "{html}");
} }
#[test] #[test]
+6 -7
View File
@@ -6,12 +6,11 @@
<meta name="description" content="{{ page.description }}"> <meta name="description" content="{{ page.description }}">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<title>{{ page.title }} · The Daily EPUB</title> <title>{{ page.title }} · The Daily EPUB</title>
{# Start the regular face downloading with the stylesheet rather than after {# No font preload here on purpose. Preloading the 132 KB regular face made
it parses. `crossorigin` is required even same-origin: font fetches are it share bandwidth with this 12 KB render-blocking sheet, so the sheet —
CORS-mode, so without it the preload would not match the @font-face and therefore first paint — landed later: simulated FCP went from ~1.0 s
request and the file would be fetched twice. Only the regular face — to ~1.8 s on Lighthouse mobile. The metric-matched fallbacks in
italic is rare enough that on-demand loading is the right trade. #} tailwind.css make waiting for the real face cost nothing visible. #}
<link rel="preload" href="/static/Newsreader.woff2?v={{ page.asset_version }}" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/static/app.css?v={{ page.asset_version }}"> <link rel="stylesheet" href="/static/app.css?v={{ page.asset_version }}">
{# Keep this synchronous script *after* the stylesheet: Gecko will not run a {# Keep this synchronous script *after* the stylesheet: Gecko will not run a
parser-blocking script while a stylesheet is pending, so the parser stalls parser-blocking script while a stylesheet is pending, so the parser stalls
@@ -58,6 +57,6 @@
{% match page.flash %}{% when Some with (flash) %}<div class="mx-auto max-w-7xl px-4 sm:px-6"><div class="flash {{ flash.kind }}" role="status">{{ flash.text }}</div></div>{% when None %}{% endmatch %} {% match page.flash %}{% when Some with (flash) %}<div class="mx-auto max-w-7xl px-4 sm:px-6"><div class="flash {{ flash.kind }}" role="status">{{ flash.text }}</div></div>{% when None %}{% endmatch %}
<main id="content" class="min-h-[68vh]">{% block content %}{% endblock %}</main> <main id="content" class="min-h-[68vh]">{% block content %}{% endblock %}</main>
<footer class="mx-auto mt-16 flex max-w-7xl flex-wrap justify-between gap-2 border-t border-rule px-4 py-6 font-sans text-[0.72rem] uppercase tracking-[0.1em] text-muted sm:px-6"><span>The Daily EPUB</span><span>daily-epub {{ page.version }}</span></footer> <footer class="mx-auto mt-16 flex max-w-7xl flex-wrap justify-between gap-2 border-t border-rule px-4 py-6 font-sans text-[0.72rem] uppercase tracking-[0.1em] text-muted sm:px-6"><span>The Daily EPUB</span><span>daily-epub {{ page.version }}</span></footer>
<script src="/static/app.js?v={{ page.asset_version }}"></script> <script defer src="/static/app.js?v={{ page.asset_version }}"></script>
</body> </body>
</html> </html>