The question this answers
Should each task get its own copy of this data, or should they all point at one?
A fan-out that hands a 4 MB parsed document to eight workers, each of which extracts a different section and returns a summary.
The parsed document. If shared, one buffer with eight readers. If copied, eight independent buffers with one reader each and nothing shared at all — which is the point.
Every worker's summary describes the same document version, and no worker observes a byte that another worker wrote.
Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.
The two costs, side by side
Copying is not the naive option. It converts a correctness problem into a resource problem, and resource problems are visible in metrics while correctness problems are visible in postmortems. Eight copies of 4 MB is 32 MB and a few milliseconds of memcpy; eight readers of one buffer is 4 MB and an obligation on every future maintainer never to write to it.
Sharing is not the fast option either, at least not automatically. If the workers only read, sharing is genuinely free after publication. If any worker writes, you have bought a lock, and the lock costs both wait time and the cache-coherence traffic of moving that line between cores — which is frequently larger than the copy you were avoiding.
The crossover has three inputs: how big the data is, how much of it each task touches, and whether anyone writes. A task that reads 5% of a large structure argues for sharing; a task that reads all of a small structure and might mutate it argues for copying.
| Approach | Memory | Setup cost | Synchronization needed | Fails as |
|---|---|---|---|---|
| Copy per task | N x payload | N x copy or serialize | None — nothing is shared | Memory ceiling; copy time dominating small tasks |
| Share read-only | One payload | One publication | Publication edge only | A future write to the "read-only" buffer |
| Share mutable + lock | One payload | One publication | Full critical section per access | Contention, convoy, lock held across I/O |
| Share a slice per task | One payload | N cheap slice objects | Disjointness must be proven | Overlapping ranges; false sharing at slice boundaries |
Sharing without the discipline
The failure that makes teams copy forever is not exotic. One of the eight workers is written six months later by someone who does not know the buffer is shared, and it normalizes whitespace in place before extracting its section. Seven summaries are now computed against a document that the eighth worker edited, and the seven that ran first are fine while the seven that ran second are not — so the bug is a function of scheduling, which means it reproduces once a week in production and never on a laptop.
Note precisely what this is: a race condition on the invariant "every worker sees the same document", not necessarily a data race. If the runtime serializes the writes (a single-threaded event loop, for instance) there is no unsynchronized conflicting access at all and every memory-model tool reports a clean run. The logical corruption is identical.
Copying makes that whole class of bug unrepresentable, which is why the correct default for data crossing a task boundary is *transfer or copy*, and sharing is the optimization you make deliberately with a comment explaining why.
| # | Worker 3 (summary) | Worker 7 (normalizes in place) | Worker 5 (summary) | State |
|---|---|---|---|---|
| 1 | read doc[0..500k] | · | · | doc=v0 w3=summary from v0 |
| 2 | · | doc.normalizeWhitespace() — writes 190k bytes in place | · | doc=v0-mutated ✕ The document is no longer the version Worker 3 summarised, and no worker asked for it to change. |
| 3 | · | · | read doc[500k..1M] | doc=v0-mutated w5=summary from v0-mutated |
| 4 | return summary | · | · | result=inconsistent set |
The decision, written down
The version that survives review makes the choice explicit at the boundary rather than implicit in a parameter type. Hand a task an owned value or a reference typed as read-only, and let the signature carry the contract. In JavaScript across workers the runtime already forces the issue — you get a structured clone unless you transfer or use a SharedArrayBuffer — which is why worker code has fewer of these bugs and more of the memory ones.
Transfer is the third option worth naming: move ownership rather than duplicating or sharing. A transferred ArrayBuffer costs no copy and leaves the sender with a detached, unusable handle, so the isolation is enforced by the runtime rather than by convention. C++ says the same thing with std::move and a unique_ptr, and Rust says it with the type system.
When you do share, share the smallest thing: a slice per worker over disjoint ranges gives you one buffer, no copies and no synchronization — provided the ranges really are disjoint, and provided the boundaries do not put two workers on one cache line, which is False Sharing: Different Variables, Same Cache Line.
1// doc is a plain object every worker can write.2async function fanOut(doc: ParsedDoc) {3 return Promise.all(4 sections.map((s) => summarize(doc, s)), // summarize *may* mutate doc5 )6}1// Readonly at the boundary; transfer where the payload is large.2async function fanOut(doc: Readonly<ParsedDoc>) {3 return Promise.all(4 sections.map((s) => summarize(doc, s)),5 )6}7// Cross-worker: transfer, do not clone a 4 MB buffer eight times.8worker.postMessage({ buf }, [buf]) // buf is detached in the senderReadonly is compile-time only and will not stop a library, but it turns a runtime scheduling bug into a review-time type error for the code you own. The transfer list turns isolation into a runtime guarantee: after postMessage the sender physically cannot touch the buffer.
Key points
- Copy gives isolation at the price of memory and copy time; share gives efficiency at the price of synchronization. Both prices are real.
- The crossover depends on payload size, the fraction each task touches, and whether anyone writes — not on which one feels more elegant.
- Shared read-only data after a correct publication needs no synchronization at all; it is the possibility of a future write that costs you.
- Transfer is the underrated third option: no copy, and isolation enforced by the runtime rather than by convention.
- A worker mutating a shared buffer is a race condition even in a runtime where no data race is possible.
The loop, answered
Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.
- • Decide whether any task writes. If none does, sharing is a publication problem and nothing more.
- • If some task writes, decide whether writes can be confined to disjoint regions — then slice rather than share the whole.
- • If writes are unavoidable and overlapping, price the lock: hold time, expected waiters, and what the critical section touches.
- • Compare that price against N copies: bytes, copy time, and peak memory at maximum fan-out — not average fan-out.
- • Encode the winner at the boundary: an owned value, a read-only reference, or a transfer, so the next reader of the code cannot get it wrong silently.
- • Shared, all readers: W3 reads, W5 reads, W7 reads — no ordering matters, every schedule is equivalent, no synchronization required.
- • Shared, one writer: W3 reads doc; W7 normalizes doc in place; W5 reads doc — W3 and W5 summarise different documents and neither knows.
- • Copied: W3 reads copy3; W7 mutates copy7; W5 reads copy5 — no schedule can make these interfere, at the cost of 8x memory.
- • Sliced: W3 writes bytes 0..500k; W5 writes bytes 500k..1M — correct by disjointness, but if the boundary lands mid cache line, both cores fight over one line and throughput drops with no lock in sight.
- • Transferred: sender posts buf and continues; sender touches buf — throws immediately because the buffer is detached. The failure is loud and deterministic rather than timing-dependent.
- • Copying guarantees complete isolation: no interleaving of any kind can make one task observe another's writes.
- • Copying does NOT guarantee the copies are of the same version — snapshot once and copy from the snapshot, or you have reintroduced skew.
- • Sharing read-only guarantees consistency only after a correct publication; before it, visibility is not promised.
- • Sharing with a lock guarantees mutual exclusion for the region the lock covers, and nothing about regions it does not — a lock on the map does not protect the object the map hands back.
- • A structured clone guarantees isolation but NOT that the copy is cheap, or that every value survives it — functions, class identity and some host objects do not.
- • Transfer guarantees the sender cannot use the buffer afterwards; it does NOT deep-transfer nested buffers you forgot to list.
- • Copy: contention on the allocator and on memory bandwidth, both of which scale with fan-out and payload size.
- • Share read-only: no contention. The line sits shared in every core's cache.
- • Share mutable: contention on the lock, plus coherence traffic on the written lines — the second is invisible in lock metrics and often larger.
- • Slices: no lock contention, but boundary cache lines can be contended by two cores with no lock involved at all.
- • Race condition on a shared buffer when one task mutates it — reproducible only under the schedules production produces.
- • Data race if the mutation is unsynchronized in a language with a real memory model; in C++ that is undefined behaviour, not a stale read.
- • Memory exhaustion at peak fan-out, because the copy design was sized against average concurrency.
- • Version skew when copies are taken from a moving source at different times.
- • False sharing at slice boundaries, presenting as inexplicable throughput loss that scales with core count.
- • Silent data loss through a structured clone that drops functions, prototypes or class identity.
- • Copy helps when payloads are small, tasks are many, and any of them might write — the isolation is worth more than the bytes.
- • Copy helps at a trust boundary: never share a mutable structure with code you do not own.
- • Share helps when the payload is large, every consumer reads, and the read set is a small fraction of the whole.
- • Transfer helps for large binary payloads moving in one direction — the common case for worker offload.
- • Copying hurts when the payload is large and the fan-out is wide: 200 tasks over a 50 MB structure is a memory incident, not a design.
- • Copying hurts when serialization is required to copy — a structured clone or JSON round-trip of a deep object can cost more than the work itself.
- • Sharing hurts the moment a write appears, because it converts a free design into a locked one and the lock is usually added under time pressure.
- • Slicing hurts when disjointness is asserted rather than proven; an off-by-one in a range calculation is silent corruption.
- • Peak RSS at maximum observed concurrency, not mean — the copy design fails at the tail.
- • Time spent in copy or serialize as a fraction of task duration; above roughly a tenth, the copy is the workload.
- • Lock wait time and acquisition count on the shared path — the number that tells you whether sharing is actually free.
- • Cache-miss or coherence-miss rate when slicing, which is the only signal that shows false sharing.
- • Count of distinct source versions observed within one fan-out, if you version the payload.
- • Copying adds a memory budget you must reason about at peak, and a snapshot discipline so the copies agree.
- • Sharing adds an ownership convention that lives in comments and reviewer memory unless the type system carries it.
- • Slicing adds range arithmetic, which is where off-by-one corruption lives, plus alignment concerns at boundaries.
- • Transfer adds a detached-object failure mode in the sender and a list of buffers that must be kept in sync with the payload shape.
- • Send only what the task needs — the section, not the document. Often the payload was never the right unit and both branches of the decision were wrong.
- • Immutability, which makes sharing free by removing the writer entirely. See Immutability as a Concurrency Strategy.
- • Copy-on-write, which shares until someone writes and copies only then. See Copy-on-Write as a Concurrency Strategy.
- • Keep the data with a single owner and pass messages, so the question does not arise. See Message Passing and The Actor Model.
- • Do it sequentially. Eight sections of one 4 MB document is often faster in one pass than in eight tasks plus eight copies. See Parallel Overhead.
What people believe, and what is true
Copying is the beginner's option and sharing is what fast code does.
Sharing is fast only when nobody writes. Once a write exists, the lock plus the coherence traffic frequently costs more than the memcpy you avoided, and it costs it on every access rather than once.
If the runtime is single-threaded, sharing a mutable object is safe.
It is free of data races and full of race conditions. An await inside a handler is a yield point; the object can be mutated across it by another task even with no threads anywhere.
postMessage is slow because it copies.
It copies unless you transfer. Moving a large ArrayBuffer through the transfer list is O(1) and leaves the sender detached, which is usually what the code wanted anyway.
Go deeper
Overview
Give each task its own copy and nothing can interfere, but you pay memory and copy time. Point them all at one and you pay coordination instead. Pick with numbers.
Practical
Default to copying or transferring across a task boundary. Share only read-only data, and encode that at the signature. If a write appears later, that is a design change, not a small patch.
Advanced
Slice a large buffer into disjoint per-task ranges to get sharing with no synchronization — then check the boundary alignment, because two tasks writing adjacent bytes on one cache line will fight over it invisibly.
Internals
Transfer of an ArrayBuffer moves the backing store pointer and detaches the sender's view; nothing is copied. A structured clone walks the object graph and reconstructs it, which is why cost scales with graph size rather than with the byte count you were thinking of.