Technical

Time to Interactive (TTI)

A performance metric that measures the time from page start until the page is reliably interactive, meaning it displays useful content, responds to user interactions within 50ms, and has registered event handlers.

Quick Answer

  • What it is: A performance metric that measures the time from page start until the page is reliably interactive, meaning it displays useful content, responds to user interactions within 50ms, and has registered event handlers.
  • Why it matters: TTI identifies when users can reliably interact with your page without delays or unresponsive elements.
  • How to check or improve: Minimize main thread work, optimize JavaScript execution, and reduce time between FCP and TTI for scores under 3.8s.

When you'd use this

TTI identifies when users can reliably interact with your page without delays or unresponsive elements.

Example scenario

Hypothetical scenario (not a real company)

A team might use Time to Interactive (TTI) when Minimize main thread work, optimize JavaScript execution, and reduce time between FCP and TTI for scores under 3.8s.

Common mistakes

  • Confusing Time to Interactive (TTI) with First Input Delay (FID): A Core Web Vitals metric that measures the time from when a user first interacts with a page to the time when the browser is able to begin processing event handlers in response.
  • Confusing Time to Interactive (TTI) with Total Blocking Time (TBT): A lab metric that measures the total time the main thread is blocked by long tasks, preventing user input responsiveness between First Contentful Paint and Time to Interactive.
  • Confusing Time to Interactive (TTI) with First Contentful Paint (FCP): A performance metric that measures the time from when a page starts loading to when any part of the page's content is rendered on the screen.

How to measure or implement

  • Minimize main thread work, optimize JavaScript execution, and reduce time between FCP and TTI for scores under 3
  • 8s

Test your page interactivity

Start here
Updated Jan 20, 2026·4 min read

Time to Interactive (TTI) is the performance metric that marks the moment your page transforms from a beautiful painting into a responsive application. It measures when your page is not just visually complete, but fully ready for user interaction—with event handlers attached, JavaScript loaded, and the main thread quiet enough to respond quickly to user input. Think of TTI as the metric that answers: "When can users actually use this page?"

What is Time to Interactive?

TTI identifies the point when a page becomes reliably interactive. A page is considered interactive when:

  1. Useful content is displayed (First Contentful Paint has occurred)
  2. Event handlers are registered for most visible elements
  3. Page responds within 50ms to user interactions
  4. Main thread is quiet (no long tasks for at least 5 seconds)

The technical algorithm:

  1. Start from FCP
  2. Find a 5-second window with no long tasks (>50ms)
  3. Look backward for the last long task before that window
  4. TTI is the end of that last long task

Lighthouse TTI thresholds:

  • Good: 3.8 seconds or less
  • Needs Improvement: 3.9 to 7.3 seconds
  • Poor: Over 7.3 seconds

Why TTI Matters for User Experience

The Interactivity Gap

TTI exposes a critical performance issue: the gap between visual completeness and actual interactivity.

Common User Experience Timeline:

FCP (1s)         LCP (2.5s)       Visual Complete (3s)    TTI (6s)
  |                 |                    |                   |
  ├─────────────────┼────────────────────┼───────────────────┤
  "Something        "Content             "Looks ready"       "Actually works"
   appears"          visible"

        User assumes page is ready ─────────►│
                                             │
        Rage clicking period ────────────────┤◄── 3 seconds ──►│

During this gap, users experience:

  • Unresponsive buttons: Click handlers not yet attached
  • Broken interactions: JavaScript still loading
  • Delayed feedback: Main thread blocked by initialization
  • Frustration: Page looks ready but doesn't work

Business Impact of Poor TTI

High TTI correlates with poor business metrics:

Conversion Impact:

  • 1-second TTI increase = 7% drop in conversions
  • TTI over 10s = 50% user abandonment
  • Mobile users especially sensitive (2x impact)

User Behavior:

  • Users attempt interactions 3.2 times on average before TTI
  • 68% of rage clicks occur between visual complete and TTI
  • 23% of users abandon if interactions don't work within 3 seconds

