DEV Community

Cover image for INP and Partytown: give the main thread back to your users

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
≤ 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          │
     └────────────────┴──────────────────────┴────────────────┘
     │◄───────────────── interaction latency ───────────────►│
Enter fullscreen mode Exit fullscreen mode
  • 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 ────────►│
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. In the worker, it creates JavaScript Proxies that look like window and document.
  2. 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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Two things to know:

  • Safari doesn't support credentialless. So Safari falls back to the service worker.
  • You can use require-corp instead, which works in Safari. But it blocks every cross-origin image, script or video that doesn't have a crossorigin attribute. 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>
Enter fullscreen mode Exit fullscreen mode

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)
  ]
};
Enter fullscreen mode Exit fullscreen mode

Now let's have some code.

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
Enter fullscreen mode Exit fullscreen mode

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"
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

💡 While you set things up, use /~partytown/debug/partytown.js and add debug: true to 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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

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
    }),
  ],
};
Enter fullscreen mode Exit fullscreen mode

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,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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(j,f);
    })(window,document,'script','dataLayer','GTM-XXXXXXX');
  </script>
</head>
<body>
  <div id="root"></div>
  <script type="module" src="/src/main.tsx"></script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Why not in a component? Because a client-side app renders late. The third-party scripts would wait for React to boot. In index.html, they're found as soon as Partytown starts.

Option B: server-rendered app (the <Partytown /> component)

The package ships a React component: @qwik.dev/partytown/react. It's a thin wrapper around the Partytown snippet. The config becomes JSX props. And the snippet is inlined in the HTML, so there's no extra request for partytown.js.

Example with a root layout (React Router framework mode / Remix style):

// app/root.tsx
import { Partytown } from '@qwik.dev/partytown/react';
import { Links, Meta, Outlet, Scripts } from 'react-router';

const GTM_ID = 'GTM-XXXXXXX';

const gtmSnippet = `
(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_ID}');
`;

