A page loads one stylesheet from its head, and that file begins with `@import url("theme.css")`. Why does first paint arrive later than if both files had been linked from the HTML head?
answer
- a dependency chain, not a parallel fetch
- the URL lives inside a CSS file
- scanner reads markup only
- two round trips before first paint
- fine at build time, costly at runtime
basics
~20 sAn @import is only discovered after the parent stylesheet has been downloaded and parsed, so the two requests run one after the other instead of in parallel. The preload scanner cannot see inside CSS, so nothing starts the second fetch early.
solid answer
~40 sBoth files are render-blocking either way, so first paint waits for the slower path. Two `<link>` elements in the head are found by the preload scanner in the document's first bytes and fetched in parallel, so the cost is roughly one round trip. With `@import`, the browser must download the parent sheet, parse it, discover the import, and only then request `theme.css` — two serialised round trips, and the scanner cannot help because it reads markup, not CSS. Chained imports multiply this. The fix is to hoist imports into `<link>` elements in the head, or to bundle the sheets at build time so there is only one request. `@import` is fine in a build-time toolchain where a bundler inlines it, and a runtime critical-path hazard when it survives into the shipped CSS.
code
css · 7 lines/* app.css — theme.css is only discovered after this file is parsed. */
@import url("theme.css");
.button {
color: var(--fg);
background: var(--accent);
}go deeper
Know that stylesheets referenced from the HTML head start downloading right away, and that a stylesheet referenced from inside another stylesheet cannot start until the first one has arrived.
Explain the discovery mechanism: the preload scanner reads markup, not CSS, so an @import is only found after its parent is downloaded and parsed, serialising two round trips ahead of first paint.
Diagnose it from a waterfall's staircase pattern, explain why HTTP/2 multiplexing does not help a dependency chain, and choose between hoisting to link elements and bundling at build time for the codebase in front of you.
Own the guardrail rather than the individual fix: how the build pipeline guarantees no runtime import reaches production, how third-party CSS is admitted, and what budget governs how many render-blocking requests a page may make.
## The shape of the problem Rendering waits for a complete CSSOM, and the CSSOM is not complete until every applicable stylesheet has arrived and been parsed. So the question is not *whether* both files block — they do — but how long the chain of requests to get them takes. ## Two links: parallel ```html <head> <link rel="stylesheet" href="/theme.css"> <link rel="stylesheet" href="/app.css"> </head> ``` Both URLs are literal attribute values near the start of the document. The preload scanner sees them in the very first bytes and issues both requests immediately, before the main parser has even reached the second tag. The two downloads overlap; the blocking window is roughly the duration of the slower one. ## One link plus an import: serialised ```html <head> <link rel="stylesheet" href="/app.css"> </head> ``` ```css /* app.css */ @import url("theme.css"); .button { color: var(--fg); } ``` The browser requests `app.css`. Nothing in the document mentions `theme.css` — the preload scanner reads markup, not stylesheets, so it has nothing to find. Only once `app.css` has fully arrived and been parsed does the CSS parser encounter the `@import` rule and issue the second request. Now the browser must wait for that one too before the CSSOM is complete. The result is a dependency chain: request → parse → request → parse. Each hop costs a full round trip, and on a high-latency connection latency dominates file size entirely. Two chained imports make three hops; a design system that imports a token file that imports a reset produces exactly that. ## Why the ordering rule makes it worse `@import` rules must appear before any style rules in a sheet (a `@charset` or `@layer` statement may precede them). That placement is deliberate: the cascade needs the imported rules to sit at that position in the sheet's rule order. It also means the browser cannot start applying anything from the parent sheet meaningfully until the import resolves, so there is no partial progress to bank. ## What the fix looks like - **Hoist to markup.** Replace the import with a `<link>` in the head. The URL is now discoverable and the fetches parallelise. This is a mechanical change and is usually the right answer for an existing page. - **Bundle at build time.** Let the build tool inline the imported file into a single stylesheet. One request, no chain, and no runtime import at all. Most CSS toolchains do this by default, which is why `@import` in source is unremarkable and `@import` in a shipped file is a smell. - **Demote it.** If the imported sheet genuinely is not needed for first paint, an import can carry a media query — `@import url("wide.css") (min-width: 1200px);` — so it stops gating paint when the query does not match. It still costs the serialised discovery, so this reduces the blocking window rather than the round trips. ## The one legitimate modern use `@import` gained the ability to assign an imported sheet to a cascade layer, as in `@import url("vendor.css") layer(vendor);`. That is a genuine capability a `<link>` cannot express directly, and it can be worth the round trip for third-party CSS you need to keep low in the cascade — but it is a deliberate trade, and it is still best made at build time where the toolchain can flatten it. ## Diagnosing it from a waterfall The signature is unmistakable: a stylesheet request whose start time lines up with the *end* of another stylesheet request, forming a staircase rather than a cluster. Anything on the critical path with that staircase shape is a discovery problem — the browser did not learn the URL early enough — and the repair is always the same in kind: make the URL visible in the markup, or remove the extra request entirely. ## How to frame the answer Say the mechanism, not just the rule of thumb. "Avoid `@import`" is folklore; "the URL is invisible to the preload scanner, so the requests serialise into a two-round-trip dependency chain in front of first paint" is the reason, and it generalises to every other late-discovered critical resource.
- Does giving the @import a media query remove the penalty?Only partly. A non-matching media query stops the imported sheet from gating first paint, which is the bigger win, but the sheet is still discovered only after its parent is parsed, so the serialised round trip remains. It reduces the blocking window rather than shortening the chain.
- How would you recognise this problem in a network waterfall without reading the CSS?By the staircase. Two stylesheet requests where the second starts almost exactly when the first finishes indicates the second URL was discovered by parsing the first. Parallel link elements instead produce a cluster of requests all starting near the beginning of the document.
- Is there any case where you would keep an @import in shipped CSS?Assigning a third-party sheet to a cascade layer with `@import url("vendor.css") layer(vendor);` is a capability a link element cannot express directly, so it can justify the round trip. Even then, prefer flattening it at build time if the toolchain supports it.
saying these in an interview costs you the question
- Says @import and link perform identically
- Thinks the preload scanner parses stylesheets
- Blames file size rather than serialised discovery
- Believes only the parent sheet blocks rendering
- Assumes HTTP/2 multiplexing removes the dependency chain