First-Frame Critical Path
also called First-Screen Blocking Set, Launch Critical Set
The subset of code and data that must execute before a screen can draw anything, which decides how slow an app feels at launch regardless of how large or fast the rest of it is.
A team cuts their app's bundle from 9 MB to 5 MB and launch feels identical. Another adds 400 KB and launch gets noticeably worse. Both results are consistent, because total size is not what the user waits for. They wait for the serial chain that must finish before the first pixel of real content appears: parse, evaluate, resolve the first screen's data, lay out, paint.
Work on that chain is paid on every cold launch; work off it is paid in parallel, later, or never. The question is not how big the app is but what is on the chain, and the two numbers move independently.
Why it matters
Cold launch is where users abandon, and it has the least parallelism available: one thread owns the pixel pipeline, so anything it does before painting is user-visible delay.
The cost compounds through ordinary feature work. An analytics SDK initialised at startup, a feature-flag fetch awaited before first render, an eagerly built dependency graph, a synchronous font load - each adds tens of milliseconds, added by someone who did not know they were on the chain. Nobody decides to have a slow launch; it accumulates one reasonable import at a time, which is why it needs an owned budget rather than periodic clean-ups.
Implementation patterns
- Split the bundle by what the first frame needs.
- Move work off the rendering thread - telemetry, network calls, dependency wiring. The test is whether removing it changes what the first frame looks like.
- Render from local state, never from a network response - drawable from disk with the data's age shown, then refreshed when the fetch lands.
- Assert it in CI: main-thread bundle size and time-to-first-content on a fixed mid-range device.
- Audit third-party initialisation.
Industry example
ByteDance open-sourced Lynx in 2025, a framework for native cross-platform screens built from web markup, CSS and JavaScript, and its published design is organised around this chain. Lynx runs two threads: a main thread owning the pixel pipeline - main-thread scripts, layout, rendering - and a background thread for work that does not touch the display. The main thread runs PrimJS, a lightweight engine derived from QuickJS.
The project calls its launch behaviour Instant First-Frame Rendering, briefly blocking the main thread until the first frame is fully rendered to eliminate blank screens. That inverts the usual advice never to block, and it only pays because the main-thread work was made small enough that blocking is shorter than a skeleton would be visible.
Failure scenarios
- An awaited configuration fetch before first render, making launch a function of the user's connection.
- Eager dependency-graph construction, wiring 200 objects to render a screen needing four.
- Several third-party SDKs initialising on the main thread, none owned by the app team.
- Measuring on a flagship, where the chain is 180 ms and the regression is invisible while the tenth-percentile device pays 900 ms.
Trade-offs
| Choose | Gains | Pays |
|---|---|---|
| Lean first-frame bundle | fastest perceived launch - regressions catchable in CI | build complexity - a second load later - a visible jump when deferred code lands |
| One eager bundle | simple build - no load states to design | launch cost grows forever - every feature taxes every user |
| Block until the first frame draws | no skeleton flash | safe only if the chain is short |
When not to use it
When launch is not the complaint. If the problem is a slow checkout or a janky list, this work has no user-visible return.
When the first screen is meaningless without a server answer - a live auction, a search page reached by deep link. Spend the effort on that round trip and an honest loading state, because local work is already dwarfed by the fetch.
And when the install base is uniformly fast hardware. The budget is worth owning in proportion to how slow your tenth-percentile device is, so measure that before acting.
Interview question
Q: Cold launch is 1.4 seconds at the tenth percentile of devices and 300 ms on your own phone. The bundle is 7 MB. Product wants under 800 ms. How do you find the work that matters, what do you move, and how do you stop it coming back?
What a strong answer covers: profiling on a representative low-end device rather than reasoning from bundle size · listing the serial pre-paint chain and its usual occupants - awaited remote config, eager wiring, third-party initialisation, synchronous fonts · rendering from on-disk state with an age indicator · splitting by first-frame need rather than by feature · and a CI assertion on main-thread bundle size, because without a failing build the gains are reclaimed within two quarters.
Quick check
Quiz: A team halves total bundle size and launch time does not change. What did they not change? — The serial work before first paint. The two numbers move independently, so they shrank code that was never on the chain while an awaited config fetch still is.
Flashcard: Why can blocking until the first frame is drawn feel faster than a skeleton? — A skeleton costs a layout pass and a placeholder flash; once the chain is short enough, blocking a few milliseconds shows real content sooner.