Case Studies:

  • eBay: Reduced TTI by 5s → 10% increase in add-to-cart rate
  • Twitter: Improved TTI by 6.5s → 20% decrease in bounce rate
  • Walmart: Every 1s TTI improvement = 2% increase in conversions

TTI vs Other Interactivity Metrics

TTI vs FID:

  • TTI: When page becomes interactive (lab metric)
  • FID: Actual delay when user interacts (field metric)
  • TTI predicts worst-case FID scenarios

TTI vs TBT:

  • TTI: Point in time when interactive
  • TBT: Sum of blocking time before TTI
  • Lower TBT typically means earlier TTI

TTI vs INP:

  • TTI: Initial interactivity readiness
  • INP: Ongoing interaction responsiveness
  • Good TTI doesn't guarantee good INP

How TTI Works: Technical Deep Dive

The Quiet Window Algorithm

// Simplified TTI detection algorithm
function findTTI(performanceData) {
  const fcp = findFCP(performanceData)
  if (!fcp) return null

  // Find all long tasks (>50ms)
  const longTasks = performanceData.filter(task => task.duration > 50)

  // Look for 5-second quiet window after FCP
  let quietWindowStart = fcp
  let quietWindowEnd = quietWindowStart + 5000

  for (const task of longTasks) {
    if (task.startTime >= quietWindowStart && task.startTime < quietWindowEnd) {
      // Long task interrupts quiet window
      quietWindowStart = task.endTime
      quietWindowEnd = quietWindowStart + 5000
    }
  }

  // Find last long task before quiet window
  const lastLongTask = longTasks
    .filter(task => task.endTime <= quietWindowStart)
    .sort((a, b) => b.endTime - a.endTime)[0]

  return lastLongTask ? lastLongTask.endTime : quietWindowStart
}

Network Quiet Detection

TTI also considers network activity:

// Network quiet levels
const NetworkQuiet = {
  // Network-2-quiet: ≤ 2 in-flight requests
  IDLE_2: requests => requests.length <= 2,

  // Network-0-quiet: 0 in-flight requests
  IDLE_0: requests => requests.length === 0
}

// TTI requires network-2-quiet
function isNetworkQuiet(pendingRequests) {
  return NetworkQuiet.IDLE_2(pendingRequests)
}

Main Thread Quiet Criteria

// Main thread is considered quiet when:
function isMainThreadQuiet(tasks, startTime) {
  const window = 5000 // 5-second window
  const threshold = 50 // 50ms long task threshold

  // Check for long tasks in window
  const longTasksInWindow = tasks.filter(
    task =>
      task.startTime >= startTime &&
      task.startTime < startTime + window &&
      task.duration > threshold
  )

  return longTasksInWindow.length === 0
}

Best Practices for Optimizing TTI

1. Minimize JavaScript Bundle Size

Code Splitting by Route

// React Router with code splitting
import { lazy, Suspense } from "react"
import { Routes, Route } from "react-router-dom"

// Split bundles by route
const Home = lazy(() => import("./pages/Home"))
const Dashboard = lazy(() => import("./pages/Dashboard"))
const Profile = lazy(() => import("./pages/Profile"))

function App() {
  return (
    <Suspense fallback={<LoadingScreen />}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/profile" element={<Profile />} />
      </Routes>
    </Suspense>
  )
}

// Webpack automatically creates separate bundles
// home.chunk.js, dashboard.chunk.js, profile.chunk.js

Progressive Bundling

// Load features progressively
class ProgressiveApp {
  constructor() {
    this.loadCritical()
    this.scheduleEnhancements()
  }

  async loadCritical() {
    // Minimum viable interactivity
    const { initCore } = await import("./core")
    initCore()
    // TTI can now be reached
  }

  scheduleEnhancements() {
    // Load enhancements after TTI
    if ("requestIdleCallback" in window) {
      requestIdleCallback(() => this.loadEnhancements())
    } else {
      setTimeout(() => this.loadEnhancements(), 1000)
    }
  }

