אז עברתם על המדריך שלנו על Long Animation Frames API, גיליתם שמשימות JavaScript ארוכות הן בדיוק "האשם" העיקרי במדד ה-INP (Interaction to Next Paint) שלכם, ועכשיו אתם עומדים בצומת המוכרת של ביצועי ווב: צריך להוריד עבודה מה-Main Thread. ב-2026 התשובה הסטנדרטית והבוגרת היא Web Workers — ובמיוחד שילוב שלהם עם הספרייה Comlink, שגומרת אחת ולתמיד עם כאב הראש של postMessage ידני.
במדריך הזה נסקור מתי באמת שווה להעביר עבודה ל-Worker, איך מקימים אחד מודרני עם Vite/Webpack/esbuild, איך משתמשים ב-Comlink כדי להפוך פונקציות מרוחקות לקריאות async רגילות, ואיך מודדים את ההשפעה האמיתית על INP ו-TBT. בלי שיווק, רק קוד שעובד.
מתי באמת צריך Web Worker?
בואו נוריד את הפיל מהחדר מיד: Web Workers הם לא כדור כסף. הם מוסיפים מורכבות, סדרליזציה (structured clone), ועוד טיפה דיליי בתקשורת. כלל אצבע פשוט שאני משתמש בו: שקלו Worker רק כשיש לכם משימה סינכרונית שלוקחת יותר מ-50ms ב-CPU "מציאותי" (כלומר CPU throttling x4 ב-DevTools), והיא רצה בתגובה לאינטראקציית משתמש.
תרחישים קלאסיים שכדאי להעביר ל-Worker:
- פרסור JSON גדול (מעל ~200KB) שמגיע מ-API
- עיבוד תמונות בצד הלקוח (resize, blur, crop) באמצעות
OffscreenCanvas - חישובים מתמטיים כבדים — רגרסיות, סטטיסטיקה, ניתוח נתונים
- חיפוש fuzzy/full-text מקומי על datasets גדולים
- קריפטוגרפיה מותאמת אישית (hashing, encryption)
- פירוק קבצים: PDF, CSV, XLSX בצד הלקוח
- קימפול/transpile — למשל הרצת Babel/PostCSS בדפדפן עבור פלייגראונד
מצד שני: אל תעבירו ל-Worker פעולות שדורשות קריאות תכופות ל-DOM, פעולות שלוקחות פחות מ-10ms, או קריאות API פשוטות (כי הן כבר async, וזה מספיק).
הבעיה: postMessage גולמי הוא פשוט כאב
הדרך ה"רגילה" לדבר עם Worker היא postMessage + onmessage + ניהול ידני של request IDs. ככה זה נראה בפועל:
// main.js — הדרך הישנה
const worker = new Worker('./heavy.worker.js', { type: 'module' });
let nextId = 0;
const pending = new Map();
worker.onmessage = (e) => {
const { id, result, error } = e.data;
const { resolve, reject } = pending.get(id);
pending.delete(id);
error ? reject(error) : resolve(result);
};
function callWorker(method, args) {
return new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
worker.postMessage({ id, method, args });
});
}
// heavy.worker.js
self.onmessage = async (e) => {
const { id, method, args } = e.data;
try {
const result = await methods[method](...args);
self.postMessage({ id, result });
} catch (error) {
self.postMessage({ id, error: String(error) });
}
};
זה עובד, כן — אבל מסורבל, פתוח לבאגים, ולא ידידותי במיוחד ל-TypeScript. בכנות, ב-2026 כבר אף אחד לא כותב את זה ידנית. נכנס Comlink.
Comlink: RPC שקוף ל-Web Workers
Comlink היא ספרייה זעירה (~1.1KB minified) מבית Google Chrome Labs שעוטפת את postMessage ב-Proxy מבוסס MessageChannel. התוצאה? אתם קוראים לפונקציות ב-Worker בדיוק כאילו היו פונקציות async רגילות. וגם types של TypeScript עוברים נכון, בלי לעקם את האפים.
npm install comlink
1. צד ה-Worker
// math.worker.ts
import * as Comlink from 'comlink';
const api = {
async heavyCompute(data: number[]): Promise<number> {
// משימה כבדה מדומה
let sum = 0;
for (let i = 0; i < data.length; i++) {
sum += Math.sqrt(data[i]) * Math.log(data[i] + 1);
}
return sum;
},
async parseLargeJson(text: string) {
const obj = JSON.parse(text);
// עיבוד נוסף, נרמול שדות וכו'
return obj;
},
};
export type MathApi = typeof api;
Comlink.expose(api);
2. צד ה-Main Thread
// main.ts
import * as Comlink from 'comlink';
import type { MathApi } from './math.worker';
const worker = new Worker(
new URL('./math.worker.ts', import.meta.url),
{ type: 'module' }
);
const math = Comlink.wrap<MathApi>(worker);
// שימוש — נראה כמו קריאת פונקציה רגילה
button.addEventListener('click', async () => {
const data = Array.from({ length: 5_000_000 }, () => Math.random());
const result = await math.heavyCompute(data);
console.log('sum =', result);
});
שימו לב לקסם: math.heavyCompute(data) מחזיר Promise, ה-types נשמרים מ-MathApi, ואתם לא רואים שום postMessage בקוד שלכם. הראשונה שראיתי את זה עובד, חשבתי שזה איזה טריק. זה לא — זה פשוט Proxy טוב.
הגדרה עם Vite, Webpack ו-esbuild
הקסם של בנדלרים מודרניים: new Worker(new URL('./x.worker.ts', import.meta.url), { type: 'module' }) הוא התחביר הסטנדרטי שכל הבנדלרים הגדולים מזהים. הם מפצלים אוטומטית את הקוד לקובץ נפרד עם hash בשם, ולכם לא צריך לעשות כלום מיוחד.
Vite
עובד מהקופסה. ה-pattern של new URL(...) מזוהה אוטומטית. עבור Workers שמיובאים במקומות רבים, אפשר להשתמש בתחביר המקוצר: import MyWorker from './my.worker.ts?worker'.
Webpack 5
גם כאן — עובד מהקופסה עם תחביר new URL. אין יותר צורך ב-worker-loader הישן (תודה לאל).
esbuild / Bun
תומכים באותו pattern, רק תוודאו ש-format: 'esm' ושה-target תומך ב-import.meta.url.
העברת Transferables: לא להעתיק — להעביר
כברירת מחדל, כל מה שעובר ל-Worker עובר structured clone, כלומר עותק עמוק. עבור ArrayBuffer בגודל 50MB, זו אופרציה שיכולה לקחת מעל 100ms ולשבור INP — בדיוק את הבעיה שבאנו לפתור! (אירוני, נכון?)
הפתרון נקרא Transferables. במקום להעתיק, אנחנו "מעבירים בעלות" על buffer ל-Worker (ה-buffer במקור הופך ל-neutered, באורך 0). זו פעולה ב-O(1).
import * as Comlink from 'comlink';
const buffer = new ArrayBuffer(50_000_000);
// העברת בעלות במקום העתקה
const result = await math.processBuffer(
Comlink.transfer(buffer, [buffer])
);
// בצד ה-Worker
const api = {
async processBuffer(buf: ArrayBuffer) {
const view = new Uint8Array(buf);
// ...עבודה על הbuffer...
return Comlink.transfer(buf, [buf]); // החזרה ב-O(1)
},
};
אובייקטים שניתן להעביר: ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, ReadableStream, WritableStream, TransformStream.
OffscreenCanvas: רינדור מחוץ ל-Main Thread
אחד השימושים החזקים ביותר ב-Workers ב-2026 הוא OffscreenCanvas — קנבס שאפשר לרנדר אליו מתוך Worker. שימושי מאוד לויזואליזציות נתונים, עיבוד תמונות, או משחקי WebGL בלי להחזיק את ה-Main Thread כבן ערובה.
// main.ts
const canvas = document.querySelector('canvas')!;
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker(new URL('./render.worker.ts', import.meta.url), {
type: 'module',
});
worker.postMessage({ canvas: offscreen }, [offscreen]);
// render.worker.ts
self.onmessage = (e) => {
const { canvas } = e.data;
const ctx = canvas.getContext('2d')!;
let t = 0;
function frame() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = `hsl(${t % 360}, 70%, 50%)`;
ctx.beginPath();
ctx.arc(200 + 100 * Math.sin(t/30), 200, 50, 0, Math.PI * 2);
ctx.fill();
t++;
requestAnimationFrame(frame);
}
frame();
};
נקודה חשובה: requestAnimationFrame בתוך Worker מתסנכרן עם ה-vsync של הדפדפן בדיוק כמו ב-Main Thread — אבל הוא לא נחסם על-ידי עבודה אחרת. וזה בדיוק הקסם.
מדידה: לפני ואחרי
איך באמת מוודאים שההעברה ל-Worker עזרה? יש שלוש מדידות חיוניות:
1. INP בפרודקשן באמצעות web-vitals
import { onINP } from 'web-vitals/attribution';
onINP((metric) => {
console.log('INP:', metric.value, 'ms');
console.log('Slow target:', metric.attribution.interactionTarget);
console.log('Input delay:', metric.attribution.inputDelay);
console.log('Processing duration:', metric.attribution.processingDuration);
console.log('Presentation delay:', metric.attribution.presentationDelay);
// שלחו ל-analytics
});
אחרי המעבר ל-Worker, processingDuration אמור לרדת דרמטית עבור האינטראקציות הרלוונטיות. זה המספר שאתם רוצים לראות יורד.
2. PerformanceObserver עבור Long Tasks
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn('Long task:', entry.duration, 'ms');
}
}
}).observe({ type: 'longtask', buffered: true });
3. Chrome DevTools Performance Panel
פתחו את ה-Performance Panel, הקליטו אינטראקציה לפני ואחרי. בטאב Threads תראו את ה-Worker כ-thread נפרד. ה-Main Thread אמור להיות שקט הרבה יותר בזמן הקלקה (זה הסיפוק האמיתי).
שגיאות נפוצות שכדאי להימנע מהן
1. יצירת Worker חדש בכל אינטראקציה
יצירת Worker עולה ~10-50ms (טעינת קוד, אתחול V8). צרו אותו פעם אחת בטעינת האפליקציה — או בעצלתיים (lazy) בפעם הראשונה שצריך אותו. שמרו singleton ותחסכו לעצמכם כאב ראש.
2. שכחה לקרוא ל-terminate()
אם Worker לא נחוץ יותר (למשל בעמוד שעוזבים אותו ב-SPA), קראו ל-worker.terminate() כדי לשחרר זיכרון. אחרת תסתבכו עם memory leaks שקשה לאתר.
3. העברת אובייקטים שלא ניתנים ל-clone
פונקציות, DOM nodes, ו-class instances עם methods — כל אלה לא עוברים structured clone. או שתסדרו את הנתונים, או שתשתמשו ב-Comlink.proxy עבור callbacks:
// callback עובר כ-proxy, לא כעותק
await math.heavyTask(input, Comlink.proxy((progress) => {
console.log('progress:', progress);
}));
4. שימוש ב-SharedArrayBuffer בלי headers מתאימים
אם אתם רוצים לשתף זיכרון אמיתי בין threads (במקום להעביר בעלות), תזדקקו ל-SharedArrayBuffer. וזה דורש headers של cross-origin isolation:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
בלי זה, SharedArrayBuffer זמין רק בסביבות מאובטחות מסוימות — משהו שכמעט תמיד שוכחים ואז מבזבזים שעה לדבג.
תבנית מתקדמת: Worker Pool
עבור עבודה parallelizable (נניח, עיבוד 100 תמונות), Worker יחיד הוא צוואר בקבוק. תקימו pool בגודל navigator.hardwareConcurrency ותראו פלא:
import * as Comlink from 'comlink';
import type { MathApi } from './math.worker';
class WorkerPool {
private workers: { wrap: Comlink.Remote<MathApi>; busy: boolean }[] = [];
private queue: Array<(w: Comlink.Remote<MathApi>) => void> = [];
constructor(size = navigator.hardwareConcurrency || 4) {
for (let i = 0; i < size; i++) {
const w = new Worker(new URL('./math.worker.ts', import.meta.url), {
type: 'module',
});
this.workers.push({ wrap: Comlink.wrap<MathApi>(w), busy: false });
}
}
acquire(): Promise<Comlink.Remote<MathApi>> {
const free = this.workers.find((w) => !w.busy);
if (free) {
free.busy = true;
return Promise.resolve(free.wrap);
}
return new Promise((resolve) => this.queue.push(resolve));
}
release(wrap: Comlink.Remote<MathApi>) {
const entry = this.workers.find((w) => w.wrap === wrap)!;
const next = this.queue.shift();
if (next) next(wrap);
else entry.busy = false;
}
}
const pool = new WorkerPool();
async function processInParallel(items: number[][]) {
return Promise.all(items.map(async (batch) => {
const w = await pool.acquire();
try {
return await w.heavyCompute(batch);
} finally {
pool.release(w);
}
}));
}
חלופות ל-Workers: scheduler.yield()
ב-2026, ל-Chrome ולשאר הדפדפנים מבוססי-Chromium יש את scheduler.yield() ו-scheduler.postTask(). עבור משימות שאי-אפשר להעביר ל-Worker (כי הן צריכות גישה ל-DOM), אפשר לפצל אותן באמצעות yields:
async function processItems(items) {
for (const item of items) {
processItem(item); // עבודה קצרה
if (navigator.scheduling?.isInputPending() || performance.now() % 50 > 45) {
await scheduler.yield(); // החזרת שליטה לדפדפן
}
}
}
זה לא תחליף ל-Worker, אבל משלים אותו טוב מאוד לתרחישים שמשלבים DOM ועבודה כבדה.
שאלות נפוצות (FAQ)
האם Web Workers משפרים את ה-INP אוטומטית?
לא אוטומטית — רק אם המשימה שגרמה ל-INP גבוה הייתה JavaScript סינכרוני שמעכב את ה-Main Thread. אם ה-INP גבוה נגרם מ-style/layout כבדים, מ-DOM updates ענקיים או מ-input delay (שאלה אחרת לגמרי ב-rendering pipeline), Worker לא יעזור. השתמשו ב-web-vitals/attribution כדי לזהות את הסיבה האמיתית לפני שמשקיעים בריפקטור.
מה ההבדל בין Web Worker, Service Worker ו-Worklet?
Web Worker — thread כללי לעבודת JavaScript. Service Worker — proxy רשת לאינטרספציה של fetch, caching ו-offline. Worklets (Audio, Paint, Animation) — threads קצרים וייעודיים שרצים בתוך pipelines מסוימים של הדפדפן. כל אחד נועד למטרה אחרת לגמרי.
האם אפשר לגשת ל-DOM מ-Web Worker?
לא ישירות. אין document או window בתוך Worker. ניתן לבצע fetch, להשתמש ב-IndexedDB, ב-OffscreenCanvas, ב-WebSocket וב-crypto. אם צריך DOM — פשוט החזירו נתונים ל-Main Thread ועדכנו שם.
מה גודל ה-overhead של Comlink?
הספרייה עצמה ~1.1KB minified+gzipped. ה-overhead של קריאה הוא ~0.05–0.2ms במחשב מודרני (הזמן של structured clone + MessageChannel). עבור משימות שאורכות שניות זה זניח לחלוטין; עבור 1000 קריאות בשנייה — כבר משמעותי, אז באצרו או החזירו batches.
האם React/Vue/Svelte תומכים ב-Workers טוב?
כן — Workers הם פיצ'ר של הדפדפן ובלתי-תלויים ב-framework. עם זאת, ספריות עזר כמו react-use, workerize-loader או vite-plugin-comlink מקלות לכתוב את הקוד יותר נקי. כלל אצבע שלי: שמרו state ב-Worker ככל האפשר, והעבירו ל-UI רק את הפלט הסופי לרינדור.
סיכום
Web Workers בשילוב עם Comlink הם אחת מהדרכים הכי אפקטיביות לשמור על Main Thread רגיש ב-2026. הם פותרים בעיות אמיתיות של INP, מאפשרים שימוש בכוח עיבוד מקבילי, ועם הכלים המודרניים — Vite, TypeScript, Comlink — כתיבת הקוד הופכת לטבעית כמו async/await רגיל.
הצעד הבא? זהו אינטראקציה אחת באפליקציה שלכם עם INP מעל 200ms, הריצו פרופיל ב-DevTools, ובדקו אם המשימה הסינכרונית שמופיעה שם היא מועמדת ל-Worker. אני יכול להבטיח לכם: במקרים רבים, ההפרש בין UI חלק לקוטע הוא 50 שורות קוד והעברה ל-thread נפרד. שווה את ההשקעה.