+1 (405) 383-8943

Initial load performance in a mature Ember app

Most performance complaints we hear about mature Ember apps are about the first few seconds. The app itself feels fine once it is running; getting it running is what people notice. That is usually not a framework problem. It is the arithmetic of ten years of features arriving in a single bundle, plus a handful of vendor dependencies that nobody has re-examined since they were added.

This work is worth doing in a specific order, because the cheap steps often make the expensive ones unnecessary.

Measure before you form an opinion

Start with numbers you can repeat. Three of them are enough to begin:

  • Transferred bytes of JS and CSS on a cold load, from the Network panel, throttled to something like Fast 3G so the differences are visible.
  • Largest Contentful Paint and Total Blocking Time from a Lighthouse run, or from real-user data if you already collect it.
  • Bundle composition, from ember build --environment=production --stats-file (or the equivalent bundle analyzer output your build supports).

Write them down in the repo. Every step below gets judged against that baseline, and without it you will end up arguing about impressions.

If you have real-user monitoring, look at the percentile distribution rather than the median. Slow first loads in mature apps are frequently concentrated: admin users on one heavy route, or one geography served from the wrong edge. That changes what is worth fixing.

Read the bundle before changing the build

Open the stats output and sort by size. In the apps we see, the top of that list is rarely application code. It is usually four or five familiar things: a moment locale set nobody needed, a chart library pulled in at the top level for two dashboards, an icon package shipping every icon, a full lodash import, a copy of a date library that arrived twice through two different addons.

Each of those is a small, self-contained change with no architectural risk:

  • Replace top-level import _ from 'lodash' with per-function imports, or drop the dependency where a language builtin now does the job.
  • Trim locale and icon sets to what you actually render.
  • Deduplicate libraries that arrived through two addons at different versions. npm ls <package> will show you the tree.
  • Delete dependencies for features that were retired years ago. There are usually some.

We have seen this pass alone cut a third of the payload on an eight-year-old app in a week, with no change to routing or build configuration. Do it first. It is also the pass that makes the remaining numbers legible.

Then split by route

When the easy weight is gone and the bundle is still large, splitting is the next lever. On Embroider this is route splitting: with staticComponents, staticHelpers, and splitAtRoutes configured, the code behind a route tree ships as its own chunk and loads when someone visits it. Our Embroider post covers earning those flags; the point here is that route splitting is where the build work pays a user-visible dividend.

Pick the split points by usage, not by size. A large route that every user hits on login is not a candidate. The reporting section that four people open monthly is. Admin areas, billing, onboarding flows, anything behind a rarely used feature flag — those are where splitting is nearly free.

If your app already uses Ember Engines, lazy engines give you the same effect at a coarser boundary, and you may already have the seams in place without knowing it. Check whether your engines are actually mounted lazily; more than once we have found an app carrying all the complexity of engines while eagerly loading every one.

Defer the code that is not needed for first paint

Some weight does not belong in a route chunk either. Editors, charting, PDF generation, map SDKs, analytics bundles: these can be loaded on demand with a dynamic import() at the point of use, behind a loading state. The pattern is unremarkable and the win is often large, because these libraries are frequently the single biggest entries in the stats file.

Third-party scripts deserve their own review. Tag managers and session-recording scripts added over a decade tend to accumulate, and they compete with your app for main-thread time during exactly the window you are trying to improve. That list is usually shorter after someone asks who owns each one.

Separate bundle cost from render cost

If LCP stays poor after the payload drops, the problem has moved and your tooling should too. Record a performance profile and look at what happens after the JS has parsed.

Common findings, in rough order of frequency: a route model hook awaiting several requests in series that could run in parallel; an initial screen rendering a few thousand rows without pagination or virtualization; expensive getters recomputing on every render because they depend on values that change more often than necessary; and, in apps still carrying classic code, long observer or computed-property chains firing repeatedly during setup.

These are application fixes, not build fixes. Fetch in parallel, paginate or virtualize the big lists, and move heavy derivation out of the render path. Ember's rendering layer is fast; what is usually slow is the work we hand it.

Hold the gains

Performance regresses silently unless something watches. Two mechanisms are enough for most teams: a bundle-size budget checked in CI that fails the build when a chunk grows past an agreed threshold, and a scheduled Lighthouse run on a representative route with the result posted where the team sees it. Neither needs to be elaborate. Both need to be automatic, because nobody re-reads a stats file voluntarily.

When to leave it alone

Sometimes the honest answer is that this is not your problem. If your app is internal, used all day by people who load it once each morning on a corporate network, a two-second improvement in cold start is not worth a quarter of engineering time — fix the slow report screen instead. And if you are mid-migration off Ember, weigh carefully: work on routes you plan to retire within the year is work you will throw away. Spend it on the routes that are staying.

The order, in short

  1. Baseline the numbers and commit them.
  2. Audit dependencies and delete or trim the obvious weight.
  3. Split rarely visited route trees, if Embroider configuration allows it.
  4. Defer heavy libraries to the moment of use.
  5. Profile rendering and fix serial fetches and unpaginated lists.
  6. Add a budget in CI so it stays fixed.

Steps one and two often deliver most of the improvement for a fraction of the effort. Teams that start at step three tend to spend a month on build configuration before learning they were shipping every icon in the set.