  async loadEnhancements() {
    const { initAnalytics } = await import("./analytics")
    const { initChat } = await import("./chat")
    const { initAdvancedFeatures } = await import("./features")

    initAnalytics()
    initChat()
    initAdvancedFeatures()
  }
}

2. Optimize Script Loading

Strategic Script Loading

<!-- Critical: Inline for immediate execution -->
<script>
  // Minimal critical JavaScript
  ;(function () {
    // Essential event handlers
    document.addEventListener("DOMContentLoaded", function () {
      // Critical initialization
    })
  })()
</script>

<!-- Important: Defer for early but non-blocking -->
<script defer src="/js/main.js"></script>

<!-- Enhancement: Dynamic loading after TTI -->
<script>
  window.addEventListener("load", () => {
    setTimeout(() => {
      // Load after main thread settles
      const script = document.createElement("script")
      script.src = "/js/enhancements.js"
      document.body.appendChild(script)
    }, 100)
  })
</script>

<!-- Non-critical: Lazy load on interaction -->
<script>
  document.getElementById("feature").addEventListener(
    "click",
    () => {
      import("/js/feature.js").then(module => {
        module.init()
      })
    },
    { once: true }
  )
</script>

3. Implement Idle Until Urgent Pattern

// Queue non-critical work
class IdleQueue {
  constructor() {
    this.queue = []
    this.isProcessing = false
  }

  push(task, options = {}) {
    this.queue.push({ task, urgent: false, ...options })
    this.process()
  }

  pushUrgent(task) {
    // User-triggered tasks jump the queue
    this.queue.unshift({ task, urgent: true })
    this.processUrgent()
  }

  process() {
    if (this.isProcessing || this.queue.length === 0) return

    if ("requestIdleCallback" in window) {
      requestIdleCallback(
        deadline => {
          this.processTasks(deadline)
        },
        { timeout: 2000 }
      )
    } else {
      setTimeout(() => this.processTasks(), 1)
    }
  }

  processUrgent() {
    // Immediately process urgent tasks
    const urgent = this.queue.filter(item => item.urgent)
    urgent.forEach(item => item.task())
    this.queue = this.queue.filter(item => !item.urgent)
  }

  processTasks(deadline) {
    this.isProcessing = true

    while (
      this.queue.length > 0 &&
      (deadline ? deadline.timeRemaining() > 0 : true)
    ) {
      const { task } = this.queue.shift()
      task()
    }

    this.isProcessing = false
    if (this.queue.length > 0) {
      this.process()
    }
  }
}

// Usage
const idleQueue = new IdleQueue()

// Non-critical work
idleQueue.push(() => initializeAnalytics())
idleQueue.push(() => preloadNextPageResources())
idleQueue.push(() => setupAdvancedFeatures())

// Urgent work (user interaction)
button.addEventListener("click", () => {
  idleQueue.pushUrgent(() => handleButtonClick())
})

4. Reduce Main Thread Work During Load

Defer Complex Calculations

// Bad: Heavy computation during load
window.addEventListener("DOMContentLoaded", () => {
  const data = processLargeDataset() // Blocks TTI
  renderVisualization(data)
})

// Good: Defer until after TTI
window.addEventListener("DOMContentLoaded", () => {
  // Show skeleton immediately
  showSkeleton()

  // Process data when idle
  requestIdleCallback(
    () => {
      const data = processLargeDataset()
      renderVisualization(data)
    },
    { timeout: 5000 }
  )
})

// Better: Use Web Worker
window.addEventListener("DOMContentLoaded", () => {
  showSkeleton()

  const worker = new Worker("processor.worker.js")
  worker.postMessage({ cmd: "process", data: dataset })
  worker.onmessage = e => {
    renderVisualization(e.data)
  }
})

5. Optimize Third-Party Scripts

Third-Party Script Manager

