Intercom Widget INP Regression on Mobile: The Fix

Intercom widget tanking INP on mobile after the May launcher update? Here is why the script blocks the main thread and the lazy-mount fix that holds up.
PerformanceINPThird-Party Scripts
June 11, 20267 min read1247 words

The Problem

A SaaS client pinged me last week about a mobile INP score that had jumped from 180ms to 540ms over a two-week window. CrUX data confirmed it across all four core pages. No deploys on their side. No layout changes. The bundle was untouched. The only variable was Intercom: their messenger widget had auto-updated to the new 2026 launcher that ships an in-page composer preview, and the script weight went from roughly 90KB gzipped to 240KB.

The Performance panel told the story in seconds. Tapping the menu button on a Pixel 8 fired a 410ms long task on the main thread before the React handler even ran. Source: js.intercomcdn.com/messenger.js initialising on DOMContentLoaded. The launcher widget itself was barely visible above the fold on mobile, but Intercom was hydrating the full conversation pane, the bot picker, and the help center index on every page, even when the user never opened the messenger.

The Lighthouse Treemap had it pinned: 71% of unused JavaScript on first paint was from the Intercom bundle. The widget was visually small, the cost was enormous, and INP was the field metric paying for it.

Why It Happens

Intercom's pre-2026 launcher was a thin script that booted the chat bubble and lazy-loaded the conversation pane only when the user opened it. The 2026 launcher bundles the conversation UI, the AI suggestions panel, and the help center search index into the initial payload so the messenger opens instantly when tapped. Faster perceived chat. Worse INP for every visitor who does not open the chat.

The script self-injects on DOMContentLoaded regardless of when you boot it, runs a synchronous setup pass to register event listeners and warm an IndexedDB session, and then schedules a microtask cascade to hydrate the React tree inside an iframe. On a fast desktop that all wraps up in 80ms. On a mid-range Android over 4G it stretches past 400ms, and any user interaction during that window is queued behind the long task. Tapping a menu button measures as a 540ms INP event because the browser cannot process the input until Intercom's hydration tick finishes.

You will see the same pattern with Drift, HubSpot Chat, and any other "boots itself on load" widget that has grown a full SPA inside it. The browser tries to be helpful by deferring third-party scripts, but a defer attribute does not change what runs on DOMContentLoaded. It just changes when DOMContentLoaded fires.

The INP debugging guide covers main-thread blocking but the practical answer for a baked-in widget is to stop running it before the user asks for it.

The Fix

Two patterns. The right one for most sites is to gate the widget behind an explicit trigger that you control and let Intercom load only when the user clicks chat.

Step 1: Remove the auto-boot snippet and replace it with a button. Pull the standard Intercom snippet out of _document.tsx, layout.tsx, or the GTM container. Replace the visual launcher in your layout with your own button that mirrors the design:

'use client'
import { useState } from 'react'

let bootPromise: Promise<void> | null = null

function loadIntercom(): Promise<void> {
  if (bootPromise) return bootPromise

  bootPromise = new Promise((resolve) => {
    const w = window as any
    w.intercomSettings = { app_id: 'YOUR_APP_ID' }

    const s = document.createElement('script')
    s.async = true
    s.src = 'https://widget.intercom.io/widget/YOUR_APP_ID'
    s.onload = () => {
      w.Intercom('boot', w.intercomSettings)
      w.Intercom('show')
      resolve()
    }
    document.head.appendChild(s)
  })

  return bootPromise
}

export function ChatButton() {
  const [loading, setLoading] = useState(false)

  return (
    <button
      aria-label="Open chat"
      onClick={async () => {
        setLoading(true)
        await loadIntercom()
        setLoading(false)
      }}
      className="fixed bottom-4 right-4 rounded-full bg-blue-600 p-3 text-white"
    >
      {loading ? 'Loading…' : 'Chat'}
    </button>
  )
}

The widget no longer touches the page until the user taps the button. INP for the rest of the session is unaffected because no Intercom code is running. When the user does click chat, you eat a single 300-400ms load cost on that interaction, which is acceptable because the user explicitly asked for it.

Step 2: Preconnect to the Intercom CDN so the click is fast. Without preconnect, the first chat click has to do a DNS lookup, TLS handshake, and HTTP fetch in sequence. Hint the browser at the connection in your root layout head:

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <link rel="preconnect" href="https://widget.intercom.io" crossOrigin="" />
        <link rel="preconnect" href="https://js.intercomcdn.com" crossOrigin="" />
        <link rel="dns-prefetch" href="https://api-iam.intercom.io" />
      </head>
      <body>{children}</body>
    </html>
  )
}

The preconnect warms the TCP and TLS handshake without downloading anything. When the user taps chat, the widget script ships over an already-open connection. The end-to-end load time drops from around 800ms to around 350ms on a mid-range mobile.

Step 3: Boot on visibility for repeat visitors who actually use chat. If your analytics show that, say, 25% of authenticated users open Intercom on every session, lazy-loading on click still wastes their first 300ms. For that segment, boot the widget when the page goes idle:

'use client'
import { useEffect } from 'react'

export function IntercomEagerBoot({ userId }: { userId?: string }) {
  useEffect(() => {
    if (!userId) return

    const idle = (cb: () => void) =>
      'requestIdleCallback' in window
        ? (window as any).requestIdleCallback(cb, { timeout: 4000 })
        : setTimeout(cb, 2500)

    idle(() => {
      void loadIntercom()
    })
  }, [userId])

  return null
}

This still keeps Intercom off the critical path for first paint and INP, but it warms the widget during the idle window after the page has settled. The user sees the chat button respond instantly on subsequent interactions because the bundle is already in memory.

Verify the regression is gone. Run a Lighthouse mobile audit before and after with the throttled mid-tier profile. INP in the Performance Insights tab should drop back below 200ms. Confirm in the CrUX dashboard 28 days out: the field INP metric updates slowly but it does update. If INP stays elevated, profile the menu interaction again in the Performance panel and look for any other third-party hydrating on load — Hotjar, Segment, and the GTM container all do the same thing.

The Lesson

A chat widget that nobody clicked should not cost you 400ms of INP. Intercom's 2026 launcher hydrates a full conversation pane on every page, blocking the main thread for every interaction that lands inside its boot window. Replace the auto-boot with a click-to-load button, preconnect to the CDN so the open feels instant, and reserve eager booting for users your analytics say will actually chat. INP recovers within the next CrUX update.

If a third-party script has wrecked your Core Web Vitals and you need it taken off the critical path without losing the feature, that is a project I get paid to fix. See my services. For a related INP regression from another third-party in the same family, read INP regression from Sentry Session Replay on mobile.

Third-party widget eating your Core Web Vitals? Let me fix it.

Back to blogStart a project