The page appears in less than a second.
The user clicks a button.
Nothing happens.
The content was visible, the network request had finished, and the load report looked respectable. The main thread was simply too busy parsing, executing, and hydrating JavaScript to respond.
A page can load quickly and still feel slow.
If you optimize only the first render, you may be measuring the moment the interface becomes visible rather than the moment it becomes usable.
Start with a real interaction
Do not begin with a bundle-size target.
Begin with an action a user actually performs:
- opening the main navigation,
- typing into search,
- applying a filter,
- expanding a product panel,
- adding an item to a cart,
- submitting a form.
Then record what the user experiences:
Click
β
Visible response begins
β
Interface finishes updating
If there is a noticeable pause after the input, the page has a responsiveness problem even when initial content appeared quickly.
Interaction to Next Paint, or INP, measures the latency of user interactions across a page visit. Web performance guidance recommends an INP of 200 milliseconds or less at the 75th percentile for a good user experience.
Field data is the strongest evidence because it reflects real devices, networks, and interactions. Lab tools help reproduce and explain the problem.
Find long tasks in the Performance panel
Open browser DevTools, switch to the Performance panel, and record the interaction that feels slow.
During the recording:
- wait for the page to settle,
- click or type exactly as a user would,
- stop recording after the interface updates,
- inspect the main-thread timeline around the interaction.
A long task is an uninterrupted period where the main UI thread stays busy for at least 50 milliseconds. While that task runs, the browser cannot process another main-thread task, including input handling and rendering work.
Common causes include:
- expensive event handlers,
- parsing and evaluating large scripts,
- synchronous data processing,
- repeated rendering,
- forced layout,
- third-party scripts,
- hydration work,
- initializing features not yet needed.
In Chromium-based DevTools, long tasks are marked visually in the performance trace. Expand the task and inspect the call tree or bottom-up view to find the functions consuming time.
Do not optimize the first large function name you see without checking the complete stack. Framework code may be executing because application code requested too much work.
Measure JavaScript, not only transfer size
Compression can make a script small on the network while leaving substantial work for the browser.
JavaScript must still be:
Downloaded
β
Decompressed
β
Parsed
β
Compiled
β
Executed
A package that adds only a modest compressed payload can still perform expensive initialization, walk a large DOM, register many listeners, or schedule repeated work.
Use the Network panel to understand transfer size, but use the Performance panel to understand main-thread cost.
Ask these questions:
- Is this script needed before the first interaction?
- Is the complete library used?
- Does initialization run for components not present on the page?
- Does the script execute during every navigation?
- Is the same calculation repeated?
- Is third-party code running at the moment of input?
Remove work before splitting work
Code splitting is useful, but the best delayed JavaScript is often JavaScript the page no longer needs.
Look for:
- unused libraries,
- duplicated utilities,
- obsolete analytics integrations,
- polyfills outside the supported browser policy,
- large dependencies used for one small helper,
- components loaded globally but rendered rarely,
- initialization that can be replaced with HTML or CSS.
For example, do not load a charting library on every page if only one route displays a chart.
const { renderChart } = await import("./chart.js");
renderChart(data);
Dynamic import moves the module behind the point where it becomes necessary.
That improves responsiveness only if the feature genuinely does not need the code earlier. Moving required work directly into the click handler may replace load delay with interaction delay.
A better option can be to load an optional feature during idle time or after primary content becomes interactive.
Break long work into smaller tasks
Sometimes the work is necessary but does not need to block the main thread in one uninterrupted chunk.
Imagine processing a large collection:
function processAllItems(items) {
for (const item of items) {
processItem(item);
}
}
If the loop becomes a long task, yield periodically:
async function processItems(items) {
for (let index = 0; index < items.length; index++) {
processItem(items[index]);
if (index % 100 === 0) {
await scheduler.yield();
}
}
}
Yielding gives the browser an opportunity to process higher-priority work such as input and rendering before continuing.
The best chunk size depends on the cost of each item and target devices. A fixed item count is easy to demonstrate but should be measured in the real application.
If scheduler.yield() is not available in your browser support range, use an appropriate scheduling fallback and keep the behavior progressively enhanced.
Move CPU-heavy work off the main thread
For expensive calculations that do not need direct DOM access, consider a Web Worker.
Good candidates include:
- parsing large datasets,
- image manipulation,
- compression,
- complex search indexing,
- numerical calculations.
Workers add message-passing and lifecycle complexity. Use them when measurement shows CPU work is blocking interactions, not as a default wrapper for every function.
Keep interaction handlers focused
An event handler should perform the minimum work required to produce the next useful visual response.
This handler does too much synchronously:
button.addEventListener("click", () => {
calculateLargeReport();
storeAnalyticsPayload();
updateRecommendations();
openPanel();
});
The user waits for unrelated work before seeing the panel.
Prioritize the visible response:
button.addEventListener("click", () => {
openPanel();
setTimeout(() => {
calculateLargeReport();
storeAnalyticsPayload();
updateRecommendations();
}, 0);
});
This simplified example separates tasks, but setTimeout(..., 0) does not make expensive work cheap. If the deferred work is still large, split it further, move it to a worker, or remove it.
Also avoid reading layout information immediately after changing styles when possible. Repeated read-write cycles can force synchronous layout:
// Repeated read/write pattern
for (const item of items) {
item.style.width = `${container.offsetWidth / 2}px`;
}
Read shared measurements once, then apply updates:
const width = container.offsetWidth / 2;
for (const item of items) {
item.style.width = `${width}px`;
}
Audit third-party scripts
Your application is not the only code using the main thread.
Analytics, experimentation, advertising, chat, support widgets, personalization, and tag managers can all execute around user interactions.
For each third-party script, document:
- who owns it,
- why it exists,
- where it loads,
- whether it is required on every page,
- how much main-thread time it consumes,
- what user data it accesses,
- what happens when it fails.
Remove scripts without a current owner or measurable purpose.
Load nonessential tools after the primary experience is usable. Be careful with a tag manager that can silently restore removed scripts later.
A third-party script may not be large enough to dominate network transfer and can still create long tasks through initialization or repeated callbacks.
Watch for hydration delays
Server-rendered HTML can appear before client-side JavaScript finishes attaching behavior.
That creates a frustrating state:
The button is visible
The button looks interactive
The click handler is not ready
To reduce the gap:
- ship less client-side JavaScript,
- avoid hydrating static sections,
- isolate interactive components,
- defer low-priority widgets,
- keep initial client state small,
- prefer server or platform behavior when interaction is unnecessary.
Framework-specific tools differ, but the principle is stable: do not make the browser hydrate parts of the page that never needed client-side ownership.
Verify the improvement
Repeat the same interaction under the same conditions.
Compare:
Before
- interaction delay
- longest task
- total main-thread work
- JavaScript evaluation time
After
- interaction delay
- longest task
- total main-thread work
- JavaScript evaluation time
Use CPU throttling to reveal problems hidden by a fast development machine. Then verify with field data, because real users have different devices, extensions, background apps, and interaction patterns.
A lower bundle size is useful evidence, but it is not proof that the slow interaction is fixed.
Responsiveness audit checklist
The takeaway
Initial load speed and interaction speed are related, but they are not the same problem.
A responsive page needs room on the main thread for user input and rendering.
The practical workflow is:
Choose a real interaction
β
Record it in DevTools
β
Find the blocking task
β
Remove unnecessary work
β
Delay, split, or move necessary work
β
Repeat the same interaction
β
Verify with field data
Do not optimize only the moment the page becomes visible.
Optimize the moment the user tries to use it.
Which interaction in your application feels slower than the initial page load?
Read the web.dev guide to optimizing INP
Sources and further reading
- MDN: JavaScript performance optimization
- MDN: PerformanceLongTaskTiming
- web.dev: Optimize long tasks
- web.dev: Optimize Interaction to Next Paint
- W3C: Long Tasks API
Connect with Me
If you found this article helpful, let's connect!
- π» GitHub: johnnylemonny
Top comments (0)