export default function App() {
  return (
    <html lang="en">
      <head>
        <Meta />
        <Links />

        {/* 1. Partytown: config as props */}
        <Partytown forward={['dataLayer.push']} />

        {/* 2. GTM in the worker */}
        <script
          type="text/partytown"
          dangerouslySetInnerHTML={{ __html: gtmSnippet }}
        />
      </head>
      <body>
        <Outlet />
        <Scripts />
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

💡 Add debug={true} on <Partytown /> while you set things up.

Scripts added later

If a text/partytown script is added to the page after Partytown started (lazy loading, route change...), recent versions of Partytown (0.14.3 and later) find it automatically. On older versions, you have to tell Partytown:

const script = document.createElement('script');
script.type = 'text/partytown'; // set the type BEFORE adding it to the DOM
script.src = 'https://third-party.com/widget.js';
document.head.appendChild(script);

window.dispatchEvent(new CustomEvent('ptupdate')); // only needed before 0.14.3
Enter fullscreen mode Exit fullscreen mode

Track page views and clicks

A small module, no library needed:

// analytics.ts
declare global {
  interface Window {
    dataLayer: unknown[];
  }
}

export function track(event: string, params: Record<string, unknown> = {}): void {
  if (typeof window === 'undefined') return; // nothing to do on the server

  window.dataLayer = window.dataLayer || [];
  // Patched by Partytown: serialized and sent to the worker
  window.dataLayer.push({ event, ...params });
}
Enter fullscreen mode Exit fullscreen mode

A hook for page views:

// usePageView.ts
import { useEffect } from 'react';
import { useLocation } from 'react-router';
import { track } from './analytics';

export function usePageView(): void {
  const location = useLocation();

  useEffect(() => {
    track('page_view', { page_path: location.pathname + location.search });
  }, [location.pathname, location.search]);
}
Enter fullscreen mode Exit fullscreen mode

Call it once, in your layout:

// Layout.tsx
import { Outlet } from 'react-router';
import { usePageView } from './usePageView';

export function Layout() {
  usePageView();
  return <Outlet />;
}
Enter fullscreen mode Exit fullscreen mode

And the click:

// ProductCard.tsx
import { track } from './analytics';
import { useCart } from './useCart';

interface Product {
  id: string;
  name: string;
  price: number;
}

export function ProductCard({ product }: { product: Product }) {
  const { add } = useCart();

  function handleAddToCart() {
    // 1. UI first: this is what the user is waiting for
    add(product);

    // 2. Tracking: GTM does the heavy work in the worker
    track('add_to_cart', { item_id: product.id, value: product.price });
  }

  return (
    <article>
      <h3>{product.name}</h3>
      <button onClick={handleAddToCart}>Add to cart</button>
    </article>
  );
}
Enter fullscreen mode Exit fullscreen mode

Using Next.js? Partytown has its own Next.js integration page. Check it, the setup is a bit different.

Check that it works

Don't trust, verify. Three quick checks in Chrome DevTools:

  1. Console: with the debug build, Partytown logs what runs in the worker.
  2. Sources panel: look at the threads. GTM should run in the Partytown web worker, not in the main one.
  3. Performance panel: record a page load and a few clicks. The long tasks from googletagmanager.com should be gone from the main thread track.

Then check in GTM Preview mode that your page_view and add_to_cart events arrive. If nothing arrives, check your forward config first. It's the most common mistake.

Where INP really wins

Let's go back to our three phases.

               Input delay     Processing      Presentation
               ───────────     ──────────      ────────────
Partytown:     big impact      some impact     small impact
Enter fullscreen mode Exit fullscreen mode
  • Input delay: the big win. Third-party long tasks no longer block the main thread, so the click is handled right away.
  • Processing duration: the work done by third-party scripts when you push an event (like in addToCart) happens in the worker, not in your handler's frame.
  • Presentation delay: Partytown batches DOM reads and writes into group updates. Less layout thrashing from third-party code.

Trade-offs: no magic here

Partytown is a great tool. It's not a silver bullet. Here is what you need to know before using it.

  • preventDefault() has no effect. When the worker receives the event, it's already too late. A script that needs to block a link or a form submit won't work.
  • DOM operations are throttled. Each call crosses threads. That's fine for analytics, not for scripts that build UI.
  • UI-heavy scripts are a bad fit. A chat widget, a cookie banner with a dialog, anything that injects a lot of DOM nodes: keep them on the main thread.
  • CORS. Some third-party scripts don't send the right CORS headers. You'll need to proxy them.
  • Cross-origin iframes created from a Partytown script can't persist cookies or storage.
  • Bad scripts stay bad. A script that reads the whole document every X milliseconds with setInterval will overload Partytown.

A quick rule:

Good fit ✅ Bad fit ❌
Google Tag Manager Chat widgets
Google Analytics 4 Cookie consent dialogs
Facebook Pixel Scripts that call preventDefault()
Mixpanel, Segment, Amplitude Scripts that inject a lot of DOM

Measure, don't guess

Never move a script "because it should be better". Measure before, measure after.

INP is a field metric. Your real users on real devices are the source of truth. Use the web-vitals library with the attribution build to see which phase is slow. It works the same in React and Angular:

import { onINP } from 'web-vitals/attribution';

onINP(({ value, attribution }) => {
  console.log('INP:', value);
  console.log('Element:', attribution.interactionTarget);
  console.log('Input delay:', attribution.inputDelay);
  console.log('Processing:', attribution.processingDuration);
  console.log('Presentation:', attribution.presentationDelay);
  // send it to your analytics
});
Enter fullscreen mode Exit fullscreen mode

Put it in main.ts (Angular) or main.tsx (React).

Here's how to read the result:

  • High input delay? Look at what runs before the click. Third-party scripts are the usual suspects. This is where Partytown shines.
  • High processing duration? It's probably your handler. Partytown won't help.
  • High presentation delay? Look at your DOM size and your layout.

In the lab, INP depends on what you click. Two tips:

  • Interact with the page while it loads. That's when the main thread is the busiest.
  • Use Total Blocking Time (TBT) as a proxy. It's not INP, but it tells you if the main thread is crowded.

Partytown won't fix your own code

If your click handler takes 400 ms, moving GTM to a worker changes nothing.

For your own code, the classic tools still apply:

  • Break long tasks into smaller ones.
  • Yield to the main thread (setTimeout, or scheduler.yield() where it's supported).
  • Show visual feedback first, do the heavy work after.
  • Move heavy computation to your own web worker.

Partytown handles the code you don't own. You handle the code you own.

Conclusion

INP measures what users feel: "I clicked, did something happen?"

Third-party scripts are often the hidden reason for a bad INP. They take the main thread, and your users wait.

Partytown moves them into a web worker, and keeps them working thanks to a clever synchronous bridge (Atomics or a service worker).

In Angular and React, the setup is small:

  • serve the Partytown files
  • add the config and type="text/partytown"
  • forward the functions your code calls
  • keep your tracking code as simple as a dataLayer.push

It's not magic. It's in beta, it has trade-offs, and it's not made for UI scripts. But for analytics, tag managers and pixels, it can give the main thread back to the only code that should be there: yours.

Measure first. Move the right scripts. Measure again. 🎉

Top comments (0)