class ThirdPartyManager {
  constructor() {
    this.scripts = new Map()
    this.loadedScripts = new Set()
    this.ttiReached = false

    this.detectTTI()
  }

  register(name, config) {
    this.scripts.set(name, {
      src: config.src,
      priority: config.priority || "low",
      loadCondition: config.loadCondition || "afterTTI",
      callback: config.callback
    })
  }

  detectTTI() {
    // Simple TTI detection
    const observer = new PerformanceObserver(list => {
      const entries = list.getEntries()
      // Check for quiet period
      if (this.isQuietPeriod(entries)) {
        this.ttiReached = true
        this.loadAfterTTI()
      }
    })

    observer.observe({ entryTypes: ["longtask"] })

    // Fallback timeout
    setTimeout(() => {
      this.ttiReached = true
      this.loadAfterTTI()
    }, 10000)
  }

  loadAfterTTI() {
    this.scripts.forEach((config, name) => {
      if (
        config.loadCondition === "afterTTI" &&
        !this.loadedScripts.has(name)
      ) {
        this.loadScript(name, config)
      }
    })
  }

  loadScript(name, config) {
    const script = document.createElement("script")
    script.src = config.src
    script.async = true
    script.onload = () => {
      this.loadedScripts.add(name)
      if (config.callback) config.callback()
    }
    document.body.appendChild(script)
  }
}

// Usage
const thirdParty = new ThirdPartyManager()

thirdParty.register("analytics", {
  src: "https://www.google-analytics.com/analytics.js",
  priority: "low",
  loadCondition: "afterTTI"
})

thirdParty.register("intercom", {
  src: "https://widget.intercom.io/widget/APP_ID",
  priority: "low",
  loadCondition: "afterTTI",
  callback: () => Intercom("boot", { app_id: "APP_ID" })
})

Common TTI Problems and Solutions

Problem 1: Hydration Delays in SSR

Issue: Server-rendered HTML looks interactive but isn't until JavaScript hydrates

Solution: Progressive hydration

// Next.js with selective hydration
import dynamic from "next/dynamic"

// Hydrate critical components immediately
const Header = dynamic(() => import("./Header"), {
  ssr: true,
  loading: () => <HeaderSkeleton />
})

// Delay hydration of non-critical components
const Comments = dynamic(() => import("./Comments"), {
  ssr: true,
  loading: () => <CommentsSkeleton />,
  // Hydrate on visibility
  ...{ hydrate: "whenVisible" }
})

// Skip hydration for static content
const Footer = dynamic(() => import("./Footer"), {
  ssr: true,
  // Never hydrate (static content)
  ...{ hydrate: false }
})

Problem 2: Large Application Initialization

Issue: Complex app initialization blocks TTI

Solution: Incremental initialization

class AppInitializer {
  constructor() {
    this.initPhases = []
  }

  addPhase(phase) {
    this.initPhases.push(phase)
  }

  async initialize() {
    // Phase 1: Critical (before TTI)
    await this.initializeCritical()

    // Let browser reach TTI
    await this.yieldToMain()

    // Phase 2: Important (after TTI)
    requestIdleCallback(() => this.initializeImportant())

    // Phase 3: Nice-to-have (when completely idle)
    requestIdleCallback(() => this.initializeEnhancements(), {
      timeout: 10000
    })
  }

  async initializeCritical() {
    // Minimum for interactivity
    await this.setupEventListeners()
    await this.initializeRouter()
  }

  async initializeImportant() {
    // Enhanced functionality
    await this.loadUserPreferences()
    await this.setupWebSocket()
  }

  async initializeEnhancements() {
    // Optimizations and nice-to-haves
    await this.prefetchResources()
    await this.setupOfflineCache()
  }

  yieldToMain() {
    return new Promise(resolve => setTimeout(resolve, 0))
  }
}

Problem 3: Framework Overhead

Issue: Framework initialization delays TTI

Solution: Optimize framework usage

// Vue 3 with async components
import { defineAsyncComponent } from "vue"

