This document details the communication protocol design between main thread and Worker, message types, payload structures, and Transferable object transfer mechanisms.
The communication protocol is the bridge connecting the main thread and Worker. It defines a type-safe message format ensuring accurate and efficient data exchange between threads.
| File | Responsibility |
|---|---|
src/core/worker/protocol.ts |
Protocol definitions (Message, MessageType, Payload) |
src/core/worker/constant.ts |
Worker constants |
interface Message<T = MessagePayload> {
id: string // Unique identifier generated by nanoid
from?: string // Source message ID (for request-response pairing)
type: MessageType // Message type enum
payload: T // Generic payload
timestamp: number // Send timestamp
}
| Field | Purpose |
|---|---|
id |
Uniquely identifies each message for debugging and tracing |
from |
Marks which request a response corresponds to (mainly for error tracking) |
type |
Determines how the message is processed (switch-case dispatch) |
payload |
Carries specific data, type determined by type |
timestamp |
Records send time, useful for performance analysis |
Both main thread and Worker have their own #sendMessage method — same core logic but different calling interfaces:
// ─── Main thread side (src/core/masonry.ts) ───
#sendMessage(type: MessageType, payload: MessagePayload, transfer?: Transferable[]) {
const message: Message<MessagePayload> = {
id: nanoid(), // Independent ID for each message
type,
payload,
timestamp: Date.now(), // Record send time
}
// postMessage's second parameter is the Transferable list
// Defaults to empty array when no transfer needed (regular structured clone)
this.#worker?.postMessage(message, transfer ?? [])
}
// ─── Worker side (src/core/worker/offscreen-canvas.ts) ───
#sendMessage(type: MessageType, payload: RequestPayload | ResponsePayload, from?: string): void {
const message: Message<RequestPayload | ResponsePayload> = {
id: nanoid(),
from, // Marks which request this message responds to
type,
payload,
timestamp: Date.now(),
}
// Worker uses globalThis.postMessage to send to main thread
// Note: Worker side has no transfer parameter because Worker→Main direction
// currently has no objects needing ownership transfer (ClickResult/LayoutUpdated are pure data)
globalThis.postMessage(message)
}
Design Notes:
transfer parameter: Because Main→Worker direction needs to transfer OffscreenCanvas and ImageBitmap (zero-copy ownership transfer).from parameter: Used for error tracing. When Worker encounters an error processing a request, the from field records which request caused it, helping the main thread pinpoint issues.nanoid() for ID generation: Lightweight unique ID generator (21 chars, URL-safe) with better browser compatibility than crypto.randomUUID().enum MessageType {
Setup, // Initialize setup
SetupResponse, // Setup complete response
LoadMore, // Request to load more data
LoadMoreResponse, // Load more data response
Render, // Trigger render
RenderLoading, // Request placeholder rendering
RenderLoadingResponse,// Placeholder render complete
Resize, // Container resize
RemoveLoading, // Remove loading placeholder
Error, // Error message
Scroll, // Scroll offset update
LayoutUpdated, // Layout update notification
ImageLoaded, // Image loaded
Click, // Click event
ClickResult, // Click result response
}
Main → Worker (8 types):
Setup, Render, Resize, Scroll, ImageLoaded, Click,
RenderLoadingResponse, LoadMoreResponse
Worker → Main (7 types):
SetupResponse, LoadMore, RenderLoading, RemoveLoading,
LayoutUpdated, ClickResult, Error
| Request | Response | Direction |
|---|---|---|
| Setup | SetupResponse | Main→Worker→Main |
| Click | ClickResult | Main→Worker→Main |
| RenderLoading | RenderLoadingResponse | Worker→Main→Worker |
| LoadMore | LoadMoreResponse | Worker→Main→Worker |
When the Worker receives a message, it dispatches it to the appropriate handler via #handleMessage's switch-case:
// src/core/worker/offscreen-canvas.ts
#setupMessageHandler(): void {
globalThis.onmessage = (event: MessageEvent<Message>) => {
try {
this.#handleMessage(event.data)
} catch (error) {
// Top-level error boundary: any uncaught exception won't crash the Worker
// Instead it notifies the main thread via an Error message
this.#sendError(error)
}
}
}
#handleMessage(message: Message) {
const { type, payload } = message
switch (type) {
case MessageType.Setup:
this.#handleSetup(payload as SetupPayload)
break
case MessageType.Render:
// Render triggers three actions: start animation loop + first frame render + check if load more needed
this.#startAnimationLoop()
this.#handleRerender()
this.#checkLoadMore()
break
case MessageType.Resize:
this.#handleResize(payload as ResizePayload)
break
case MessageType.Scroll:
this.#handleScroll(payload as ScrollPayload)
break
case MessageType.RenderLoadingResponse:
this.#handleRenderLoading(payload as RenderLoadingResponsePayload)
break
case MessageType.LoadMoreResponse:
this.#handleLoadMoreResponse(payload as LoadMoreResponsePayload)
break
case MessageType.ImageLoaded:
this.#handleImageLoaded(payload as ImageLoadedPayload)
break
case MessageType.Click:
this.#handleClick(payload as ClickPayload)
break
default:
// Exhaustiveness guard: immediately errors on unknown message types
// Ensures protocol changes don't silently ignore new message types
throw new MasonryError(`unknown message type: ${type}`)
}
}
Design Notes:
onmessage, making all subsequent messages unprocessable.default throws error: During protocol evolution, if the main thread sends a new message type the Worker doesn't support yet, throwing immediately makes version mismatch issues easier to discover than silently ignoring.Render triggers three actions: This is the "start" signal after initialization completes — it atomically starts the render loop, draws the first frame, and performs the first loadMore check.// Main → Worker request payloads
type RequestPayload =
| SetupPayload | ResizePayload | ScrollPayload
| ImageLoadedPayload | ClickPayload | Array<string> | string
// Worker → Main response payloads
type ResponsePayload =
| RenderLoadingResponsePayload | LayoutUpdatedPayload
| ClickResultPayload | LoadMoreResponsePayload | Error | null
interface SetupPayload {
offscreenCanvas: OffscreenCanvas // Offscreen canvas [Transferable]
clientWidth: number // Container CSS width
clientHeight: number // Container CSS height
config: WorkerConfiguration // Worker configuration
dpr: number // Device pixel ratio
}
interface ScrollPayload {
deltaX: number // Horizontal scroll delta (px)
deltaY: number // Vertical scroll delta (px)
}
interface ImageLoadedPayload {
index: number // Image index in data source
bitmap: ImageBitmap // Loaded bitmap [Transferable]
width: number // Original width
height: number // Original height
}
interface LoadMoreResponsePayload {
page: number // Current page number
hasMore: boolean // Whether more data exists
data: Array<ImageBitmap> // Loaded image bitmap array
}
interface ResizePayload {
clientWidth: number // New CSS width
clientHeight: number // New CSS height
dpr: number // New device pixel ratio
}
interface RenderLoadingResponsePayload {
id: string // Placeholder ID
bitmap: ImageBitmap // Rendered bitmap [Transferable]
}
interface ClickPayload {
x: number // Click CSS X coordinate (relative to canvas)
y: number // Click CSS Y coordinate
}
type ClickResultPayload = {
item: GridItem // Hit grid item
index: number // Data source index
row: number // Row number
column: number // Column number
} | null // Returns null on miss
interface LayoutUpdatedPayload {
contentWidth: number // Total content width
contentHeight: number // Total content height
}
interface GridItem {
id: string // nanoid unique identifier
image: ImageBitmap | null // Image data (null when loading)
status: 'loading' | 'loaded' // Loading status
x: number // Layout X coordinate
y: number // Layout Y coordinate
width?: number // Render width (variable in masonry)
height?: number // Render height
itemIndex: number // Index in data source
}
GridItem is the core data structure for managing elements within the Worker, with x/y/width/height populated by the layout strategy.
The second parameter of postMessage can specify a list of Transferable objects. After transfer:
| Object Type | Transfer Timing | Direction |
|---|---|---|
| OffscreenCanvas | Initialization (once) | Main → Worker |
| ImageBitmap | Image load complete | Main → Worker |
| ImageBitmap | Placeholder render complete | Main → Worker |
// Transfer OffscreenCanvas (initialization)
this.#sendMessage(MessageType.Setup, payload, [offscreenCanvas])
// Transfer ImageBitmap (image loaded)
this.#sendMessage(MessageType.ImageLoaded, payload, [bitmap])
// Transfer ImageBitmap (placeholder)
this.#sendMessage(MessageType.RenderLoadingResponse, { bitmap, id }, [bitmap])
Worker cannot and does not need certain main-thread-only objects:
interface WorkerConfiguration extends Omit<
MasonryConfiguration,
'core' | 'interaction' | 'loader' | 'placeholderRenderer' | 'events' | 'imageLoad'
> {
core: Omit<Core, 'canvas' | 'items'> & {
items?: ImageBitmap[]
itemCount?: number
itemSizes?: Array<{ width?: number; height?: number }>
}
interaction?: Omit<Interaction, 'onClick'>
loader?: Omit<LoadMoreConfig, 'loadMore'>
}
Here is the complete code for how the main thread builds the SetupPayload, showing configuration stripping and data normalization logic:
// src/core/masonry.ts - payload assembly in #initWorker()
const payload: SetupPayload = {
offscreenCanvas,
clientWidth: canvas.clientWidth,
clientHeight: canvas.clientHeight,
config: {
core: {
// Only pass pure data fields; exclude canvas (DOM) and items (may contain URL strings)
backgroundColor: this.#config.core.backgroundColor,
style: this.#config.core.style,
layout: this.#config.core.layout,
limit: this.#config.core.limit,
timeout: this.#config.core.timeout,
},
},
dpr: window.devicePixelRatio || 1,
}
// ─── Items normalization: different handling paths for three input formats ───
const items = this.#config.core.items
if (items?.length) {
if (items[0] instanceof ImageBitmap) {
// Path 1: Pre-loaded ImageBitmap array
// Pass directly to Worker — Worker can render immediately
payload.config.core.items = items as ImageBitmap[]
} else {
// Path 2: URL strings or ItemDescriptor objects
// URLs cannot be sent to Worker (fetch needs main thread for cookies/auth)
// So only send count and size info; Worker uses placeholders first
const descriptors = this.#normalizeItems(items as string[] | ItemDescriptor[])
payload.config.core.itemCount = descriptors.length // Worker creates placeholder items
payload.config.core.itemSizes = descriptors.map((d) => ({
width: d.width,
height: d.height,
}))
// URLs stay on main thread, loaded later by ImageLoader
this.#pendingUrls = descriptors
}
}
// ─── Interaction config stripping: exclude non-serializable onClick function ───
if (this.#config.interaction) {
payload.config.interaction = {
scroll: this.#config.interaction?.scroll, // scroll is a pure data object
}
}
// ─── Loader config stripping: exclude non-serializable loadMore function ───
if (this.#config.loader) {
payload.config.loader = {
pageSize: this.#config.loader.pageSize, // Only pass the numeric value
}
}
// Send message with OffscreenCanvas as Transferable for zero-copy transfer
this.#sendMessage(MessageType.Setup, payload, [offscreenCanvas])
Design Notes:
itemCount + itemSizes design: Worker needs to know "how many items exist" to pre-create placeholder GridItems and perform layout calculation. Size information (when available) lets masonry layout produce accurate heights before images load.#pendingUrls purpose: Stores the list of URLs to load, which #loadImages() uses to start async loading after receiving SetupResponse.Main Thread Worker Thread
│ │
│──── Setup [OffscreenCanvas] ──────────→│
│ │ handleSetup()
│ │ performLayout()
│←──── SetupResponse ───────────────────│
│ │
│──── Render ───────────────────────────→│
│ │ startAnimationLoop()
│ │ detect loading items
│←──── RenderLoading [ids] ─────────────│
│ render placeholders │
│──── RenderLoadingResponse [bitmap] ───→│
│ │ draw placeholder
│ │
│──── ImageLoaded [bitmap] ─────────────→│
│ │ handleImageLoaded()
│ │ performLayout()
│←──── RemoveLoading [id] ──────────────│
│←──── LayoutUpdated ───────────────────│
│ │
│──── Scroll {deltaX, deltaY} ─────────→│
│ │ handleScroll()
│ │ tickInertia()
│ │ checkLoadMore()
│←──── LoadMore ────────────────────────│
│ loadMore() │
│──── LoadMoreResponse [bitmaps] ───────→│
│ │ handleLoadMoreResponse()
│ │
│──── Click {x, y} ────────────────────→│
│ │ handleClick()
│←──── ClickResult {item, row, col} ────│
│ │
│──── Resize {w, h, dpr} ──────────────→│
│ │ handleResize()
│←──── LayoutUpdated ───────────────────│