Modern web applications demand more from browsers than ever before. Analytics, personalization, A/B testing, chat widgets, and countless third-party scripts compete for the same precious resource: the main thread. When this thread gets overwhelmed, users experience jank, delayed interactions, and frustration.
Off-Main-Thread (OMT) Architecture is a design pattern that moves non-UI work away from the main thread, allowing the browser to stay responsive while still executing heavy JavaScript operations.
Table of Contents
- Understanding the Main Thread Problem
- Web Workers: The Foundation
- How Off-Main-Thread Architecture Works
- Partytown: OMT Made Practical
- Advantages of OMT Architecture
- Complexities and Challenges
- Production Usage
- Things to Keep in Mind
- Further Reading
Understanding the Main Thread Problem
The browser's main thread is responsible for:
- Parsing HTML and CSS
- Executing JavaScript
- Calculating layouts and styles
- Painting pixels to the screen
- Handling user interactions (clicks, scrolls, typing)
When you add heavy JavaScript execution to this list, something has to wait. That "something" is usually user interactionβcausing the dreaded jank.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β MAIN THREAD TIMELINE β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Parse β JS β Layout β Paint β JS β Layout β Paint β
β HTML β Execute β Calc β Screen β Execute β Calc β Screenβ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β β β β β β β
β β ββββββββββββββββββββββββββββββββββββ β β β
β β Long Task (>50ms) - UI Blocked! β β β
β β User clicks here... waits... waits..β β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
The 50ms Rule
According to the RAIL performance model, any task taking longer than 50 milliseconds is considered a "long task" and risks making the UI feel sluggish. Third-party scripts like Google Analytics, Facebook Pixel, or chat widgets can easily exceed this threshold.
Web Workers: The Foundation
Web Workers are the browser's mechanism for running JavaScript in background threads, separate from the main thread.
Types of Workers
| Worker Type | Purpose | DOM Access | Scope |
|---|---|---|---|
| Dedicated Worker | General computation | β None | Single page |
| Shared Worker | Shared state across tabs | β None | Multiple pages (same origin) |
| Service Worker | Network proxy, offline | β None | Origin-wide |
Basic Web Worker Example
main.js (Main Thread)
// Create a new worker
const worker = new Worker('worker.js');
// Send data to the worker
worker.postMessage({ numbers: [1, 2, 3, 4, 5], operation: 'sum' });
// Receive results from the worker
worker.onmessage = (event) => {
console.log('Result:', event.data); // Result: 15
};
// Handle errors
worker.onerror = (error) => {
console.error('Worker error:', error.message);
};
worker.js (Worker Thread)
self.onmessage = (event) => {
const { numbers, operation } = event.data;
let result;
if (operation === 'sum') {
result = numbers.reduce((a, b) => a + b, 0);
}
// Send result back to main thread
self.postMessage(result);
};
Key Limitations of Web Workers
- No DOM Access: Workers cannot directly read or modify the DOM
-
No
windowObject: Limited browser APIs available -
Communication Overhead: Data must be serialized via
postMessage() - Separate Context: No shared memory (unless using SharedArrayBuffer)
How Off-Main-Thread Architecture Works
OMT architecture uses workers strategically to keep heavy operations away from the main thread while still allowing those operations to interact with the DOM when necessary.
The Communication Pattern
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β OFF-MAIN-THREAD ARCHITECTURE β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
MAIN THREAD WORKER THREAD
βββββββββββββββββββββββ βββββββββββββββββββββββ
β β β β
β βββββββββββββββββ β postMessage() β βββββββββββββββββ β
β β DOM β β βββββββββββββββββββββ β β Heavy JS β β
β β Rendering β β β β Execution β β
β β Layout β β βββββββββββββββββββββΊ β β (Analytics, β β
β β Paint β β postMessage() β β Parsing, β β
β βββββββββββββββββ β β β Compute) β β
β β β βββββββββββββββββ β
β βββββββββββββββββ β β β
β β User Input β β Proxy Layer for β βββββββββββββββββ β
β β Event Loop β β DOM Operations β β Virtual DOM β β
β β (clicks, β β βββββββββββββββββββββ β β Proxy β β
β β scroll, β β βββββββββββββββββββββΊ β β Interface β β
β β typing) β β Results/Data β βββββββββββββββββ β
β βββββββββββββββββ β β β
β β β β
β π’ Stays Fast! β β π Does Heavy Work β
βββββββββββββββββββββββ βββββββββββββββββββββββ
The Flow
- Initialize: Main thread creates a worker and loads heavy scripts into it
- Proxy Setup: A proxy layer intercepts DOM API calls in the worker
- Message Passing: DOM operations become serialized messages
- Main Thread Execution: Only actual DOM mutations run on main thread
- Response: Results are sent back to the worker
- UI Remains Responsive: Main thread is free for rendering and input
Partytown: OMT Made Practical
Partytown by Builder.io is a library that makes off-main-thread architecture accessible. It runs third-party scripts inside a web worker while proxying DOM access.
How Partytown Works
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β PARTYTOWN ARCHITECTURE β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
βββββββββββββββββββββββ βββββββββββββββββββββββββββββββ
β MAIN THREAD β β WEB WORKER β
β β β β
β 1. Load Partytown β β 2. Scripts loaded here β
β runtime β ββββββββββββββββββββββΊ β (GA, GTM, FB Pixel) β
β β β β
β βββββββββββββββββ β β βββββββββββββββββββββββββ β
β β Real DOM β β 3. Proxy calls β β Virtual DOM Proxy β β
β β β β ββββββββββββββββββββββ β β β β
β β document β β "read document.cookie"β β document.cookie β β
β β window β β β β window.location β β
β β localStorage β β 4. Real values β β localStorage β β
β β β β ββββββββββββββββββββββΊ β β β β
β βββββββββββββββββ β "session_id=abc123" β βββββββββββββββββββββββββ β
β β β β
β 5. Only minimal β β Script thinks it has β
β DOM work here β β normal DOM access β
βββββββββββββββββββββββ βββββββββββββββββββββββββββββββ
Basic Setup
1. Install Partytown
npm install @builder.io/partytown
2. Copy library files to your public folder
npx partytown copylib public/~partytown
3. Add to your HTML
<!DOCTYPE html>
<html>
<head>
<!-- Partytown configuration (optional) -->
<script>
partytown = {
forward: ['dataLayer.push'], // Forward these calls to worker
debug: true // Enable debug mode
};
</script>
<!-- Load Partytown runtime -->
<script src="/~partytown/partytown.js"></script>
<!-- Third-party scripts with type="text/partytown" -->
<script type="text/partytown" src="https://www.googletagmanager.com/gtag/js?id=GA_ID"></script>
<script type="text/partytown">
window.dataLayer = window.dataLayer || [];
function gtag(){ dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', 'GA_ID');
</script>
</head>
<body>
<!-- Your app content -->
</body>
</html>
Framework Integration
Next.js
// pages/_document.js
import { Html, Head, Main, NextScript } from 'next/document';
import { Partytown } from '@builder.io/partytown/react';
export default function Document() {
return (
<Html>
<Head>
<Partytown forward={['dataLayer.push']} />
<script
type="text/partytown"
src="https://www.googletagmanager.com/gtag/js?id=GA_ID"
/>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
React (Vite/CRA)
// index.html or App component
import { Partytown } from '@builder.io/partytown/react';
function App() {
return (
<>
<Partytown forward={['dataLayer.push', 'fbq']} />
{/* Your app */}
</>
);
}
Under the Hood: The Proxy Magic
Partytown uses several clever techniques:
// Simplified view of how Partytown proxies work
// In the worker, Partytown creates proxy objects:
const documentProxy = new Proxy({}, {
get(target, prop) {
if (prop === 'cookie') {
// Synchronously request from main thread
return syncRequest('document.cookie');
}
// ... handle other properties
},
set(target, prop, value) {
if (prop === 'cookie') {
// Send to main thread
postToMain({ type: 'set', path: 'document.cookie', value });
}
return true;
}
});
// Scripts in the worker see this proxy as the real document
Synchronous Access Challenge
Web Workers are asynchronous by nature, but many third-party scripts expect synchronous DOM access. Partytown solves this using:
- Atomics and SharedArrayBuffer (when available)
- Synchronous XMLHttpRequest (fallback)
- Service Worker intermediary (another fallback)
Advantages of OMT Architecture
1. Improved Core Web Vitals
| Metric | Impact |
|---|---|
| First Input Delay (FID) | Reduced blocking = faster response to first interaction |
| Interaction to Next Paint (INP) | Less main thread contention = smoother interactions |
| Total Blocking Time (TBT) | Heavy scripts don't contribute to blocking time |
| Largest Contentful Paint (LCP) | Main thread free to render content faster |
2. Better User Experience
BEFORE OMT:
User clicks β [Wait 200ms for JS] β Response
β
Main thread busy
with analytics
AFTER OMT:
User clicks β [5ms] β Response
β
Main thread free
Analytics in worker
3. Isolation and Stability
- Crash Isolation: A misbehaving script in a worker won't freeze the page
- Memory Isolation: Worker memory issues don't affect main thread
- CPU Isolation: Worker can use dedicated CPU time
4. Clearer Performance Budgets
// You can now categorize scripts
const mainThreadScripts = [
'app-bundle.js', // Core app logic
'critical-ui.js', // UI interactions
];
const workerScripts = [
'analytics.js', // Google Analytics
'tracking.js', // Marketing pixels
'chat-widget.js', // Customer support
'personalization.js', // A/B testing
];
5. Measurable Improvements
Real-world results from Partytown users:
- Up to 99% reduction in third-party script main thread time
- 10-50ms reduction in Total Blocking Time
- Significant FID improvements on script-heavy marketing pages
Complexities and Challenges
1. Communication Overhead
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β MESSAGE PASSING OVERHEAD β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Without OMT:
document.cookie (1 operation) β 0.01ms
With OMT:
document.cookie (1 operation):
Worker: Serialize request β 0.1ms
Worker β Main: postMessage β 0.5ms
Main: Execute β 0.01ms
Main β Worker: postMessage β 0.5ms
Worker: Deserialize response β 0.1ms
Total: ~1.2ms
For chatty scripts with many DOM calls, this adds up!
2. Synchronous API Challenges
Many browser APIs are synchronous but workers are async:
// This works in main thread:
const width = element.offsetWidth; // Immediate value
// In a worker, this needs special handling:
const width = await getFromMainThread('element.offsetWidth');
// Or Partytown's sync mechanism via Atomics
3. Limited Browser API Access
Workers don't have access to:
- DOM (
document,Element, etc.) -
window(partially available asself) -
localStorage/sessionStorage(directly) - Certain APIs like
alert(),confirm(),prompt()
4. Debugging Complexity
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β DEBUGGING CHALLENGES β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Traditional:
Console β Script β Error β Stack trace β Line number
With OMT:
Console β Worker proxy β Actual script β Error β
β
Proxied stack trace β Virtual line number β π€ Where?
DevTools improvements needed:
- Trace across thread boundaries
- Map proxied calls to original scripts
- Monitor message passing performance
5. Script Compatibility
Not all scripts work well in workers:
| Script Type | OMT Compatibility |
|---|---|
| Analytics (GA, GTM) | β Excellent |
| Chat widgets | β οΈ May need config |
| A/B testing | β Usually good |
| Payment SDKs | β Often problematic |
| UI libraries | β Not suitable |
| Heavy DOM manipulation | β Not suitable |
6. State Synchronization
// Challenge: Keeping state in sync
// Main thread state:
window.userLoggedIn = true;
// Worker sees stale state:
if (window.userLoggedIn) { // Might be outdated!
trackLoggedInUser();
}
// Solution: Forward state changes
partytown = {
forward: ['dataLayer.push', 'updateUserState']
};
Production Usage
Companies Using OMT Architecture
| Company | Use Case |
|---|---|
| Builder.io | Creator of Partytown, uses it extensively |
| Shopify | Hydrogen framework supports Partytown |
| Netlify | Edge functions + Partytown for marketing sites |
| Various e-commerce | Marketing-heavy sites with many third-party scripts |
Real Production Examples
E-commerce Site
Before Partytown:
- Total Blocking Time: 850ms
- Third-party script time: 600ms
- FID: 180ms
After Partytown:
- Total Blocking Time: 280ms (-67%)
- Third-party script time: ~0ms on main thread
- FID: 45ms (-75%)
Marketing Landing Page
Scripts moved to worker:
- Google Analytics
- Google Tag Manager
- Facebook Pixel
- Hotjar
- Intercom
Result:
- TTI improved by 40%
- Conversion rate increased 12%
Browser Support
| Browser | Support |
|---|---|
| Chrome 80+ | β Full support |
| Firefox 79+ | β Full support |
| Safari 15+ | β Full support |
| Edge 80+ | β Full support |
| IE 11 | β No worker support |
Things to Keep in Mind
1. Start with the Right Candidates
β
GOOD CANDIDATES:
βββ Analytics scripts (GA, Adobe Analytics)
βββ Tag managers (GTM, Tealium)
βββ Marketing pixels (Facebook, LinkedIn, Twitter)
βββ Heatmap tools (Hotjar, FullStory)
βββ Chat widgets (Intercom, Drift)
βββ A/B testing (Optimizely, VWO)
β POOR CANDIDATES:
βββ Payment processors (Stripe.js, PayPal)
βββ Authentication SDKs
βββ Core UI libraries
βββ Real-time collaboration tools
βββ Anything requiring immediate DOM feedback
2. Measure Before and After
// Set up performance monitoring
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.log('Long Task:', entry.duration, 'ms');
// Send to analytics
}
}
});
observer.observe({ entryTypes: ['longtask'] });
// Track Core Web Vitals
import { onFID, onINP, onTBT } from 'web-vitals';
onFID(console.log);
onINP(console.log);
3. Implement Gradually
Phase 1: Single Script
βββ Move Google Analytics to Partytown
βββ Monitor for 1-2 weeks
βββ Check: Events still tracking?
βββ Check: Performance improved?
Phase 2: Expand
βββ Add GTM
βββ Add marketing pixels
βββ Monitor each addition
βββ Document any shims needed
Phase 3: Optimize
βββ Fine-tune forwarding config
βββ Add error monitoring for workers
βββ Create runbook for issues
4. Handle Errors Properly
// Configure error handling
partytown = {
forward: ['dataLayer.push'],
// Catch errors from worker
mainWindowAccessors: ['onerror'],
// Log worker errors
resolveUrl: (url) => {
console.log('Partytown loading:', url);
return url;
}
};
// Add global error handler for worker issues
window.addEventListener('error', (event) => {
if (event.filename?.includes('partytown')) {
// Log to your error tracking service
trackError('Partytown Error', event.message);
}
});
5. Have a Rollback Plan
<!-- Feature flag approach -->
<script>
const usePartytown = window.FEATURE_FLAGS?.partytown ?? true;
if (usePartytown) {
// Load Partytown version
document.write('<script src="/~partytown/partytown.js"><\/script>');
}
</script>
<!-- Scripts that can fallback -->
<script
type="text/partytown"
data-fallback-type="text/javascript"
src="https://analytics.example.com/script.js"
></script>
6. Test Third-Party Script Functionality
// Create a test suite for moved scripts
describe('Analytics in Partytown', () => {
it('should track page views', async () => {
// Trigger page view
gtag('event', 'page_view');
// Verify it was received (check your analytics dashboard or mock)
await waitFor(() => {
expect(analyticsReceived('page_view')).toBe(true);
});
});
it('should track custom events', async () => {
gtag('event', 'button_click', { button_id: 'cta' });
await waitFor(() => {
expect(analyticsReceived('button_click')).toBe(true);
});
});
});
Alternative Approaches
While Partytown is popular, other OMT solutions exist:
1. Manual Web Workers
// For computation-heavy tasks
const computeWorker = new Worker('compute.js');
// Offload data processing
computeWorker.postMessage({
action: 'processLargeDataset',
data: massiveArray
});
2. Comlink (by Google)
// Simplifies worker communication
import * as Comlink from 'comlink';
// worker.js
const api = {
async processData(data) {
return heavyComputation(data);
}
};
Comlink.expose(api);
// main.js
const worker = new Worker('worker.js');
const api = Comlink.wrap(worker);
const result = await api.processData(largeData);
3. Workerize
// Automatically moves functions to workers
import workerize from 'workerize';
const worker = workerize(`
export function expensiveCalculation(n) {
// Heavy computation
return result;
}
`);
const result = await worker.expensiveCalculation(1000000);
Further Reading
Official Documentation
Articles & Deep Dives
- The Main Thread is Overworked & Underpaid (web.dev)
- Workers Overview (web.dev)
- Partytown: Run Third-Party Scripts From A Web Worker (Smashing Magazine)
- Introducing Partytown (Builder.io Blog)
Video Resources
Tools & Libraries
Performance Monitoring
Conclusion
Off-Main-Thread Architecture represents a fundamental shift in how we think about web performance. Instead of fighting for main thread time, we acknowledge it as a scarce resource and protect it for what matters most: rendering and responding to users.
Key Takeaways:
- The main thread is precious - Reserve it for UI work
- Web Workers are mature - Excellent browser support
- Partytown makes OMT practical - No need to rewrite scripts
- Measure everything - Use Core Web Vitals as your guide
- Start small - Move one script, validate, expand
- Have a rollback plan - Things can go wrong
The web is getting heavier, but our users' patience isn't increasing. Off-main-thread architecture gives us a way to have our analytics cake and eat our performance too.
Top comments (0)