Largest Contentful Paint (LCP)

Hello Team,
Hope you are doing well!

Recently I have published my app on Shopify App Store. When insight section displaying the High LCP of 4.8s.
For achieving the “Built For Shopify” badge I need to reduce the LCP to < 2.5 s

After leveraging lazy loading, caching, reducing the blocking render calls and external Apis, I reached to 3.5s.
I have also compare the Load time of Shopify and iframe of embedded section which is taking 2.8s additional to load my app contents.
Could any one help me to reduce the LCP to achieve the badge “Built for Shopify”?
Note: I have already gone through every docs related to reducing the LCP provided under the Shopify DOCs.

Any help will be appreciated!
Thanks!
Annex Cloud

1 Like

I’d recommend looking at the debugging in the app bridge settings, Config

That might give you some clues.

How is your app hosted? Are you making an API calls when your app loads, if so, how long do these take?

1 Like

Hello @JordanFinners
Thanks for the solution.
Yes, My App is hosted on my own premises. And making my api calls on app load.
it is taking 2.7s
Note: This is Shopify Remix based App.
Thanks!
Annex Cloud (Jitesh)

Okay, you’ve got two options.
Change your UI so that the API call doesn’t block the first render
Or speed up the API call to be under 500ms, that will improve your LCP

If you’re using Shopify’s Remix boilerplate and running into Web Vitals issues, switching to Polaris Web Components for your UI can help improve performance.
I’ve been building a free library of ready-to-use blocks with them → https://polarisblocks.dev

1 Like

Hi guys, if we switch to using Polaris web component (Using Polaris web components) does it improve the LCP score?

Your LCP is score is a basic proxy for how fast your app is to load the first content to your users. Changing to web components may improve it a bit as there is likely less JavaScript than a full react app. However that is likely only ms of time.

Improving your applications loading patterns is going to be much more impactful.
Are any API requests slow and therefore slowing your app loading?
What are you using for hosting, is it close to your users or is that introducing additional latency? Is it serving up assets as fast as possible?
Can you change your pages so they load faster or put off any data loading until after?
Can you do any static generation or prerending?
Are you taking advantage of all Shopify new dev options, managed installs for example which is much faster

Hi @JordanFinners
Thank you for responding.
I will consider to upgrade the Web components.

My app is using Remix template and almost loader route, I’m using query Shopify Graphql Admin API, I think this is the main reason make our app loading slower.
Do you think if I move all the loader queries into the component side (client side), it will make the LCP score lower?

I hosted my app on Fly.io & separated to 3 machine for each region so I don’t think it’s the reason make our app LCP score slow.

I’m afraid there isn’t one simple change by itself, that will improve your LCP. Its understanding your whole application and architecture and changing its patterns.

But theres some questions to help get started:
How long does your app take to load?
How much of that time is API requests being made?
What does your network waterfall look like in the browser?

as I checked on the Chrome network tab, the app dashboard takes 3.7 - 4.3s to load the content

I’m not sure about this because this query is calling inside the remix app loader

This is the performance waterfall

Hello @JordanFinners,
Thank you for reply.


My app load time is 3.74s - 5.46s
Yes, I am using Shopify Remix boilerplate with Polaris web components.
Thanks!

What’s your TTFB right now? Ideally, it should be around 800ms–1200ms. If your server takes longer than that, then optimizing for LCP becomes very difficult.

Also, if you’re using Remix loaders on routes (meaning your pages are SSR, SSR pages will have store specific data and it means you can’t cache those pages in CDN ) then getting LCP under 2.5s consistently for all users is almost impossible. For example, if your servers are in the US and someone from Asia opens your site, their LCP will naturally be higher compared to someone in the US. This is why you’ll see unstable LCP graphs with very high and very low values.

What I did to fix this was remove SSR completely and switch to REST endpoints for loading data. Since the routes didn’t have user-specific data, I could cache the route files for everyone. That meant all users got the main files directly from the CDN. After that, I just fetched the dynamic data separately through REST endpoints, which could take longer, but it didn’t affect the initial LCP.

From my point of view, you will move the query to use the REST API & call it from the client side, is that correct? Is it more effective? Can you detail your way, it will help me and other developers can more understand then apply to our app.

I also investigate our server responding quite slow, it takes 2.5s - 5s for server response. I’m using Fly.io server and I separate 3 machines for each reagion but the result still low. I’m not sure what should I do now

Here’s how I approached it

I stopped rendering store-specific data on the page itself and turned every UI route into a plain “app shell.” Think of it like showing the frame of the page first, header, sidebar, placeholders, without any shop data inside. Because that HTML is exactly the same for every store, I can cache it at the CDN and serve it instantly. The moment the shell appears, I start calling my REST endpoints to fetch the real data and fill the placeholders. Even if those API calls take a second or two, the user already sees the page and statically rendered UI and with some loaders here and there.

Earlier my code looked like this:

// app/routes/dashboard.tsx
export async function loader({ request }: LoaderArgs) {
  // This is a slow database call
  const storeData = await getStoreDataFromDB();
  return json(storeData);
}

