Optimizing INP Index in NextJS
Introduction
In the previous article, I introduced Core Web Vitals as well as how to optimize for the LCP index and in this article, we will continue with the next index, which is INP. This index measures responsiveness when users interact, as fetching too much data then rendering a large list (like hundreds of thousands of items) will block the Main-thread. The solutions I will mention in this article to optimize INP include:
- Virtualization: there are many packages supporting Data-heavy Lists, you can choose to apply them effectively, just render what is in the viewport. Handling thousands of DOM nodes simultaneously and continuously is the main factor directly affecting the INP index.
- Web Workers: If you need to process heavy logic (calculating, formatting a large amount of data) before displaying, push that logic part to a Web Worker to free up the Main-thread.
- Debouncing & Transitions: Use a combination of debounce and hooks like useTransition or useDeferredValue to mark state updates from an API as "non-priority", helping the UI still respond to user actions such as typing or clicking while data is being loaded.
Detail
Create file app/inp/types.ts
export interface MockItem {
id: number
name: string
email: string
role: string
status: 'active' | 'inactive'
}
export interface WorkerInput {
list: MockItem[]
searchTerm: string
}
Create file app/inp/useDebounce.ts
import {useEffect, useState} from 'react'
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(handler)
}
}, [value, delay])
return debouncedValue
}
This is a simple implementation of a debounce hook, note that unlike useDeferredValue and useTransition which are related to React Fiber and operate in the Render Phase, the operating mechanism of debounce is only to delay execution rather than splitting it up for processing, so if there is a large enough data processing operation, it will still block the main thread.
Create file app/inp/filter.worker.ts
import type {WorkerInput} from './types'
const ctx: Worker = self as any
ctx.onmessage = (e: MessageEvent<WorkerInput>) => {
const {list, searchTerm} = e.data
if (!searchTerm) {
ctx.postMessage(list)
return
}
const startTime = performance.now()
while (performance.now() - startTime < 100) {
}
const lowerSearch = searchTerm.toLowerCase()
const filtered = list.filter(
item =>
item.name.toLowerCase().includes(lowerSearch) ||
item.email.toLowerCase().includes(lowerSearch)
)
ctx.postMessage(filtered)
}
This is a web worker running in a separate thread used to handle heavy computational tasks to avoid blocking the main thread, here I use it for filtering searchTerm by name and email.
Create file app/inp/TableClient.tsx
'use client'
import {SafetyCertificateOutlined, SearchOutlined} from '@ant-design/icons'
import {Badge, Card, Input, Spin, Table, Tag, Typography} from 'antd'
import type {ColumnsType} from 'antd/es/table'
import {
useDeferredValue,
useEffect,
useRef,
useState,
useTransition,
} from 'react'
import {MockItem} from './types'
import {useDebounce} from './useDebounce'
const {Title, Text} = Typography
interface Props {
initialData: MockItem[]
}
export default function TableClient({initialData}: Props) {
const rawData = useMemo(() => initialData, [initialData])
const [filteredData, setFilteredData] = useState<MockItem[]>(initialData)
const [searchQuery, setSearchQuery] = useState('')
const [isPending, startTransition] = useTransition()
const [isWorkerLoading, setIsWorkerLoading] = useState(false)
const debouncedSearchValue = useDebounce(searchQuery, 300)
const deferredSearchValue = useDeferredValue(debouncedSearchValue)
const workerRef = useRef<Worker | null>(null)
useEffect(() => {
workerRef.current = new Worker(
new URL('./filter.worker.ts', import.meta.url)
)
workerRef.current.onmessage = (event: MessageEvent<MockItem[]>) => {
startTransition(() => {
setFilteredData(event.data)
setIsWorkerLoading(false)
})
}
return () => workerRef.current?.terminate()
}, [])
useEffect(() => {
if (workerRef.current) {
setIsWorkerLoading(true)
workerRef.current.postMessage({
list: rawData,
searchTerm: deferredSearchValue,
})
}
}, [deferredSearchValue, rawData])
const columns: ColumnsType<MockItem> = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
render: id => <Text code>#{id}</Text>,
},
{
title: 'Full Name',
dataIndex: 'name',
key: 'name',
width: 250,
render: name => <Text strong>{name}</Text>,
},
{
title: 'Role',
dataIndex: 'role',
key: 'role',
width: 150,
render: role => (
<Tag color={role === 'Tech Lead' ? 'magenta' : 'blue'}>{role}</Tag>
),
},
{
title: 'Email Address',
dataIndex: 'email',
key: 'email',
width: 300,
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 120,
render: status => (
<Badge
status={status === 'active' ? 'success' : 'default'}
text={status.toUpperCase()}
/>
),
},
]
const isStale = debouncedSearchValue !== deferredSearchValue
return (
<Card className="shadow-xl border-none rounded-2xl">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
<div>
<Title level={2} className="m-0 flex items-center gap-3">
<SafetyCertificateOutlined className="text-blue-500" />
Virtual Table
</Title>
</div>
<Badge
count={`Total: ${filteredData.length}`}
overflowCount={10000}
style={{backgroundColor: '#10b981', padding: '0 12px'}}
/>
</div>
<div className="mb-6 relative">
<Input
size="large"
placeholder="Search employees..."
prefix={<SearchOutlined className="text-gray-400" />}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="rounded-xl h-14 shadow-sm text-lg"
allowClear
/>
{(isPending || isWorkerLoading || isStale) && (
<div className="absolute right-12 top-1/2 -translate-y-1/2 flex items-center gap-3 bg-white pl-4">
<Spin size="small" />
<span className="text-sm text-gray-400">Syncing...</span>
</div>
)}
</div>
<div
className={`transition-all duration-300 ${isStale ? 'opacity-40 blur-[1px]' : 'opacity-100'}`}
>
<Table
columns={columns}
dataSource={filteredData}
rowKey="id"
pagination={false}
scroll={{y: 550}}
virtual
bordered
className="border border-gray-100 rounded-xl overflow-hidden"
/>
</div>
</Card>
)
}
This is the main part with the combination of useTransition, useDeferredValue, debounce, web worker and Antd virtual scroll table.
- when user types something the input will show content immediately.
- useDebounce waits for the user to stop typing for 300ms.
- After 300ms, debouncedSearchValue changes, useDeferredValue runs at low priority so it does not block the main thread, the user can still interact with the UI.
- When deferredSearchValue receives a new value, it triggers useEffect to push data to the Web Worker for calculation.
- The Worker (Separate Thread) finishes processing the array of 10k items, sends data back and is received through onmessage.
- Use useTransition so that while React is processing, the user can still interact.
- Use Table virtual scroll to only handle the number of items that the user sees in the viewport rather than all 10k real DOM nodes.
Create file app/inp/page.tsx used to simulate fetching data on the server and passing it to TableClient for use.
import TableClient from './TableClient'
import {MockItem} from './types'
async function getBigDataFromServer(): Promise<MockItem[]> {
return Array.from({length: 10000}).map((_, index) => ({
id: index + 1,
name: `Employee Name Full ${index + 1}`,
email: `worker.performance_${index + 1}@company.com`,
role:
index % 3 === 0
? 'Tech Lead'
: index % 2 === 0
? 'Senior Dev'
: 'Data Scientist',
status: index % 5 === 0 ? 'inactive' : 'active',
}))
}
export default async function TablePage() {
const initialData = await getBigDataFromServer()
return (
<div className="p-8 max-w-6xl mx-auto space-y-6 antialiased bg-gray-50 min-h-screen">
<TableClient initialData={initialData} />
</div>
)
}
The result will be as follows
Comments
Post a Comment