



WithCodeMedia-1-pc
WithCodeMedia-2-pc
WithCodeMedia-3-pc
WithCodeMedia-4-pc




WithCodeMedia-1-sp
WithCodeMedia-2-sp
WithCodeMedia-3-sp
WithCodeMedia-4-sp








生徒重い計算処理をするとUIがフリーズしてしまいます。スクロールも止まって最悪なんですが……



それはメインスレッドがブロックされているんじゃ!Web Workersを使えば重い処理を別スレッドに移してUIをフリーズさせなくできるぞ。Core Web VitalsのINPやLCPの改善にも直結するから、パフォーマンスを本気で上げたいなら必須の技術じゃ!
結論から言うと、Web Workers はメインスレッドをブロックする重い処理を別スレッドに移すことで UI のフリーズを防ぎ、Core Web Vitals(INP・LCP・TBT)を直接改善します。postMessage → TransferableObjects → Comlink の順に導入難易度が上がりますが、どれも段階的に導入できます。
本記事では、Web Workers の仕組み・postMessage による通信・TransferableObjects によるゼロコピー転送・Comlink ライブラリの活用・Worker Pool・INP/LCP への影響・OMT アーキテクチャ・React/TypeScript での実践パターンを完全解説します。
あわせて読みたい:
WebAssembly(Wasm)フロントエンド高速化の実践ガイド(パフォーマンス最適化)
ブラウザは1つのタブに対して基本的に1つのメインスレッドを持ちます。このスレッドはJavaScript の実行・DOM の更新・レイアウト計算・ペイントをすべて担当します。重い処理が走るとこれらがブロックされ、UI がフリーズします。
人間が UI の「遅さ」を感じる閾値は約 100ms と言われています。一方、JavaScript で1万件のデータをソートすると数十〜数百ms かかります。50MB の CSV ファイルをパースすると1〜3秒ブロックされることもあります。Web Workers はこの問題を根本から解決する「マルチスレッド」の仕組みです。


