WithCodeMedia-1-pc
previous arrowprevious arrow
next arrownext arrow

WithCodeMedia-1-sp
previous arrowprevious arrow
next arrownext arrow

Web Workersでメインスレッドをブロックしないタスクオフロード【完全ガイド】|postMessage・Comlink・INP改善を徹底解説

この記事でわかること

  • メインスレッドがブロックされる原因と Core Web Vitals(INP・LCP・TBT)への影響
  • postMessage による Worker 基本通信と CSV パースのオフロード実装
  • TransferableObjects(ゼロコピー転送)で大きなデータを高速に渡す方法
  • Comlink ライブラリで Worker を通常の async 関数のように呼び出す方法
  • Worker Pool・React カスタム Hook・OMT アーキテクチャの実践パターン
生徒

重い計算処理をすると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)フロントエンド高速化の実践ガイド(パフォーマンス最適化)


目次

メインスレッドの問題と Web Workers の役割

ブラウザは1つのタブに対して基本的に1つのメインスレッドを持ちます。このスレッドはJavaScript の実行・DOM の更新・レイアウト計算・ペイントをすべて担当します。重い処理が走るとこれらがブロックされ、UI がフリーズします。

人間が UI の「遅さ」を感じる閾値は約 100ms と言われています。一方、JavaScript で1万件のデータをソートすると数十〜数百ms かかります。50MB の CSV ファイルをパースすると1〜3秒ブロックされることもあります。Web Workers はこの問題を根本から解決する「マルチスレッド」の仕組みです。

メインスレッドとWeb Workerスレッドの分離を示す構成図
>【メインスレッドがブロックされる典型的なケース】

❌ 大量データの処理(CSVパース・JSONの解析)
❌ 複雑な数値計算(物理シミュレーション・機械学習の推論)
❌ 画像処理(フィルター適用・リサイズ・カラー変換)
❌ 暗号化・ハッシュ計算
❌ 大きなデータの並び替え・検索
❌ Zip/Gzipの圧縮・解凍処理
❌ テキストの全文検索インデックス構築

→ これらをメインスレッドで実行すると:
  - スクロールが止まる
  - ボタンが反応しない
  - アニメーションがカクつく
  - Core Web Vitals(INP・LCP)が悪化する

Web Workers と Core Web Vitals の関係

指標説明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 にも直結します。


Web Workers の基本的な使い方|postMessage による通信

基本:Worker の作成とメッセージ通信

>// 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 データのパース処理をオフロード

>// 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 })  // 一度だけ受け取ったらリスナーを削除
}

TransferableObjects|ゼロコピーで大きなデータを転送する

デフォルトの 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

実践:ImageData を Transferable で高速転送

>// 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]  // バッファの所有権を移転
    )
  })
}

Comlink:postMessage をもっと簡単にする

生の 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
}

Worker Pool|複数 Worker で並列処理する

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()

React / Next.js での実装パターン

カスタム Hook で Worker を管理する

>// 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 (