export default function Dashboard() {
  const storeData = useLoaderData<typeof loader>();
  return <div>{storeData.name}</div>;
}

The issue here is that the loader must finish before the page renders. If that DB/API call takes 2–3 seconds, the browser has nothing to show until then. And because the renders page is different per store, you can’t cache this SSRed page at the CDN, so every route request goes all the way back to your server

I replaced it with this approach: For example

//routes/dashboard.tsx
// this is route but it has no loader and has no store specific data in it
export default function Dashboard() {
  const [storeData, setStoreData] = useState(null);

  useEffect(() => {
    fetch('/api/store')
      .then(response => response.json())
      .then(data => setStoreData(data));
  }, []);

  return <div>{storeData?.name || 'Loading...'}</div>;
}

And then I created a separate API route to return the data:

//routes/api.store.ts
import { authenticate } from "~/shopify.server";
import { type LoaderFunctionArgs, json } from "@remix-run/node";

export async function loader({ request }: LoaderFunctionArgs) {
  try {
    const { session } = await authenticate.admin(request);
    const storeData = await getStoreDataFromDB(session.id);
    return json(storeData);
  } catch (error) {
    console.error(error);
    return json({ error: "Failed to fetch store data" }, { status: 500 });
  }
}

Now the route is just a static shell, same for all stores. That means the CDN can cache it and return it instantly. After the page loads, the browser makes an API call to get the real data. Even if that takes a while, the page has already painted and LCP is unaffected.

One thing to watch out for: make sure you don’t render a bigger UI element later once the data comes in. As far as i know Shopify collects the latest LCP event(I could be wrong here), not the first one. So if your page shows a small heading at load and then a huge chart or banner appears after two seconds, that bigger element becomes the LCP, and your score goes down. It’s better if your static shell already contains the element that will be your largest paint, so it doesn’t shift later.

The problem with keeping loaders (including in root.tsx) in routes is that they force the server to do work before sending anything. If your route loader touches the database or the Shopify API, your TTFB easily jumps to 2–5 seconds. With that kind of delay, you’re fighting a lost battle on LCP because the browser has nothing to paint until the server finishes. Another issue with route loaders is regional latency. You can’t have servers in every region. If your app server is in the US and a merchant in Asia opens the app, the request travels all the way to the US server, waits there for the SSR to complete, then comes back to Asia, and only after that can the browser start loading the rest of your static assets from the CDN. That round trip alone ruins performance. When you serve a cached static shell from the CDN, this problem disappears because the HTML is already at the CDN and is same for every store. so it literally appears within 200ms-400ms

Caching needs a small mindset shift too. Since the UI routes are identical for all shops, I let the CDN ignore query strings like ?shop=... That keeps the cache clean and highly reusable and every store can load same already SSRed skeleton. My /api/* endpoints are excluded from CDN cache; they remain dynamic and can be uncached or lightly cached depending on the data, but they don’t affect the initial paint.

In simple words: show the page fast, validate and fill the details after.

I’m using this approach for two apps, one has LCP of 0.8s and the other 1.7s. The only difference is in CDN caching (the 1.7s one has slightly different settings with lower cache time), but I didn’t change it since the LCP is still within range

1 Like

Thank you very much for the dedicated explanation :folded_hands:
I’m following your way to make clean every route’s loader & make the fetcher (useFetcher from @remix-run/react) call in component useEffect side.

I will try to release each version and see the different of them :smiley:

Thank you @Gulam_Hussain_Quinn for dedicated and detailed information on LCP
Thank you very much!
Annex Cloud (Jitesh)

Please share updates here if you can :slight_smile:

1 Like

It is better for sure but my app still slow :smiling_face_with_tear:, I will continue dig more to whether can optimize more or not

Can you share your root.tsx file? How does it look right now? Also, I see you still have a redirect from / to /app. The first thing I would do is remove that redirect. I still don’t understand why the default remix template has a redirect from / to /app.

This is my root.tsx file, I only added a script for Crisp chat in the client side:

import {
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";
import { useEffect } from "react";

export default function App() {
  useEffect(() => {
    // Crisp chat script
    (window as any).$crisp = [];
    (window as any).CRISP_WEBSITE_ID = "apiKey";

    const script = document.createElement("script");
    script.src = "https://client.crisp.chat/l.js";
    script.async = true;
    document.head.appendChild(script);

    return () => {
      // Cleanup
      if (script && document.head.contains(script)) {
        document.head.removeChild(script);
      }
    };
  }, []);

  return (
    <html>
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width,initial-scale=1" />
        <meta name="shopify-debug" content="web-vitals" />
        <link rel="preconnect" href="https://cdn.shopify.com/" />
        <link
          rel="stylesheet"
          href="https://cdn.shopify.com/static/fonts/inter/v4/styles.css"
        />
        <Meta />
        <Links />
      </head>
      <body>
        <Outlet />
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

Yeah as your mentioned, I don’t know the exactly reason they use a redirect to app route like this:

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const url = new URL(request.url);

  if (url.searchParams.get("shop")) {
    throw redirect(`/app?${url.searchParams.toString()}`);
  }

  return { showForm: Boolean(login) };
};

whether is it impacted?