INP and Partytown: give the main thread back to your users
Introduction
You click on a button. Nothing happens. You click again. Still nothing. Then the menu opens... and closes right away. We have all lived this. It's a responsiveness problem. And since March 12, 2024, Google measures it with a Core Web Vital: INP.
Very often, the problem is not your code. It's the code you didn't write: analytics, tag managers, pixels, A/B testing tools...
This article explains:
- What INP really measures
- Why third-party scripts hurt it so much
- How Partytown works
- How to use it in React and Angular, with real examples
- When you should not use it
INP in a Few Words
INP means Interaction to Next Paint. It measures the time between a user interaction and the next frame painted by the browser. In other words: how long does the user wait before seeing that something happened?
Only three types of interaction count:
- A click with a mouse
- A tap on a touchscreen
- A key press
Scroll, hover, and zoom are not measured.
INP looks at all the interactions during the visit, and reports the worst one. If there are a lot of interactions, the browser ignores the highest one for every 50 interactions. That way, one random hiccup does not ruin your score.
The thresholds, at the 75th percentile of your real users:
| INP Result | Rating |
|---|---|
| ≤ 200 ms | ๐ข Good |
| 200 ms to 500 ms | ๐ Needs improvement |
| > 500 ms | ๐ด Poor |
Just a reminder: INP replaced FID (First Input Delay). FID only measured the waiting time of the first interaction. INP measures every interaction, from start to paint. Much stricter.
What's Inside an Interaction?
An interaction has three phases:
user clicks
│
โผ
next frame ││
│
โผ
├────────────────┬──────────────────────┬────────────────┤
│ Input delay │ Processing duration │ Presentation │
│ delay │ │ delay │
├────────────────┴──────────────────────┴────────────────┤
│โ───────────────── interaction latency ────────────────โบ│
- Input delay: the time before your event handlers start. The browser is busy with something else.
- Processing duration: the time to run all your event handlers.
- Presentation delay: the time to compute style, layout, and paint the next frame.
Keep the first one in mind. This is where third-party scripts hurt the most.
The Main Thread: A One-Lane Road
The browser has one main thread. It does everything:
- Runs JavaScript
- Handles events
- Computes style and layout
- Paints
And it does one thing at a time. A task longer than 50 ms is a long task. During a long task, the browser can't respond to the user.
Now imagine your page with Google Tag Manager, a marketing pixel, and an analytics script. The user clicks at the wrong moment:
Main thread, without Partytown
──[ your app ]──[ tag manager 180ms ]──[ pixel 120ms ]──[ click handler ]──[ paint ]──
โฒ user clicks here
│โ──────── input delay ────────โบ│
Your click handler is fast. Your code is clean. And your INP is still bad.
This is the key point: you can write perfect code and still fail INP, because you share the road with scripts you don't control.
Partytown: Move the Party Somewhere Else
Partytown is a small library, lazy-loaded, maintained by the QwikDev team. Its goal is simple: move third-party scripts from the main thread into a web worker.
The philosophy: the main thread is for your code. Everything that is not in the critical path can go elsewhere.
flowchart LR
subgraph Before["Without Partytown"]
MT1["Main thread<br/>your app + GTM + pixel + analytics"]
end
subgraph After["With Partytown"]
MT2["Main thread<br/>your app"]
WW["Web worker<br/>GTM + pixel + analytics"]
WW -. "DOM calls through a proxy" .-> MT2
end
And the same timeline as before:
Main thread, with Partytown
──[ your app ]──[ click handler ]──[ paint ]──
โฒ user clicks here: almost no input delay
Web worker
──[ tag manager 180ms ]──[ pixel 120ms ] ← nobody waits for this
The long tasks still exist. They just don't block the user anymore.
โ ๏ธ Partytown is still in beta. It's not guaranteed to work for every script. Test before you ship.
How Does It Work?
Here comes the tricky part. A web worker has no DOM. No document, no real window. But third-party scripts expect them. They call document.cookie, getBoundingClientRect(), addEventListener...
On top of that, communication between a worker and the main thread is asynchronous. Third-party scripts are written in a synchronous way. No await, no callback.
So Partytown does two things:
- In the worker, it creates JavaScript Proxies that look like
windowanddocument. Each call to these proxies is sent to the main thread through a synchronous channel, and the worker waits for the answer. - From the script's point of view, nothing changed.
This code works as is inside the worker:
const rect = element.getBoundingClientRect(); // blocking call, like on the main thread
console.log(rect.x, rect.y);
There are two ways to get this synchronous channel.
Option 1: Service Worker (the Fallback)
The worker sends a synchronous XHR. A service worker intercepts it, asks the main thread, and answers.
sequenceDiagram
participant W as Web worker (3rd-party script)
participant SW as Service worker
participant M as Main thread (DOM)
W->>SW: sync XHR "getBoundingClientRect()"
SW->>M: postMessage
M-->>SW: result
SW-->>W: response { x, y }
Note over W: for the script, it was a normal blocking call
That's why you'll see a lot of proxytown requests in the network tab. They are not real HTTP requests. They're handled locally. You can hide them with the -url:proxytown filter in Chrome DevTools.
Option 2: Atomics (the Fast One)
With Atomics and SharedArrayBuffer, the worker writes the request, calls Atomics.wait(), and reads the result when the main thread answers. No service worker needed. It's about 10x faster to transfer data between threads.
But there's a condition: the page must be cross-origin isolated. You need these response headers on your document:
Cross-Origin-Embedder-Policy: credentialless
Cross-Origin-Opener-Policy: same-origin
Two things to know:
- Safari doesn't support
credentialless. So Safari falls back to the service worker. You can userequire-corpinstead, which works in Safari. But it blocks every cross-origin image, script, or video that doesn't have acrossoriginattribute. Be careful with your CDN. - If the headers are not there, no problem: Partytown falls back to the service worker automatically.
The Two Rules to Remember
Before the code, two rules. They are the same for every framework.
Rule 1: type="text/partytown"
The browser doesn't know this type, so it doesn't run the script. Partytown finds it with a selector and runs it in the worker.
- <script src="https://third-party.com/script.js"></script>
+ <script type="text/partytown" src="https://third-party.com/script.js"></script>
Partytown is opt-in. Only the scripts with this type move. All the others stay where they are. You choose.
Rule 2: forward
Your own code still calls dataLayer.push(...) on the main thread. For example, when a user adds a product to the cart. But GTM now lives in the worker. So Partytown needs to know which window functions to patch and send to the worker. That's the role of forward. It even queues the calls made before Partytown is ready.
flowchart LR
A["Your code<br/>dataLayer.push(...)"] -->|"patched by forward"| B["Partytown<br/>(main thread side)"]
B -->|"serialized message"| C["GTM<br/>in the web worker"]
C -->|"network"| D["Google servers"]
By default, a forwarded call only runs in the worker. If you need the original function to also run on the main thread, use preserveBehavior:
partytown = {
forward: [
['dataLayer.push', { preserveBehavior: true }], // runs on both sides
'fbq' // worker only (default)
]
};
Angular
Our goal: run Google Tag Manager in the worker, and track page views and a click from our Angular app.
Step 1: Install
npm install @qwik.dev/partytown
Step 2: Serve the Partytown Files
The worker and the service worker are real files. They must be served by your app. In angular.json, add them to the assets:
"build": {
"options": {
"assets": [
{
"glob": "**/*",
"input": "node_modules/@qwik.dev/partytown/lib",
"output": "/~partytown"
}
]
}
}
After a build, you should see a ~partytown/ folder in your output, with partytown.js, partytown-sw.js, partytown-atomics.js...
Step 3: index.html
Third-party scripts must be there early, before Angular starts. So we put everything in src/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>My Shop</title>
<base href="/">
<!-- 1. Partytown configuration: BEFORE partytown.js -->
<script>
partytown = {
forward: ['dataLayer.push']
};
</script>
<!-- 2. Partytown itself: no async, no defer -->
<script src="/~partytown/partytown.js"></script>
<!-- 3. Google Tag Manager, moved to the worker -->
<script type="text/partytown">
(function(w, d, s, l, i) {
w[l] = w[l] || [];
w[l].push({'gtm.start': new Date().getTime(), event: 'gtm.js'});
var f = d.getElementsByTagName(s)[0],
j = d.createElement(s),
dl = l != 'dataLayer' ? '&l=' + l : '';
j.async = true;
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl;
f.parentNode.insertBefore(j, f);
})(window, document, 'script', 'dataLayer', 'GTM-XXXXXXX');
</script>
</head>
<body>
<app-root></app-root>
</body>
</html>
๐ก While you set things up, use
/~partytown/debug/partytown.jsand adddebug: trueto the config. You get logs in the console. Don't ship the debug build to production.
Step 4: An Analytics Service
Now, our Angular code must talk to GTM. We only push to dataLayer. Thanks to forward, Partytown sends it to the worker.
// analytics.service.ts
import { DOCUMENT, Injectable, inject } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { filter } from 'rxjs';
declare global {
interface Window {
dataLayer: unknown[];
}
}
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
private readonly router = inject(Router);
private readonly window = inject(DOCUMENT).defaultView; // null on the server (SSR)
constructor() {
// An SPA has no real page load after the first one.
// So we send a page view on each navigation.
this.router.events.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
takeUntilDestroyed()
).subscribe((event) => this.track('page_view', {
page_path: event.urlAfterRedirects
}));
}
track(event: string, params: Record<string, unknown> = {}): void {
if (!this.window) return; // nothing to do on the server
this.window.dataLayer = this.window.dataLayer || [];
// This call is patched by Partytown: it's serialized and sent to the worker.
// GTM does its heavy work there, not on the main thread.
this.window.dataLayer.push({ event, ...params });
}
}
Start it with the application:
// app.config.ts
import { ApplicationConfig, inject, provideAppInitializer } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { AnalyticsService } from './analytics.service';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideAppInitializer(() => {
inject(AnalyticsService); // create the service, so it listens to the router
}),
],
};
Step 5: Track a Click
// product-card.ts
import { Component, inject, input } from '@angular/core';
import { AnalyticsService } from './analytics.service';
import { CartStore } from './cart.store';
interface Product {
id: string;
name: string;
price: number;
}
@Component({
selector: 'app-product-card',
template: `
<h3>{{ product().name }}</h3>
<button (click)="addToCart()">Add to cart</button>
`,
})
export class ProductCard {
private readonly analytics = inject(AnalyticsService);
private readonly cart = inject(CartStore);
readonly product = input.required<Product>();
addToCart(): void {
// 1. What the user wants to see: update the UI first
this.cart.add(this.product());
// 2. Tracking: cheap on the main thread, GTM works in the worker
this.analytics.track('add_to_cart', {
item_id: this.product().id,
value: this.product().price,
});
}
}
Look at the click handler. Without Partytown, dataLayer.push wakes up GTM on the main thread, in the same frame as your click. With Partytown, the push is sent to the worker, and GTM runs its tags there. Your handler stays small. Your processing duration stays small.
React
Same goal, same app: GTM in the worker, page views and a click.
There are two ways to do it in React:
- Option A:
index.html, for a client-side app (Vite, Create React App...). The simplest. - Option B: the
<Partytown />component, for apps rendered on the server (React Router framework mode, Remix, Gatsby...).
Step 1: Install and Copy the Files
npm install @qwik.dev/partytown
Partytown comes with a small CLI to copy its files into your public folder. Run it before dev and build:
"scripts": {
"partytown": "partytown copylib public/~partytown",
"dev": "npm run partytown && vite",
"build": "npm run partytown && vite build"
}
Add public/~partytown to your .gitignore. It's generated.
Option A: Client-Side App (Vite)
Everything goes in index.html, like in Angular:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>My Shop</title>
<script>
partytown = {
forward: ['dataLayer.push']
};
</script>
<script src="/~partytown/partytown.js"></script>
<script type="text/partytown">
(function(w, d, s, l, i) {
w[l] = w[l] || [];
w[l].push({'gtm.start': new Date().getTime(), event: 'gtm.js'});
var f = d.getElementsByTagName(s)[0],
j = d.createElement(s),
dl = l != 'dataLayer' ? '&l=' + l : '';
j.async = true;
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl;
f.parentNode.insertBefore(
Comments
No comments yet. Start the discussion.