const app = createApp({
  components: {
    // Load components as needed
    HeavyChart: defineAsyncComponent(
      () => import("./components/HeavyChart.vue")
    ),
    DataTable: defineAsyncComponent({
      loader: () => import("./components/DataTable.vue"),
      loading: LoadingComponent,
      delay: 200,
      timeout: 3000
    })
  }
})

// React 18 with Concurrent Features
import { startTransition } from "react"

function App() {
  const [data, setData] = useState(null)

  useEffect(() => {
    // Non-urgent updates don't block TTI
    startTransition(() => {
      fetchLargeDataset().then(setData)
    })
  }, [])

  return (
    <div>
      <CriticalContent /> {/* Renders immediately */}
      <Suspense fallback={<Spinner />}>
        <HeavyContent data={data} /> {/* Loads progressively */}
      </Suspense>
    </div>
  )
}

Tools for Measuring TTI

Lighthouse

# Measure TTI with Lighthouse
lighthouse https://example.com \
  --only-categories=performance \
  --only-audits=interactive

# With Chrome DevTools protocol
lighthouse https://example.com \
  --chrome-flags="--headless" \
  --output=json \
  --output-path=tti-report.json

Puppeteer for Custom TTI Measurement

const puppeteer = require("puppeteer")

async function measureTTI(url) {
  const browser = await puppeteer.launch()
  const page = await browser.newPage()

  // Start tracing
  await page.tracing.start({ categories: ["devtools.timeline"] })

  // Navigate and wait for network idle
  await page.goto(url, { waitUntil: "networkidle2" })

  // Stop tracing
  const tracing = await page.tracing.stop()

  // Parse trace for TTI
  const trace = JSON.parse(tracing.toString())
  const tti = findTTIFromTrace(trace)

  console.log(`TTI: ${tti}ms`)

  await browser.close()
  return tti
}

function findTTIFromTrace(trace) {
  // Simplified TTI calculation from trace events
  const events = trace.traceEvents
  const fcpEvent = events.find(e => e.name === "firstContentfulPaint")
  const longTasks = events.filter(
    e => e.cat === "devtools.timeline" && e.name === "Task" && e.dur > 50000 // 50ms in microseconds
  )

  // Find quiet window after FCP
  // (Implementation simplified for example)
  return calculateTTI(fcpEvent, longTasks)
}

Frequently Asked Questions

Why is TTI often much later than visual completion?

JavaScript-heavy sites often look ready before they're interactive. Common causes:

  • Large JavaScript bundles still parsing/executing
  • Framework hydration in progress
  • Event listeners not yet attached
  • Third-party scripts initializing

Focus on reducing JavaScript and progressive enhancement.

How does TTI relate to Core Web Vitals?

TTI isn't a Core Web Vital, but it influences them:

  • High TTI correlates with poor FID
  • TTI timing affects TBT calculation
  • Poor TTI often indicates INP issues

Optimize TTI to improve Core Web Vitals indirectly.

Can TTI be measured in real user monitoring?

TTI is primarily a lab metric because determining the "quiet window" requires knowing future events. For real user monitoring, use:

  • FID for first interaction delay
  • INP for ongoing interactivity
  • Custom metrics for specific interactions

What's the difference between TTI and Time to First Byte?

  • TTFB: When server starts responding (network metric)
  • TTI: When page becomes interactive (JavaScript metric)

TTFB affects TTI (can't be interactive without content), but they measure different aspects of performance.

Should I prioritize TTI or visual metrics?

Balance both:

  1. Fast visual feedback (FCP/LCP) prevents abandonment
  2. Fast interactivity (TTI) prevents frustration
  3. The gap between them should be minimal

Aim for progressive enhancement where basic functionality works immediately, with enhancements loading progressively.

Put GEO into practice

Generate AI-optimized content that gets cited.

Try Rankwise Free
Newsletter

Stay ahead of AI search

Weekly insights on GEO and content optimization.