Skip to content

Removing 1.2 Seconds of Render Blocking with Dynamic Naver Map API Loading

Web | December 4, 2025


This post documents how we removed render blocking caused by the Naver Map API and improved Moitz's initial loading performance.

Introduction

Performance optimization is a core frontend concern for delivering a better web experience. While measuring Moitz, we found a bottleneck that kept its Lighthouse performance score at 46.

ResultPage Lighthouse score before the improvement

The main culprit was Lighthouse's Render blocking requests audit. The Naver Map API script blocked the initial render for 1,240 ms—about 1.2 seconds.

Naver Map API render-blocking request

This post explains how we diagnosed the synchronous script blocking the initial render and improved the experience by switching to dynamic loading.

[WHY] A script blocking the Critical Rendering Path

The cause was clear: index.html loaded the Naver Map API synchronously.

The previous execution order was:

  1. The browser starts parsing HTML.
  2. It encounters a <script> tag and immediately stops parsing HTML.
  3. The Naver Map API script (87.8 KB) downloads and runs, creating window.naver.
  4. HTML parsing completes.
  5. The DOMContentLoaded event fires.
  6. The React app starts (main.tsx).
  7. IndexPage renders.
  8. The user navigates to ResultPage.
  9. useCustomOverlays uses window.naver.maps.

The problem was that this script blocked the Critical Rendering Path (CRP).

<!-- index.html: runs synchronously on every page -->
<script src="https://oapi.map.naver.com/openapi/v3/maps.js" type="text/javascript"></script>

<!-- Rendering is blocked until the 87.8 KB script downloads and executes. -->
<!-- An API used only by ResultPage is loaded globally. -->

In other words, the browser could not render the Map component until the global naver.maps object was ready.

Moitz has an IndexPage, which does not need a map, and a ResultPage, which does. The original structure still downloaded and executed the map API on IndexPage, delaying the initial load of routes unrelated to maps.

[WHAT] Dynamic loading: load it only when it is needed

We first removed the synchronous script tag from index.html. This moved the 1,270 ms blocking task and its 87.8 KB resource out of the initial path, while preventing unnecessary API loads on every route.

<!-- Removed -->
<!-- <script src="https://oapi.map.naver.com/openapi/v3/maps.js" type="text/javascript"></script> -->

We then needed to decide when to load the script. We compared three approaches:

  1. async
  2. defer
  3. Dynamic loading

Both async and defer download scripts in parallel with HTML parsing. However, defer only postpones execution until parsing is complete; it still downloads the script on pages that do not need a map.

async also does not guarantee when the script will execute. A React component can therefore mount before window.naver is available, creating a race condition.

Dynamic loading creates the script tag with JavaScript and loads the API only when the user enters ResultPage. It gives us control over the loading point, supports conditional loading, and makes Promise-based error handling straightforward. It was the best fit for this requirement.

[HOW] Implementation

To keep the UI stable even when the map API arrives late or fails to load, we implemented detailed script-loading state management.

1. Naver Map API loader utility (loadNaverMapScript)

First, we created a utility that dynamically loads the script and returns the result as a Promise. It provides two guarantees.

Prevent duplicate loads

Do not create another script tag if window.naver.maps already exists or a script tag has already been inserted into the DOM.

Manage state with a Promise

Resolve on the script element's onload event and reject on onerror, making success and failure explicit.

// /utils/loadNaverMapScript.ts
export const loadNaverMapScript = (): Promise<void> => {
  return new Promise((resolve, reject) => {
    // Already loaded
    if (window.naver?.maps) {
      resolve();
      return;
    }

    // Check for an existing loading attempt to avoid duplicates
    const existingScript = document.querySelector(
      'script[src*="oapi.map.naver.com"]'
    );
    if (existingScript) {
      existingScript.addEventListener('load', () => resolve());
      existingScript.addEventListener('error', () => reject());
      return;
    }

    const script = document.createElement('script');
    script.src = `https://oapi.map.naver.com/openapi/v3/maps.js?ncpKeyId=${YOUR_API_KEY}`;
    script.async = true;
    script.onload = () => resolve();
    script.onerror = () => reject();
    document.head.appendChild(script);
  });
};

2. Loading-state hook (useNaverMapLoader)

Next, we built a custom hook so that the Map component could consume the API loading state easily. The hook manages three states: isLoading, isScriptLoaded, and errorMessage.