>【メインスレッドがブロックされる典型的なケース】
❌ 大量データの処理(CSVパース・JSONの解析)
❌ 複雑な数値計算(物理シミュレーション・機械学習の推論)
❌ 画像処理(フィルター適用・リサイズ・カラー変換)
❌ 暗号化・ハッシュ計算
❌ 大きなデータの並び替え・検索
❌ Zip/Gzipの圧縮・解凍処理
❌ テキストの全文検索インデックス構築
→ これらをメインスレッドで実行すると:
- スクロールが止まる
- ボタンが反応しない
- アニメーションがカクつく
- Core Web Vitals(INP・LCP)が悪化する
| 指標 | 説明 | Web Workers 導入前 | Web Workers 導入後 |
|---|---|---|---|
| INP(Interaction to Next Paint) | 操作後の次のペイントまでの時間 | 重い処理中はユーザー操作への応答が遅延(200ms 超) | メインスレッドが空くため即座に応答(50ms 以下) |
| LCP(Largest Contentful Paint) | 最大コンテンツ描画時間 | 起動時の重い処理が LCP をブロック | 起動時の重い処理を Worker に移して LCP 改善 |
| TBT(Total Blocking Time) | メインスレッドのブロック合計時間 | 長い処理タスクが積み重なる | Worker へのオフロードでタスク時間を 50ms 以下に |
Google のランキングアルゴリズムには Core Web Vitals が直接影響します。特に INP(Interaction to Next Paint)は 2024年3月に FID に替わる新しい指標として正式採用されており、Web アプリの応答性改善は SEO にも直結します。
>// main.js - メインスレッド側
const worker = new Worker('./worker.js')
// Workerにメッセージを送る
worker.postMessage({ type: 'CALCULATE', data: [1, 2, 3, 4, 5] })
// Workerからの結果を受け取る
worker.addEventListener('message', (event) => {
console.log('計算結果:', event.data) // → { result: 15 }
})
// エラー処理
worker.addEventListener('error', (error) => {
console.error('Worker エラー:', error.message)
})
// Workerを終了する(不要になったら必ず破棄する)
// worker.terminate()
>// worker.js - Workerスレッド側
// ⚠️ WorkerではDOMにアクセスできない(document/windowは使えない)
// ✅ fetch・IndexedDB・WebSocket・setTimeout等は使える
addEventListener('message', (event) => {
const { type, data } = event.data
switch (type) {
case 'CALCULATE': {
// 重い計算処理(メインスレッドをブロックしない)
const result = data.reduce((sum, n) => sum + n, 0)
// 結果をメインスレッドに返す
postMessage({ type: 'RESULT', result })
break
}
case 'SORT': {
const sorted = [...data].sort((a, b) => a - b)
postMessage({ type: 'SORTED', result: sorted })
break
}
}
})
>// csv-worker.js
addEventListener('message', (event) => {
const { csvText } = event.data
// 大量データのパース(メインスレッドをブロックしない)
const lines = csvText.split('\n')
const headers = lines[0].split(',')
const records = lines.slice(1)
.filter(line => line.trim()) // 空行を除外
.map(line => {
const values = line.split(',')
return headers.reduce((obj, header, i) => {
obj[header.trim()] = values[i]?.trim() ?? ''
return obj
}, {})
})
// 処理完了を通知
postMessage({ records, count: records.length })
})
>// main.js - CSVファイルをWorkerで処理する
const csvWorker = new Worker('./csv-worker.js')
async function handleFileUpload(file) {
const text = await file.text()
// UIは継続して応答する(スピナーを表示しながら待てる)
showLoadingSpinner()
csvWorker.postMessage({ csvText: text })
csvWorker.addEventListener('message', (event) => {
hideLoadingSpinner()
const { records, count } = event.data
console.log(`${count}件のレコードをパースしました`)
renderTable(records)
}, { once: true }) // 一度だけ受け取ったらリスナーを削除
}
デフォルトの postMessage ではデータはコピーされて渡されます。100MB の画像データをコピー転送すると、メモリを 200MB 消費した上に転送時間がかかります。TransferableObjects を使うと所有権の移転(ゼロコピー)で高速かつメモリ効率よくデータを渡せます。
| 転送方式 | 仕組み | 速度 | 元のオブジェクト |
|---|---|---|---|
| コピー転送(デフォルト) | データを複製してから渡す | データサイズに比例して遅くなる | 元のオブジェクトはそのまま使える |
| Transferable(所有権移転) | メモリの所有権ごと移転する | データサイズに関わらず高速 | 移転後は元のオブジェクトは使えなくなる |
| SharedArrayBuffer(共有) | メモリを共有する | 最速(コピーなし・移転なし) | 両方から参照・変更可能 |
>// transferable_example.js - TransferableObjectsの使い方
// ❌ 遅い:100MBのArrayBufferをコピーして転送(合計200MB使用)
const bigBuffer = new ArrayBuffer(100 * 1024 * 1024) // 100MB
worker.postMessage({ buffer: bigBuffer }) // コピーされる
// ✅ 速い:ArrayBufferの所有権を移転(コピーなし、メモリ効率◎)
const bigBuffer2 = new ArrayBuffer(100 * 1024 * 1024) // 100MB
worker.postMessage(
{ buffer: bigBuffer2 },
[bigBuffer2] // 第2引数にTransferリストを指定
)
// ⚠️ 移転後は bigBuffer2 は使えなくなる(detached状態)
console.log(bigBuffer2.byteLength) // → 0(空になっている)
// Transferableとして使えるオブジェクト:
// - ArrayBuffer
// - MessagePort
// - ReadableStream / WritableStream / TransformStream
// - ImageBitmap
// - OffscreenCanvas
>// image-worker.js - 画像処理Workerでの双方向Transferable活用
addEventListener('message', (event) => {
const { buffer, width, height, filter } = event.data
const data = new Uint8ClampedArray(buffer)
// フィルター処理
if (filter === 'grayscale') {
for (let i = 0; i < data.length; i += 4) {
const avg = (data[i] + data[i+1] + data[i+2]) / 3
data[i] = data[i+1] = data[i+2] = avg
}
}
// 処理済みバッファを所有権移転で返す(コピーなし)
postMessage(
{ buffer, width, height },
[buffer] // 所有権を移転して返す
)
})
>// main.js - Canvas画像をWorkerで処理する高速版
const imageWorker = new Worker('./image-worker.js', { type: 'module' })
async function applyFilterFast(canvas, filter) {
const ctx = canvas.getContext('2d')
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
// ImageData.data(Uint8ClampedArray)のbufferをTransferableで送る
const buffer = imageData.data.buffer
return new Promise((resolve) => {
imageWorker.addEventListener('message', (e) => {
const resultData = new Uint8ClampedArray(e.data.buffer)
const resultImageData = new ImageData(resultData, canvas.width, canvas.height)
ctx.putImageData(resultImageData, 0, 0)
resolve()
}, { once: true })
imageWorker.postMessage(
{ buffer, width: canvas.width, height: canvas.height, filter },
[buffer] // バッファの所有権を移転
)
})
}
生の postMessage API は、複数の処理タイプを扱うとコードが複雑になります。Comlink ライブラリを使うと、Worker の関数を通常の非同期関数のように呼び出せます。
>npm install comlink
>// heavy-worker.ts - Comlink で Worker の関数を公開する
import { expose } from 'comlink'
const api = {
// 重い画像フィルター処理
async applyGrayscale(imageData: ImageData): Promise {
const data = imageData.data
for (let i = 0; i < data.length; i += 4) {
const avg = (data[i] + data[i+1] + data[i+2]) / 3
data[i] = data[i+1] = data[i+2] = avg
}
return imageData
},
// 大規模なソート処理
async sortLargeArray(arr: number[]): Promise {
return arr.sort((a, b) => a - b)
},
// ハッシュ計算(Web Crypto API はWorkerでも使える)
async computeHash(text: string): Promise {
const encoder = new TextEncoder()
const data = encoder.encode(text)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
},
// プログレス付き重い処理(コールバックを渡す例)
async processWithProgress(
data: number[],
onProgress: (percent: number) => void
): Promise {
const result = []
for (let i = 0; i < data.length; i++) {
result.push(data[i] * 2) // 重い処理のシミュレーション
if (i % 1000 === 0) {
onProgress(Math.floor((i / data.length) * 100))
}
}
return result
}
}
expose(api)
>// main.ts - Comlink で Worker を通常の関数のように呼び出す
import { wrap, proxy } from 'comlink'
import type { Remote } from 'comlink'
// Worker の型
type WorkerAPI = typeof import('./heavy-worker').api
const worker = new Worker(new URL('./heavy-worker.ts', import.meta.url), {
type: 'module'
})
const api: Remote = wrap(worker)
// async/await で Worker の関数を直接呼び出せる
async function processImage(canvas: HTMLCanvasElement) {
const ctx = canvas.getContext('2d')!
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
// Worker に処理をオフロード(UI はブロックされない)
const processed = await api.applyGrayscale(imageData)
ctx.putImageData(processed, 0, 0)
}
// コールバック付き処理(Comlink の proxy() を使う)
async function processWithProgress(data: number[]) {
const result = await api.processWithProgress(
data,
proxy((percent: number) => {
console.log(`進捗: ${percent}%`)
updateProgressBar(percent)
})
)
return result
}
CPU のコア数に合わせて複数の Worker を起動し、タスクを並列処理する「Worker Pool」パターンを使うと、大量のタスクをより高速に処理できます。例えば画像変換ツールで100枚の画像を一括処理する場合、4コア CPU に4つの Worker を起動すれば理論上4倍速くなります。
>// worker-pool.ts - シンプルなWorker Pool実装
class WorkerPool {
private workers: Worker[]
private taskQueue: Array<{
data: unknown
resolve: (result: unknown) => void
reject: (error: unknown) => void
}> = []
private idleWorkers: Worker[]
constructor(workerUrl: URL, poolSize: number = navigator.hardwareConcurrency) {
// CPUのコア数に合わせてWorkerを起動(デフォルト)
this.workers = Array.from({ length: poolSize }, () =>
new Worker(workerUrl, { type: 'module' })
)
this.idleWorkers = [...this.workers]
console.log(`Worker Pool: ${poolSize}スレッドで起動しました`)
}
async execute(data: unknown): Promise {
return new Promise((resolve, reject) => {
const task = { data, resolve, reject }
const idleWorker = this.idleWorkers.pop()
if (idleWorker) {
this.runTask(idleWorker, task)
} else {
// 空きWorkerがなければキューに追加
this.taskQueue.push(task)
}
})
}
private runTask(worker: Worker, task: typeof this.taskQueue[0]) {
const handler = (event: MessageEvent) => {
worker.removeEventListener('message', handler)
worker.removeEventListener('error', errorHandler)
task.resolve(event.data)
// 次のタスクを取り出して実行
const nextTask = this.taskQueue.shift()
if (nextTask) {
this.runTask(worker, nextTask)
} else {
this.idleWorkers.push(worker)
}
}
const errorHandler = (error: ErrorEvent) => {
worker.removeEventListener('message', handler)
worker.removeEventListener('error', errorHandler)
task.reject(error)
this.idleWorkers.push(worker)
}
worker.addEventListener('message', handler)
worker.addEventListener('error', errorHandler)
worker.postMessage(task.data)
}
terminate() {
this.workers.forEach(w => w.terminate())
}
}
// 使用例:100枚の画像を並列処理
const pool = new WorkerPool(new URL('./image-worker.ts', import.meta.url))
const images = getImageDataArray() // 100個のImageData
const results = await Promise.all(
images.map(img => pool.execute({ imageData: img, filter: 'grayscale' }))
)
console.log(`${results.length}枚の画像処理完了`)
pool.terminate()
>// hooks/useWorker.ts - Web WorkerをReact Hooksで管理する
import { useEffect, useRef, useState, useCallback } from 'react'
type WorkerState = {
result: T | null
loading: boolean
error: string | null
}
export function useWorker(workerUrl: URL) {
const workerRef = useRef(null)
const [state, setState] = useState>({
result: null,
loading: false,
error: null,
})
useEffect(() => {
workerRef.current = new Worker(workerUrl, { type: 'module' })
workerRef.current.addEventListener('message', (event: MessageEvent) => {
setState({ result: event.data, loading: false, error: null })
})
workerRef.current.addEventListener('error', (error: ErrorEvent) => {
setState(prev => ({ ...prev, loading: false, error: error.message }))
})
return () => {
workerRef.current?.terminate()
}
}, [workerUrl])
const execute = useCallback((data: unknown) => {
if (!workerRef.current) return
setState(prev => ({ ...prev, loading: true, error: null }))
workerRef.current.postMessage(data)
}, [])
return { ...state, execute }
}
>// components/DataProcessor.tsx - カスタムHookを使うコンポーネント
'use client'
import { useWorker } from '@/hooks/useWorker'
import { useState } from 'react'
type SortResult = { sorted: number[]; time: number }
export function DataProcessor() {
const [inputText, setInputText] = useState('')
const { result, loading, error, execute } = useWorker(
new URL('../workers/sort-worker.ts', import.meta.url)
)
const handleProcess = () => {
const numbers = inputText
.split(',')
.map(Number)
.filter(n => !isNaN(n))
execute({ numbers })
}
return (
)
}
>// Vite(Next.js / React)での Worker ファイルのインポート方法
// ✅ Vite 推奨の書き方(import.meta.url を使う)
const worker = new Worker(new URL('./heavy-worker.ts', import.meta.url), {
type: 'module'
})
// ✅ Vite の ?worker サフィックスを使う書き方
import HeavyWorker from './heavy-worker.ts?worker'
const worker = new HeavyWorker()
// ✅ Next.js での書き方(package.json の type: "module" が必要)
// next.config.ts で worker を設定
// module.exports = { experimental: { workerThreads: true } }
Off-Main-Thread(OMT)アーキテクチャとは、UI の状態管理・ビジネスロジックを Worker に移し、メインスレッドはレンダリングのみに専念させる設計思想です。Google が開発したゲーム「PROXX」で効果が実証されました。
>【OMTアーキテクチャの分担】
メインスレッド(UIスレッド):
→ DOMの更新・アニメーション・CSS適用
→ ユーザー操作のイベント受信
→ Workerから受け取った状態変化をレンダリング
→ 担当するJavaScriptは最小限に抑える
Workerスレッド:
→ アプリケーション状態管理(Redux相当)
→ ビジネスロジック・計算
→ データの取得・変換・バリデーション
→ 状態変化をメインスレッドに通知
メリット:
✅ ローエンドデバイス(Android エントリーモデル等)でもUIが滑らかになる
✅ ロジックがDOMから分離されてユニットテストが書きやすくなる
✅ 将来的にマルチコアへのスケールアウトが容易
✅ UIとロジックの関心事を明確に分離できる
注意点:
→ Worker-メインスレッド間の通信オーバーヘッドが発生する
→ DOM操作は依然メインスレッドのみ可能
→ 初期実装コストがやや高い(設計を丁寧に考える必要がある)
>// state-worker.ts - OMTパターンでのステート管理Worker
type State = {
items: Item[]
filter: string
sortBy: string
}
type Action =
| { type: 'SET_FILTER'; filter: string }
| { type: 'SET_SORT'; sortBy: string }
| { type: 'LOAD_ITEMS'; items: Item[] }
let state: State = {
items: [],
filter: '',
sortBy: 'name'
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'SET_FILTER':
return { ...state, filter: action.filter }
case 'SET_SORT':
return { ...state, sortBy: action.sortBy }
case 'LOAD_ITEMS':
return { ...state, items: action.items }
default:
return state
}
}
function getFilteredSortedItems(state: State) {
return state.items
.filter(item =>
item.name.toLowerCase().includes(state.filter.toLowerCase())
)
.sort((a, b) =>
a[state.sortBy as keyof Item] > b[state.sortBy as keyof Item] ? 1 : -1
)
}
addEventListener('message', (event) => {
const action: Action = event.data
// 状態を更新
state = reducer(state, action)
// フィルター・ソート済みのアイテムを計算(重い処理)
const displayItems = getFilteredSortedItems(state)
// 差分のみメインスレッドに送る
postMessage({ type: 'STATE_UPDATE', displayItems })
})
>【Chrome DevTools でのWorkerパフォーマンス計測手順】
1. DevToolsを開く(F12)
2. Performanceタブを選択
3. 「Record」ボタンを押して処理を実行
4. 「Stop」で記録終了
確認ポイント:
- 「Main」スレッドの長いタスク(赤くマークされる50ms超のタスク)
- 「Worker」スレッドの処理時間
- postMessage の通信頻度とサイズ
最適化の指標:
- メインスレッドのタスクを 50ms 以下に抑える
- Workerへの postMessage は頻繁すぎない(バッチ処理を検討)
- 大きなデータはTransferableを使う
>// 頻繁すぎるpostMessageはオーバーヘッドになる
// バッチ処理で通信回数を減らす
// ❌ 悪い例:毎フレームworkerに送信(60fps = 1秒60回)
function onFrameUpdate(data) {
worker.postMessage({ type: 'UPDATE', data }) // 1秒に60回送信
}
// ✅ 良い例:データを溜めてまとめて送信(通信回数を削減)
const pendingData: unknown[] = []
function onFrameUpdate(data) {
pendingData.push(data)
}
// 100ms ごとにバッチ送信
setInterval(() => {
if (pendingData.length === 0) return
worker.postMessage({ type: 'BATCH_UPDATE', items: [...pendingData] })
pendingData.length = 0
}, 100)
Web Worker は「重い処理をメインスレッドからオフロードする」目的で使います。ページのライフサイクルに連動し、ページを閉じると終了します。Service Worker はネットワークリクエストのプロキシ・オフラインキャッシュ・プッシュ通知の受信に使うもので、ページを閉じても登録は維持されます(PWA の核心技術)。名前が似ていますが全く別の技術です。
Worker から localStorage にアクセスできません。Worker から永続的なデータにアクセスするには IndexedDB を使います(Worker から IndexedDB は利用可能)。また SharedArrayBuffer を使ってメインスレッドと Worker で共有メモリを使うことも可能ですが、COOP/COEP ヘッダーの設定が必要です。
Vite では new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }) の形式で Worker ファイルを指定するだけで動作します。TypeScript も自動的にトランスパイルされます。Webpack では worker-loader プラグインを導入するか、Webpack 5 以降では同様の形式がネイティブサポートされています。
Chrome DevTools は Worker のデバッグに対応しています。Sources タブの左サイドバーに「Workers」というセクションがあり、Worker スクリプトにブレークポイントを設置できます。console.log も Worker 内で使え、コンソールには「[Worker]」というプレフィックスが付いて表示されます。
使えます。Worker 内では fetch・setTimeout・setInterval・WebSocket・IndexedDB・crypto などの API を利用できます。DOM 操作(document・window のプロパティへのアクセス)のみ制限されています。
useWorker フックで React コンポーネントから Worker を宣言的に使えるWeb Workers は「パフォーマンスが問題になってから対処する」のではなく、設計段階から取り込むことで恩恵が最大化します。まず重い処理を1つ Worker に移してみることから始めましょう。TransferableObjects と Comlink を組み合わせることで、実装コストを低く保ちながら高いパフォーマンスを実現できます。
公式サイト より
今すぐ
無料カウンセリング
を予約!