Offloading Analytics to Web Workers with Partytown: How It Improved INP at Shiksha
How Shiksha moved Microsoft Clarity, GTM, and Facebook Pixel off the main thread to web workers with Partytown, reducing main thread blocking and improving INP across 200K+ URLs.
The problem: analytics scripts blocking user interactions
When a user clicks something on your page, the browser needs the main thread free to process that interaction and paint a response. If a third-party script is running at that exact moment — recording a heatmap, firing a tag, sending a pixel event — the interaction waits. That wait is measured directly by INP.
At Shiksha, INP was above 500ms across all page types. Profiling revealed that Microsoft Clarity, running its heatmap recording logic on the main thread, was the single biggest contributor to long tasks during user sessions. Google Tag Manager and Facebook Pixel added further pressure. None of these scripts are user-critical — a user clicking a filter or a button does not need to wait for heatmap data to be recorded before they see a response. But that's exactly what was happening.
INP specifically captures this failure. Unlike LCP (which measures load) or CLS (which measures visual stability), INP measures responsiveness throughout the entire session. Every interaction competes with whatever else is on the main thread. Analytics scripts run continuously, which means they are always competing.
What Partytown does
Partytown is an open-source library from Builder.io that relocates third-party scripts from the main thread into a web worker. Scripts that run in a worker have their own thread — they no longer compete with user interaction handling for CPU time.
The browser's main thread handles layout, paint, and JavaScript execution. Running analytics there creates a direct conflict with interaction handling. Web workers run in parallel, on a separate thread, without that conflict.
At Shiksha, we moved three scripts to Partytown: Microsoft Clarity (heatmap recording), Google Tag Manager, and Facebook Pixel. Of these, Clarity had the most significant per-session main thread footprint due to continuous DOM observation for heatmap data.
Implementation
Basic Partytown setup (Next.js App Router)
// next.config.mjs — copy Partytown service worker files to /public/~partytown/
import { withPartyTown } from '@builder.io/partytown/utils';
export default withPartyTown({
partytown: {
lib: '/~partytown/',
},
// ...rest of Next.js config
});// app/layout.tsx
import { Partytown } from '@builder.io/partytown/react';
import Script from 'next/script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<Partytown
forward={['dataLayer.push', 'fbq', 'clarity']}
resolveUrl={(url: URL) => {
// Clarity requires a proxy — see note below
if (url.hostname.includes('clarity.ms')) {
const proxy = new URL('/api/clarity-proxy', 'https://yourdomain.com');
proxy.searchParams.set('url', url.href);
return proxy;
}
return url;
}}
/>
</head>
<body>
{/* GTM — type="text/partytown" moves it to the web worker */}
<Script
id="gtm"
src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX"
type="text/partytown"
/>
{/* Facebook Pixel */}
<Script id="fb-pixel" type="text/partytown">{`
!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){
n.callMethod?n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;t.src=v;
s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)
}(window,document,'script','https://connect.facebook.net/en_US/fbevents.js');
fbq('init','YOUR_PIXEL_ID');fbq('track','PageView');
`}</Script>
{children}
</body>
</html>
);
}The Microsoft Clarity problem
Clarity doesn't work out of the box with Partytown. Clarity's session recording makes cross-origin XMLHttpRequest calls to www.clarity.ms. Web workers cannot make cross-origin XHR requests without a proxy. If you add Clarity to Partytown without a resolveUrl proxy, it silently fails — no errors, no heatmap data.
The fix is a thin proxy endpoint on your own domain:
// app/api/clarity-proxy/route.ts
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const target = searchParams.get('url');
if (!target) return new Response('Missing url', { status: 400 });
const upstream = await fetch(target);
const body = await upstream.arrayBuffer();
return new Response(body, {
status: upstream.status,
headers: {
'Content-Type': upstream.headers.get('Content-Type') ?? 'application/octet-stream',
'Access-Control-Allow-Origin': '*',
},
});
}GTM and Facebook Pixel work without a proxy.
The JS yielding technique
Partytown handles the continuous main thread load from analytics. A complementary technique addressed JavaScript execution cost during interactions themselves.
The core pattern: long tasks are broken into chunks, and between each chunk the code yields back to the browser. The browser uses that gap to process any pending user input and trigger interaction responses.
// Utility used across the codebase
async function yieldToMain(): Promise<void> {
// scheduler.yield() is the modern API — available in Chrome 115+
if (
typeof window !== 'undefined' &&
'scheduler' in window &&
'yield' in (window as any).scheduler
) {
return (window as any).scheduler.yield();
}
// setTimeout(resolve, 0) gives up the thread for one task turn
return new Promise<void>((resolve) => setTimeout(resolve, 0));
}
// Before triggering CSR page navigation — let any pending render cycle finish
async function handleNavigate(path: string, router: AppRouterInstance) {
await yieldToMain();
router.push(path);
}For state updates not directly tied to the triggering interaction, startTransition signals to React that the work can be deferred if higher-priority updates arrive:
import { startTransition, useState } from 'react';
function CollegeFilter({ colleges }: { colleges: College[] }) {
const [query, setQuery] = useState('');
const [filtered, setFiltered] = useState(colleges);
function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
// Input value — urgent, runs immediately
setQuery(value);
// Filter result — non-urgent, deferred so input stays responsive
startTransition(() => {
setFiltered(
colleges.filter((c) => c.name.toLowerCase().includes(value.toLowerCase()))
);
});
}
return (
<>
<input value={query} onChange={handleSearch} placeholder="Search colleges..." />
<CollegeList colleges={filtered} />
</>
);
}Results
Across 200,000+ URLs (OVP, CTP, ACP, BIP, SIP page types), INP moved from above 500ms to an average of 219.48ms and a median of 220ms — inside the Good zone. 100% of desktop URLs reached Good. Affected URLs in the Poor or Needs Improvement category dropped from approximately 6,900 to 1,110. Mobile reached 218,000 Good-zone URLs.

One caveat: these numbers reflect the full optimization set — Partytown, yielding, reflow reduction, and CSR navigation changes together. The data doesn't isolate Partytown's contribution on its own. Based on profiling, Clarity was the largest single main thread contributor before the work, which points to Partytown having meaningful impact — but a precise attribution number would require a controlled experiment this data doesn't support.
When to use this approach
Open DevTools → Performance tab → record an interaction. If task bars attributed to Clarity, GTM, or similar scripts are overlapping your interaction's event handlers, Partytown applies directly.
Check INP attribution too. The web-vitals library's onINP callback includes inputDelay, processingTime, and presentationDelay. If inputDelay is the dominant number — meaning the browser queued the interaction before even starting to process it — third-party main thread load is the likely culprit.
import { onINP } from 'web-vitals/attribution';
onINP(({ value, attribution }) => {
console.log('INP:', value);
console.log('Input delay:', attribution.inputDelay); // time waiting for main thread
console.log('Processing time:', attribution.processingTime); // time running your handlers
console.log('Presentation delay:', attribution.presentationDelay); // time to next paint
});One less obvious signal: long tasks between interactions, not just during them. Clarity and heatmap tools run continuously. If you see long tasks in Performance recordings when the user isn't doing anything, that's background analytics work — and the right fix is offloading it, not deferring it.
In progress
Three workstreams from Shiksha's broader INP roadmap remain open:
- Virtualization for long list pages — CTP and ACP/BIP/SIP pages render long scrollable lists; virtual scrolling is underway
- GTM container impact analysis — individual tags within GTM vary in their main thread cost; analysis is ongoing
- 6x–10x interaction slowdown investigation — a subset of interactions show outlier latency; root-cause work is in progress