A Web Worker runs JavaScript in a background thread separate from the browser’s main thread — any computation inside a Worker cannot block user interactions, animations, or paint cycles, which is the key mechanism for fixing high INP (Interaction to Next Paint) scores caused by long-running JavaScript tasks. The main thread and Workers communicate exclusively via postMessage() / onmessage — data is passed as structured-clone serialized copies (not shared references) by default, or as transferable objects (ArrayBuffer, MessagePort) for zero-copy transfer of large typed arrays. Workers do not have access to the DOM, window, or document — they are limited to the fetch API, IndexedDB, Web Crypto, timers, and other non-UI browser APIs. For WordPress front-end use cases, Workers are appropriate for: client-side search index processing (parsing and ranking a large JSON search index while the user types), image resizing or thumbnail generation before upload using OffscreenCanvas, CSV or JSON data parsing for large admin tables, and cryptographic operations like client-side file hashing before upload to verify integrity. Shared Workers (new SharedWorker('worker.js')) allow a single Worker instance to be shared across multiple browser tabs open to the same origin — useful for a shared WebSocket connection or a cross-tab state manager in a single-page WordPress admin. Service Workers (covered in a previous post) are a specialized Worker type with network interception powers; plain Workers are used for computation. The Intersection Observer post removed scroll-related main-thread work; Workers remove computation-related main-thread work — together they address the two most common INP sources in WordPress JavaScript.
Problem: A WordPress admin page loads a 50,000-row product table — filtering, sorting, and searching are implemented in JavaScript using Array.filter() and Array.sort() running on the main thread. Each keystroke in the search field triggers a 300ms computation block that freezes the UI, causing visible input lag and failing Core Web Vitals INP thresholds.
Solution: Move the filter/sort computation to a Web Worker — the main thread posts the dataset and search term, the Worker processes the data and returns sorted results, and the UI updates asynchronously without any main-thread block.
// ── table-worker.js ───────────────────────────────────────────────────────
// Runs in a Web Worker — no DOM access, safe for heavy computation
self.onmessage = function(event) {
const { type, payload } = event.data;
if (type === 'FILTER_AND_SORT') {
const { rows, searchTerm, sortKey, sortDir } = payload;
// Filter
const term = searchTerm.toLowerCase().trim();
let result = term
? rows.filter(row =>
Object.values(row).some(val =>
String(val).toLowerCase().includes(term)
)
)
: rows;
// Sort
if (sortKey) {
result = [...result].sort((a, b) => {
const av = a[sortKey], bv = b[sortKey];
const cmp = typeof av === 'number'
? av - bv
: String(av).localeCompare(String(bv));
return sortDir === 'desc' ? -cmp : cmp;
});
}
self.postMessage({ type: 'RESULT', payload: { rows: result, total: result.length } });
}
};
// ── main.js — main thread ─────────────────────────────────────────────────
const worker = new Worker(
// WordPress: enqueue this file and use wp_localize_script to pass the URL
myPlugin.workerUrl,
{ type: 'module' }
);
let allRows = [];
let debounceTimer;
// Load data on page init
fetch(myPlugin.apiUrl + '/products?per_page=500')
.then(r => r.json())
.then(data => {
allRows = data;
requestFilter();
});
// Worker result → update table DOM (main thread only)
worker.onmessage = function(event) {
if (event.data.type !== 'RESULT') return;
const { rows, total } = event.data.payload;
renderTable(rows);
document.getElementById('result-count').textContent = total + ' products';
};
// Debounce user input → send work to Worker
document.getElementById('search-input').addEventListener('input', function() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(requestFilter, 150);
});
function requestFilter() {
worker.postMessage({
type: 'FILTER_AND_SORT',
payload: {
rows: allRows,
searchTerm: document.getElementById('search-input').value,
sortKey: currentSortKey,
sortDir: currentSortDir,
},
});
}
function renderTable(rows) {
const tbody = document.querySelector('#product-table tbody');
// Use DocumentFragment for batch DOM update (single reflow)
const frag = document.createDocumentFragment();
rows.forEach(row => {
const tr = document.createElement('tr');
tr.innerHTML = `${escapeHtml(row.sku)} ${escapeHtml(row.name)} ${escapeHtml(String(row.price))} `;
frag.appendChild(tr);
});
tbody.replaceChildren(frag);
}
function escapeHtml(str) {
return String(str)
.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
}
NOTE: Data transferred between the main thread and a Worker is deep-cloned by the structured clone algorithm — sending a 50,000-row array triggers a full copy on every message. For very large datasets, use Transferable objects: encode the data as an ArrayBuffer (e.g., via TextEncoder) and pass it with the transfer list in postMessage(data, [data.buffer]) — ownership transfers to the Worker at zero copy cost. Alternatively, keep the full dataset in the Worker and only send filter parameters, returning only the visible page of results rather than the complete filtered array.