// /hooks/useNaverMapLoader.ts
export const useNaverMapLoader = () => {
  const [isScriptLoaded, setIsScriptLoaded] = useState(false);
  const [isLoading, setIsLoading] = useState(true);
  const [errorMessage, setErrorMessage] = useState<string | null>(null);

  useEffect(() => {
    const initializeScript = async () => {
      try {
        await loadNaverMapScript();
        setIsScriptLoaded(true);
      } catch (err) {
        setErrorMessage(err.message);
      } finally {
        setIsLoading(false);
      }
    };
    initializeScript();
  }, []);

  return { isScriptLoaded, isLoading, errorMessage };
};

3. Applying it to the Map component (loading and error UI)

The Map component renders a different UI for each state returned by useNaverMapLoader.

  • While loading (isLoading), it shows a skeleton UI.
  • On an error (errorMessage), it shows fallback UI that informs the user and allows a retry.
  • After loading (isScriptLoaded), it renders the actual map.
// /components/Map.tsx
function Map(...) {
  const { isScriptLoaded, isLoading, errorMessage } = useNaverMapLoader();

  if (isLoading) {
    return <Skeleton />;
  }
  if (errorMessage) {
    return <FallBackPage error={new Error(`Failed to load map: ${errorMessage}`)} ... />;
  }

  return <ActualMapComponent ... />;
}

4. Solving the CustomOverlay race condition

One issue remained: a race condition involving CustomOverlay. Naver Map's CustomOverlay extends window.naver.maps.OverlayView.

The old code defined that class at the top level of the module. The map script may not have loaded when the module is evaluated, so window.naver may be unavailable and cause an error.

// Problematic previous code
// Defining the class at the module top level can fail because window.naver
// is not available before the script loads.
class CustomOverlay extends window.naver.maps.OverlayView {
  // window.naver may not exist
}

We solved this with a factory function that defines the class at execution time, rather than when the module loads.

// createCustomOverlay.ts
export const createCustomOverlay = (props: CustomOverlayProps) => {
  // Verify once more that the API has loaded
  if (!window.naver?.maps?.OverlayView) {
    throw new Error('Naver Map API has not loaded.');
  }

  // window.naver.maps.OverlayView is guaranteed at this point
  class CustomOverlay extends window.naver.maps.OverlayView {
    // ... (CustomOverlay logic)
  }

  return new CustomOverlay(props);
};

[SO] Results

After removing the synchronous script and introducing dynamic loading, the execution flow changed as follows.

BeforeAfter
1. Start parsing HTML1. Parse HTML (no blocking)
2. Load and execute the Naver Map API (blocks for 1,270 ms)2. Start the React app immediately
3. Finish parsing HTML3. IndexPage: do not load the map API
4. Start the React app4. Dynamically load it only on entry to ResultPage
5. Load the map API on every page5. Load the API asynchronously in the background
Execution flow after dynamic loading

The Lighthouse results improved as well.

IndexPage — Before

The Naver Map API script in index.html ran synchronously on every page, causing render blocking.

IndexPage Lighthouse results before the improvement IndexPage render-blocking request before the improvement

IndexPage — After

IndexPage does not need a map, so it no longer executes the script. As a result, the render blocking is gone.

IndexPage Lighthouse results after the improvement IndexPage render-blocking request after the improvement

ResultPage — Before

ResultPage needs a map, but the synchronous HTML script blocked the Critical Rendering Path and delayed FCP to 4.4 seconds.

ResultPage Lighthouse score before the improvement ResultPage FCP result before the improvement ResultPage render-blocking request before the improvement

ResultPage — After

The map API now loads dynamically only when it is needed. FCP fell to 0.6 seconds, and the Performance score rose from 46 to 57.

ResultPage Lighthouse score after the improvement ResultPage FCP result after the improvement

Removing render blocking from every page improved FCP. However, the script's execution time did not change, so TBT did not improve substantially.

Why TBT did not improve

TBT measures the time spent executing work on the main thread. The important question is not when a script downloads, but how long it occupies the main thread when it runs.

The Naver Map API still executes synchronously on the main thread. Longer parsing and execution increase TBT. async makes only the download asynchronous; execution still blocks the main thread.

As a third-party script, the Naver Map API has a few fundamental constraints:

  • It is a third-party script, so we cannot optimize it directly.
  • Its parsing and execution take a long time (200–300 ms).
  • It requires DOM access, so it cannot run in a Web Worker.

To improve TBT, we would need to remove the script entirely or postpone its execution beyond the measurement window.

Closing

This work did more than improve a performance score; it also made the structure around the map feature clearer.

  1. Separation of concerns: Map-loading logic is encapsulated in useNaverMapLoader and the Map component.
  2. Resource optimization: The API loads only on pages that need a map.
  3. Improved resilience: Error UI protects the user experience when loading fails.

Starting with Lighthouse's recommendation to remove render blocking, we reconsidered our resource-loading strategy. The result is a faster initial screen while keeping the map reliable on the page that needs it.