INP and Partytown: give the main thread back to your users
DEV Community

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:

  1. In the worker, it creates JavaScript Proxies that look like window and document. Each call to these proxies is sent to the main thread through a synchronous channel, and the worker waits for the answer.
  2. 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 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>

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.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 });
  }
}

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(
Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.