← Rakha Kanz KautsarWriting

The Performance Cost of Visual Instability

The page renders fast, but then it changes several times as it loads.

I captured these four states on Trip.com’s homepage. First there’s a placeholder and incomplete navigation. Then the hero and “List your property” appear, followed by the search field. Finally, “Secure payment” and “We’re here 24/7” change to three different guarantees.

Interactive demo loading. JavaScript is required to use it.

The two alternatives are illustrations, not tested implementations. Playback spacing is illustrative, not a measurement of loading time.

I looked into the page after capturing this. The general homepage content supplies the first two messages. The hotel-specific content supplies the three guarantees; switching between Hotels and Flights requests a different set for each. The page’s client code can load and apply that content after an initial version is already visible. I didn’t capture the original requests during this recording, so I can’t confirm the exact sequence for that load.

The search field is separately loaded too. The header waits for the search component and its resources before rendering it, which fits the late appearance in my screenshots. It’s a separately loaded module; I can’t tell whether Trip.com treats it as a micro-frontend or how its teams deploy it.

A fast first paint gets something on screen, but the page can still feel unfinished while its navigation and content keep changing. Sometimes the better experience comes from avoiding a state that will immediately be replaced, not from making a request faster.

I like how web.dev frames performance around the user: has anything appeared, is the content useful, and can I interact with it? Visual stability is part of that experience too. One metric can’t answer all of those questions.

Make the loading state look like the page it will become

When the final UI can’t be ready immediately, I want the loading state to leave room for it. In the Trip.com screenshots, the hero starts gray before turning blue, the search field arrives in an empty part of the header, and “List your property” pushes the App link aside.

In the comparison above, the middle version uses the final screenshot as a base: it reserves space for the search field and navigation item, and shows separate skeletons for the travel icons and promotional messages. The blue hero is already there. It assumes the hero is available early, but the neutral placeholders don’t assume the final messages are ready. The last version shows the final state from the start, if that were possible.

A placeholder still means waiting, but it can make the change less abrupt and keep the surrounding layout in place. I’d also make it clear when an interaction isn’t ready: a search-shaped skeleton is more honest than a search field that looks usable but ignores input. For the navigation item, reserving space or placing it at the edge of the group could keep existing links from moving.

Not every early state needs to match the final one. In my Shopee cart work, cached cart data was useful immediately, and the interface could signal that it might be stale while checking for updates. That early state helped users get on with their task. A temporary default that is immediately replaced may offer no comparable benefit.

The search field appearing and the guarantees changing are different kinds of update. One adds an interaction that wasn’t visible before. The other replaces messages that were already complete. If an update moves elements around, it can contribute to Cumulative Layout Shift (CLS). But the guarantees can change without moving the surrounding layout, and the user still sees one set of messages replaced by another. CLS alone won’t capture that flicker.

Feature flags can show the wrong version first

Trip.com’s content change isn’t evidence of a feature flag, but it reminded me of a similar flicker I’ve seen at Shopee, TikTok, and Hopper. The app paints before it has the flag value it should use. During an experiment, the treatment may arrive after the first frame. After a rollout, the default itself may be left behind.

I like feature flags. They let us experiment, deploy code separately from releasing it, roll changes out gradually, and turn a feature off when something goes wrong. But if the app renders one version while it waits for a flag and then switches to another, the user sees that decision happening. Used without care, a flag can make an otherwise fast page feel less settled.

Imagine an experiment starts with A as the default and B as the treatment. B wins and becomes permanent, but the application still starts with A. On every cold load, users see A before the flag resolves to B. The experiment is over, yet the temporary default keeps creating an unnecessary change. Updating the default and removing the obsolete flag can remove that path altogether.

What happens while flags load?

Removing a stale default can fix a finished rollout, but it won’t prevent flicker in the next experiment. I also look at what the app does while flags are loading.

The demo shows four first-render strategies in one purchase flow: server bootstrap, cache + SWR, a fallback, and waiting for flags. Assignment B adds express delivery and moves the purchase row. In Cache + SWR, replay after the first run to see the fresh assignment load immediately. The delay is simulated; the demo doesn’t measure CLS.

Interactive demo loading. JavaScript is required to use it.

1. Send flags with server-rendered HTML

If the server renders the page for each request, it can evaluate flags and put their values in the HTML. The browser has the assignment before the page paints. This works when the server has the identity and attributes the flag rules need, and can evaluate them without holding up the response.

Many apps don’t render a personalized page on the server. They serve the same static shell from a CDN, then build the page in the browser. That shell can’t contain a user-specific assignment. Adding server work just to inject flags may cost more than it saves. If you do bootstrap values, the server and client need to use the same ones or the UI can still switch during hydration. LaunchDarkly’s JavaScript guidance covers bootstrapping and waiting for initialization.

2. Reuse local values in a static shell

This is usually my go-to for a static app.

When the HTML can’t include the user’s assignment, I keep the last flag values in the browser’s local cache, under that user’s identity. On startup, the app reads that user’s saved values before rendering. It then asks the flag service for current values in the background. When the response comes back, the app updates the cache and uses the fresh values. That second request and update is what “revalidate” means here.

This is stale-while-revalidate (SWR): show the saved value now, check whether it’s current, then replace it in the cache and app if needed. A shared flag client can read and refresh values once for screens owned by different teams. It also helps during an experiment: returning visitors see their last assignment while the service checks for updates.

Keep a separate cache for each user and environment. If someone signs out or switches accounts, don’t reuse the previous user’s values. Do the same when a flag input changes, like region or entitlement. Otherwise the app can briefly show the wrong variant. A cache miss still needs a fallback.

For an experiment, decide whether a fresh response can move someone to another variant mid-session, and log exposure for the version they actually saw. For a kill switch, a cached “on” value can keep the feature on until revalidation finishes—or longer if the request fails. That may be too long for something meant to turn off risky behavior immediately.

3. Use a fallback, but expect it to be wrong sometimes

Without server values or a local cache, the app still needs something to render while the flag client starts. That’s often a hard-coded default. If it doesn’t match the user’s assignment, the app shows the fallback and then replaces it. This can happen with a brand-new experiment just as easily as with a forgotten flag. Changing one default only fixes that one case; a shared loading strategy covers more screens.

4. Wait when a wrong value is worse than a delay

You can hold the flag-controlled part of the page until the client is ready. A placeholder avoids showing the wrong variant and can reserve its space, although the user still has to wait for that part. I’d keep the rest of the page useful rather than hold the whole page for one flag-controlled button.

Clean up when the rollout is finished

You don’t have to find every stale flag by hand. PostHog documents cleanup workflows, and Reflag offers automated cleanup. The tool can help find old branches; the team still decides when it’s safe to remove them.

Give temporary flags an owner and cleanup date. Once the decision is final, make the winning behavior unconditional and remove the losing branch. Retire the remote flag when supported clients no longer need it; old mobile versions may stick around. Cleanup finishes the rollout, but it needn’t block a shared fix for first-render flicker.

I still want the control that feature flags give us, and I still want pages to show useful content as early as they can. But I also want to think about what happens after that first paint. If a component or assignment isn’t ready, can the page leave a stable space for it? If a rollout is finished, can we stop showing its old default? Sometimes making a page feel faster means giving it fewer unnecessary changes to make.