Get E-Book
Buffers

Buffer Fragmentation, Retained Views, and External Memory

Ishtmeet Singh @ishtms/September 28, 2025/25 min read
#buffers#fragmentation#memory-management#troubleshooting

Buffer fragmentation is not caused by one API call. It comes from the size of allocations, how long those allocations stay reachable, and which references keep backing memory alive. In production, it usually appears as RSS growing faster than the JavaScript heap, or as a service that keeps resident memory after traffic falls back to normal. Buffer payloads live in external memory accounted to V8, and process.memoryUsage().arrayBuffers includes memory allocated for ArrayBuffer, SharedArrayBuffer, and Buffer instances. Some small Buffer APIs may share slabs; other Buffer allocations use separate backing stores managed by V8, the runtime, and the native allocator.

Buffer Fragmentation

A retained slice can keep a larger allocation alive because the view still points at the same memory. That kind of lifetime bug often looks similar to allocator fragmentation in dashboards: RSS rises, GC work increases, allocations begin to stall, and heap snapshots do not seem large enough to explain the process size. The fixes are usually ordinary engineering constraints rather than special memory tricks: bounded queues, prompt reference release, copies for tiny retained regions, and streams for large payloads.

The previous sections established the core Buffer ownership difference: views share memory, while copies allocate new backing storage. Fragmentation is where that ownership rule becomes operationally visible. The same choice that looked small in a parser now affects memory graphs, queue behavior, and the shape of long-running services.

Small Buffer views pointing into one large backing store beside a copied byte range with independent storage.

Figure 2.1 — A tiny Buffer view keeps the original backing store reachable. Copying the retained range costs allocation and byte copying, but it gives the smaller value its own lifetime.

Memory Fragmentation

Fragmentation is important in long-running services because allocator state can drift away from the tidy heap graphs developers usually inspect. The process may contain plenty of reusable memory in total, but that memory can be split across regions that are awkward for later allocation requests. In a Buffer-heavy process, the symptom is often stable heapUsed alongside rising rss, external, or arrayBuffers. On Linux systems that use glibc malloc, Node's own process.memoryUsage() documentation calls out sustained rss growth with stable V8 heap totals as a possible allocator-fragmentation symptom.

The failure begins below the JavaScript object.

Virtual vs. Physical Memory

Your process does not directly choose physical RAM addresses. It operates inside a virtual address space, a large linear range that the operating system presents to each process. When native code asks for memory, the allocator needs a suitable range in that virtual address space and then coordinates with the operating system to back it with pages.

Behind that address space, the Memory Management Unit (MMU), a piece of CPU hardware, works with the operating system to map virtual addresses to physical pages. Those physical pages do not need to be adjacent to one another. The mapping also supports features such as swapping memory to disk and preventing one process from writing into another process's memory.

A large allocation needs a suitable range in the process's virtual address space and allocator state. It does not require one contiguous run of physical RAM.

Allocator State

When you call Buffer.alloc(65536) to get a 64 KiB buffer for a file read, the runtime does not use its internal Buffer pool. The reason is the API, not only the size: Buffer.alloc() always returns zero-filled memory outside the internal pool. Small pooled allocations are specific to APIs such as Buffer.allocUnsafe(), Buffer.from(array), Buffer.from(string), and Buffer.concat() when the requested size is below half of Buffer.poolSize.

That backing storage is ultimately handled by V8, the runtime, and the platform's native allocator. The exact system calls and allocator behavior are implementation details, but the operational effect is visible. A JavaScript wrapper can be collected while native memory, allocator arenas, and resident pages behave differently from the V8 heap.

Trouble starts when the application repeats this pattern thousands of times with buffers of varying sizes. Constant allocation and deallocation can leave the allocator with free regions of different sizes, separated by live allocations. The process may still contain enough reusable memory in aggregate, but a later request can require fresh memory or fail under process limits if no suitable region is available.

Two categories count for Buffer-heavy services. External fragmentation is the scenario above: enough total reusable memory may exist, but it is divided into non-contiguous regions. A later allocation may force the allocator to request more memory from the operating system, hold on to resident pages longer than expected, or fail when process or container limits are tight.

Internal fragmentation is different. It happens when an allocator reserves more memory than the application requested. If an allocator only deals in blocks of 32, 64, and 128 bytes, then a 33-byte request may receive a 64-byte block. The remaining 31 bytes are allocated but unused. The internal Buffer pool can also leave unused remainder inside a slab, and a tiny retained Buffer view can keep a larger pooled backing store alive. That is not the same as arbitrary gaps between fixed blocks; it is a trade-off made to reduce allocation overhead for short-lived small buffers.

A retained view is a lifetime problem, not allocator fragmentation by itself. It can look similar in graphs because a tiny JavaScript object keeps much larger external memory reachable. Keep that difference clear when reading heap snapshots: fragmentation is about allocator reuse, while retention is about references that are still live.

Four allocator states showing free regions split around a still-live allocation.

Figure 2.2 — Allocator pressure depends on reusable ranges, not only total free bytes. Separated free regions may be too small for a later contiguous allocation even when their combined size looks sufficient.

Read the figure as four states: the process starts with one large free region, allocates two buffers, releases the first buffer, and then holds two separated free regions around the still-live second buffer. A later request may not be able to reuse the total free space as one allocation.

A later request might need a 1.2 MiB buffer for a database dump. In the simplified diagram, the existing free regions cannot satisfy that request directly. In a real server, the allocator may request more memory from the operating system, keep RSS high after traffic drops, or eventually fail with ENOMEM when limits are tight. External fragmentation is about more than the number of free bytes in aggregate; it is about where those bytes are and how the allocator can reuse them.

Reducing Allocation Churn

The risk grows when the application becomes a high-churn allocator client: many buffers, varied sizes, and lifetimes that overlap unpredictably. Buffer.poolSize defaults to 8192 bytes, and in Node v24 the pool is used by eligible APIs only below half of that size. Buffer.alloc() is outside that pool at every size.

You usually cannot change how the OS allocator works from application code, but you can change allocation behavior. Reduce memory churn before reaching for runtime flags or allocator swaps.

Buffer.allocUnsafeSlow() is useful only in a narrow retained-small-chunk case. It never uses the shared internal pool, so it can avoid keeping an 8 KiB slab alive for one long-lived tiny buffer. The tradeoff is the same as Buffer.allocUnsafe(): the bytes are uninitialized until you overwrite them. For ordinary retained data, Buffer.from(view) is usually clearer. Reach for allocUnsafeSlow() only when the pool-retention cost is measured and the code immediately writes every retained byte.

Buffer Reuse

A hot path can often reduce churn by reusing a fixed backing buffer instead of allocating per message. This shows up inside a network data event handler or a tight binary-processing loop.

A server that processes incoming messages might need to frame each message with a 4-byte length header. For this example, assume chunk is already one complete message; a real TCP parser must handle arbitrary chunk breaks.

Allocating on Every Message

javascript
socket.on("data", (chunk) => {
  const header = Buffer.alloc(4);
  header.writeUInt32BE(chunk.length, 0);

  const framedPacket = Buffer.concat([header, chunk]);
  sendToNextService(framedPacket);
});

This version allocates a zero-filled header and then asks Buffer.concat() to allocate the full framed packet and copy both inputs. If the server handles 10,000 messages per second, that is 20,000 Buffer objects per second plus repeated byte copying. Buffer.concat() may use the internal pool for small totals, but it still creates a new Buffer and copies the input bytes. Both the garbage collector and native allocator now have more work to do.

Reusing One Buffer

javascript
const MAX_PACKET_SIZE = 65536; // 64 KiB
const reusableBuffer = Buffer.alloc(MAX_PACKET_SIZE);

The handler writes into that backing store and sends a view over the valid range:

javascript
socket.on("data", (chunk) => {
  const length = chunk.length + 4;
  if (length > MAX_PACKET_SIZE) {
    return console.error("Packet too large for reusable buffer!");
  }
  reusableBuffer.writeUInt32BE(chunk.length, 0);
  chunk.copy(reusableBuffer, 4);
  sendToNextService(reusableBuffer.subarray(0, length));
});

This removes repeated backing-store allocation from the hot path. The subarray() call still creates a small Buffer wrapper, but it does not copy the bytes. The header allocation and the full-frame allocation performed by Buffer.concat() are gone.

Shared Memory Hazard

The optimization above has a serious ownership problem. The Buffer returned by subarray() shares the same underlying memory as reusableBuffer.

If sendToNextService is asynchronous, which is typical for network operations, queuing systems, and pipelines, the next packet can overwrite the reusable buffer while the previous consumer is still reading it. The result is silent data corruption. A single reusable buffer is only safe when the consumer finishes synchronously before the function returns, or when the code coordinates buffer lifetimes explicitly.

Two overlapping views into the same reusable buffer while an earlier asynchronous consumer is still pending.

Figure 2.3 — A subarray() view shares the reusable buffer's backing store. If the next message overwrites that store before the asynchronous consumer finishes, the earlier view observes new bytes.

Explicit Pooling

For asynchronous consumers, use an explicit checkout/release pool. A ring counter that wraps blindly is still unsafe: when in-flight work exceeds the pool size, it reuses memory that a previous operation may still be reading.

javascript
const POOL_SIZE = 32;
const MAX_PACKET_SIZE = 65536;
const MAX_WAITERS = 64;
const freeBuffers = Array.from(
  { length: POOL_SIZE },
  () => Buffer.alloc(MAX_PACKET_SIZE),
);
const waiters = [];

The pool starts with a fixed number of buffers and a bounded list of waiters. Checkout either returns an available buffer, queues the caller, or rejects new work when the backlog is already too large.

javascript
function acquireBuffer() {
  const buffer = freeBuffers.pop();
  if (buffer !== undefined) return Promise.resolve(buffer);
  if (waiters.length >= MAX_WAITERS) {
    return Promise.reject(new Error("buffer pool backlog exceeded"));
  }
  socket.pause();
  return new Promise((resolve) => waiters.push(resolve));
}

Release hands the buffer directly to the oldest waiter when work is already queued. Only when no waiter exists does it return the buffer to the free list and resume the socket.

javascript
function releaseBuffer(buffer) {
  const resolve = waiters.shift();
  if (resolve !== undefined) {
    resolve(buffer);
    return;
  }
  freeBuffers.push(buffer);
  if (socket.isPaused()) socket.resume();
}

The data listener still delegates to an asynchronous handler and destroys the socket on failure.

javascript
socket.on("data", (chunk) => {
  void handleChunk(chunk).catch((error) => socket.destroy(error));
});

The handler then holds the buffer only for the duration of the asynchronous send. The finally block is the ownership handoff: every successful checkout is released even if the downstream operation rejects.

javascript
async function handleChunk(chunk) {
  const length = chunk.length + 4;
  if (length > MAX_PACKET_SIZE) return console.error("Packet too large!");
  const buf = await acquireBuffer();
  try {
    buf.writeUInt32BE(chunk.length, 0);
    chunk.copy(buf, 4);
    await sendToNextService(buf.subarray(0, length));
  } finally { releaseBuffer(buf); }
}

The pool gives each in-flight operation its own backing buffer and releases it only after the asynchronous consumer finishes. If the pool is exhausted, the socket is paused while work is backlogged. The waiter cap is as important as the pool: without it, input pressure moves from buffers into an unbounded promise queue. In a production parser, combine this pattern with real message framing and explicit queue limits so retained chunks cannot grow without bound.

Use the ownership model to choose the strategy:

  • Single reusable buffer only if consumers are truly synchronous (very rare)
  • Buffer pool for asynchronous consumers with bounded concurrency
  • Copy to new buffer if you can't bound in-flight work. Copy the data at send time, for example with Buffer.from(framedPacketView). This costs an allocation per packet, but the ownership contract is simple.

Buffer reuse can improve allocation behavior, but shared memory requires careful lifetime management to avoid corruption.

Debugging the Memory Graph

When a Buffer-heavy service grows in memory, collect evidence before changing allocation strategy:

  • process.memoryUsage() snapshots with rss, heapUsed, external, and arrayBuffers
  • RSS over time, especially before traffic, during peak load, and after traffic drops
  • Heap snapshots for retained JavaScript objects, with the caveat that Buffer payload bytes live outside the ordinary object graph
  • GC logs only when you need to separate JavaScript heap pressure from external-memory pressure
  • Container or process memory limits, because ENOMEM and OOM kills are limit problems even when the JavaScript heap looks stable
  • Queue depth, in-flight payload counts, and per-connection retained bytes
  • Allocation-site sampling or native profiling when the process appears dominated by allocator work

external can include more than Buffer memory, and rss can remain high after references are dropped because allocators do not always return pages to the OS immediately. Treat the counters as a shape, not as a single proof. A stable heap with rising arrayBuffers points toward retained backing stores. Stable arrayBuffers with rising rss points lower, toward allocator or native-memory behavior.

Understanding fragmentation is about seeing Buffer allocation as real work: zero filling, native allocation, V8 accounting, and later reclamation all have costs. Designing hot paths around bounded queues, prompt release, copies for tiny retained regions, and deliberate pooling makes those costs visible and controllable.

Practice Lab

These challenges bring together the Buffer concepts from this chapter and the previous ones: fixed-offset parsing, endianness, retained views, stream state, pooling, and shared memory. They are intentionally closer to production failure modes than to API drills.

Solutions are intentionally omitted. Build the parser, run it, inspect the bytes, and compare your output with the expected shape.

Start with fixed-size binary parsing.

Challenge #1

An IoT project sends sensor data packets over TCP to your server. The protocol is fixed-size: every packet is exactly 24 bytes long and has this structure:

Offset (Bytes)Length (Bytes)Data TypeDescription
0-34UInt32BESensor ID
4-118Float64BETimestamp (Unix epoch, ms)
12-132UInt16BESensor Type Code
141UInt8Status Flags (a bitmask)
151Int8Temperature (°C)
16-194Float32BEHumidity (%)
20-234Float32BEPressure (kPa)

Your Task

Write a function called parseSensorData that accepts a 24-byte Buffer. It should parse the buffer according to the specification above and return a JavaScript object with the decoded values.

Use this sample Buffer to test your function.

javascript
const samplePacket = Buffer.from([
  0x00, 0x00, 0x01, 0xa4, // Sensor ID: 420
  0x42, 0x78, 0x56, 0xaa, 0x0c, 0x80, 0x00,
  0x00, // Timestamp: 1672531200000
  0x00, 0x01, // Sensor Type: 1 (Thermometer)
  0x05, // Status Flags: 00000101 (Bit 0 and Bit 2 are set)
  0x19, // Temperature: 25°C
  0x42, 0x48, 0x00, 0x00, // Humidity: 50.0
  0x42, 0xc8, 0x66, 0x66, // Pressure: 100.2
]);

The Goal

Your parseSensorData(samplePacket) function should return an object that looks like this:

json
{
  "sensorId": 420,
  "timestamp": 1672531200000,
  "sensorType": 1,
  "statusFlags": 5,
  "temperature": 25,
  "humidity": 50,
  "pressure": 100.19999694824219
}

Things to Consider

  • Which Buffer read method matches each field?
  • Pay close attention to the data types (UInt, Int, Float64/Double, Float32/Float) and the endianness (BE means Big Endian).
  • The offset for each read is important. This is a fixed-size protocol, so the offsets are constant.
  • Validate the input buffer's length before attempting to parse it.

Challenge #2

Make the retained-view behavior measurable. A small Buffer view can keep a much larger backing store alive even after the parent variable is gone.

Your Task

Write a program that demonstrates and quantifies retained backing-store memory. The script should perform two separate tests:

  1. The view test

    • Allocate a single, large Buffer (for example, 50 MiB).
    • In a loop, create a large number of small views (e.g., 100,000 views of 16 bytes each) from this large buffer using buf.slice() or buf.subarray() (preferred).
    • Store these views in an array so they are not garbage collected.
    • After the loop, log the memory usage using process.memoryUsage(). Pay close attention to arrayBuffers and external.
  2. The copy test

    • Allocate a single, large Buffer of the same size (50 MiB).
    • In a loop, create a large number of small copies (e.g., 100,000 copies of 16 bytes each).
    • Store these copies in an array.
    • After the loop, ensure the original large buffer is eligible for garbage collection and, if possible, invoke the GC.
    • Log the memory usage again.

The Goal

Your script's output should show a clear difference in Buffer backing-store memory between the two tests. The view test's arrayBuffers and external values should stay slightly over 50 MiB because the small views keep the large backing store alive. The copy test's arrayBuffers value should be much closer to the total copied payload size, though rss may not drop immediately because the native allocator can retain pages.

Things to Consider

  • You'll need to run your script with the --expose-gc flag to be able to call global.gc(). This makes the results much more deterministic.
  • Why are arrayBuffers and external the important metrics for this experiment? What do rss and heapUsed represent?
  • The total size of the copies is 100,000 * 16 bytes = 1,600,000 bytes, about 1.53 MiB. Your result for the copy test should be in this ballpark.
  • A helper function to format the byte counts into KiB/MiB will make your output easier to read.

Challenge #3

Now handle a variable-length protocol: a TCP stream carrying Type-Length-Value (TLV) messages.

Because the source is a TCP stream, data can arrive in arbitrary chunks. A single data event might contain multiple TLV messages, or only part of one. The parser therefore needs state: it must hold partial data until the rest of the message arrives in a later chunk.

The Protocol Specification

Each TLV message has a 3-byte header followed by a variable-length value.

Offset (Bytes)Length (Bytes)Data TypeDescription
01UInt8Message Type (a number from 1-255)
1-22UInt16BELength of the value part in bytes (0-65535)
[3, 3+L)LBufferThe Value (payload)

The payload range is half-open to match Buffer.subarray(start, end): it includes offset 3 and excludes offset 3 + L. For L = 5, the value occupies offsets 3 through 7.

Your Task

Create a TlvParser class that extends stream.Transform. This class is the core of the solution. It needs to:

  1. Maintain an internal buffer for incomplete message chunks.
  2. Configure the readable side for object output with super({ readableObjectMode: true }); the writable side should still accept byte chunks.
  3. In its _transform method, append incoming data to the internal buffer.
  4. Continuously try to parse complete TLV messages from its internal buffer.
  5. If a full message is parsed, it should push a JavaScript object { type, value } downstream. The value should be a copy of the payload buffer.
  6. The remaining unparsed data must be kept in the internal buffer for the next chunk.

Sample Data Stream

The data will arrive in chunks. Here is one example sequence:

javascript
const message1 = Buffer.from([
  0x01, 0x00, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f,
]); // Type 1, Length 5, Value "hello"

const message2 = Buffer.from([
  0x02, 0x00, 0x08, 0x67, 0x6f, 0x6f,
  0x64, 0x62, 0x79, 0x65, 0x21,
]); // Type 2, Length 8, Value "goodbye!"

Now split those messages across arbitrary chunks:

javascript
const chunk1 = message1.subarray(0, 4); // Contains header and one byte of value
const chunk2 = Buffer.concat([
  message1.subarray(4),
  message2.subarray(0, 6),
]); // Contains rest of msg1 and start of msg2
const chunk3 = message2.subarray(6); // Contains the rest of msg2

The Goal

When you pipe these chunks through an instance of your TlvParser, it should emit two data events, producing these objects in order:

  1. { type: 1, value: <Buffer 68 65 6c 6c 6f> } (value is "hello")
  2. { type: 2, value: <Buffer 67 6f 6f 64 62 79 65 21> } (value is "goodbye!")

Things to Consider

  • How will you manage your internal buffer? For this exercise, Buffer.concat() is acceptable; in production, bound retained data and avoid repeated concat patterns that grow quadratically.
  • Your parsing loop needs to check if you have enough data for a header (3 bytes), then read the length, and then check if you have enough data for the full value.
  • Once a message is successfully parsed, how do you remove it from your internal buffer so you can parse the next one? buf.subarray() is the tool for this.
  • Why is it important for the parser to emit a copy of the value buffer, not a view into its internal buffer? Think about what happens to the internal buffer over time.

Challenge #4

Your video processing service constantly allocates and frees large 64 KiB buffers. After running for a few days, it crashes with out-of-memory errors. To reduce that churn, implement a custom application-level buffer pool.

Your Task

Create a BufferPool class that manages a fixed number of pre-allocated buffers of a specific size.

The class must have the following features:

  1. Constructor (bufferSize, poolSize):

    • Takes the size of each buffer (for example, 65,536 bytes) and the number of buffers to keep in the pool (for example, 100).
    • Pre-allocates all of these buffers and stores them, perhaps in an array.
  2. Method get():

    • Returns an available buffer when the pool has one.
    • If the pool is empty, logs a warning and allocates a temporary buffer of the correct size. This prevents the application from crashing but signals that the pool might be too small.
    • Returns a Buffer.
  3. Method release(buffer):

    • Takes a buffer that was previously acquired from the pool.
    • Returns the buffer to the pool, making it available for the next get() call.
    • It must reject buffers that were not created by the pool.
    • It must reject double releases.
    • It should prevent temporary overflow buffers from being added to the fixed pool.
  4. Property used:

    • A getter that returns the number of buffers currently checked out from the pool.

The Goal

Write the BufferPool class and then write a small simulation to test it. The simulation should:

  1. Create a pool.
  2. Get several buffers from it, checking the used count.
  3. Release those buffers back to the pool.
  4. Test the "pool empty" condition by trying to get more buffers than the pool size.
  5. Test the release logic for an "extra" buffer that was created when the pool was empty.

Things to Consider

  • What's the best data structure to hold the available buffers? An array with push() and pop() is simple and efficient.
  • Track ownership explicitly. A same-size Buffer from somewhere else must not pass validation. A Set of pool-owned buffers plus a Set of currently checked-out buffers is enough for this exercise.
  • A regular JavaScript BufferPool instance is not shared mutable state across worker threads. If workers need a common pool, use a main-thread lease protocol with message passing, or design a SharedArrayBuffer allocator with Atomics. A plain array of Buffer objects does not become thread-safe by being passed to workers.
  • Use try...finally when using this pool so buffers are released even if errors occur.

Challenge #5

You are interfacing with a legacy piece of hardware that mixes Big-Endian and Little-Endian byte orders within the same data packet. You could parse this with Buffer read methods, but this exercise uses DataView so each field's byte order is explicit.

The Protocol Specification

The packet is 16 bytes long.

Offset (Bytes)Length (Bytes)Data TypeEndiannessDescription
0-12UInt16BigPacket Signature (must be 0xCAFE)
2-54Int32LittleDevice ID
6-94Float32BigVoltage Reading
101UInt8N/AStatus Code
111UInt8N/AChecksum
12-154UInt32LittleUptime in seconds

Your Task

Write a function parseLegacyPacket(buffer) that takes a 16-byte Buffer. Inside it, create a DataView over the buffer's underlying ArrayBuffer using the Buffer's byteOffset and byteLength: new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength). Use DataView methods such as getUint16, getInt32, and getFloat32 to parse the packet according to the specification. The final boolean argument controls endianness: true for little-endian, false for big-endian.

Sample Data

javascript
const legacyPacket = Buffer.from([
  0xca, 0xfe, // Packet signature (BE)
  0xad, 0xde, 0x00, 0x00, // Device ID: 57005 (LE)
  0x40, 0xa0, 0x00, 0x00, // Voltage: 5.0 (BE)
  0x01, // Status: 1 (OK)
  0xb5, // Checksum
  0x80, 0x51, 0x01, 0x00, // Uptime: 86400 (LE)
]);

The Goal

Your parseLegacyPacket(legacyPacket) function should return an object that looks like this:

json
{
  "signature": 51966,
  "deviceId": 57005,
  "voltage": 5,
  "status": 1,
  "checksum": 181,
  "uptime": 86400
}

Things to Consider

  • How do you get the underlying ArrayBuffer from a Buffer to create a DataView? Every Buffer instance has a .buffer property.
  • Be careful with the byteOffset. A small Buffer may share a larger pooled ArrayBuffer, so the DataView should use buffer.byteOffset and buffer.byteLength even when the Buffer is not an explicit slice.
  • The third argument to DataView methods is the endianness flag. false (or omitted) is Big-Endian. true is Little-Endian. You will need to use both.
  • The parser should make every offset, width, and byte-order choice explicit.

Challenge #6 (Advanced)

You have a latency-sensitive application where multiple worker threads need to increment a shared counter. Passing a message to the main thread for every increment would add per-increment messaging, scheduling, and cloning overhead. That is the wrong shape for a hot counter; the counter needs shared memory plus an atomic update operation.

Your Task

Write a script that demonstrates a thread-safe counter using a SharedArrayBuffer and Atomics.

  1. Main Script (main.js)

    • Create a SharedArrayBuffer large enough to hold one 32-bit integer (4 bytes).
    • Create an Int32Array view over it.
    • Initialize the counter at that memory location to 0.
    • Create two Worker threads, passing the SharedArrayBuffer to each of them.
    • Each worker will increment the counter a large number of times (e.g., 1 million).
    • Wait for both workers to signal that they are finished.
    • Read the final value from the SharedArrayBuffer using Atomics.load() and print it. The final value should be the sum of all increments (e.g., 2 million).
  2. Worker Script (worker.js)

    • Receive the SharedArrayBuffer via a message.
    • Create its own Int32Array view over the shared buffer.
    • In a tight loop, increment the shared counter using Atomics.add(). This is the key to thread safety.
    • When the loop is done, send a 'done' message back to the main thread.

The Goal

The final output on the main thread should be Final counter value: 2000000. If you were to use a non-atomic operation like view[0]++, you would likely get a final value less than 2 million due to race conditions, where one worker's read-modify-write cycle overwrites another's.

Things to Consider

  • This is the only challenge that requires two separate files.
  • SharedArrayBuffer is the core component that allows memory to be visible across threads.
  • Why is Atomics.add(view, 0, 1) required instead of view[0]++? Research what a "race condition" is in the context of a read-modify-write operation.
  • How does the main thread know when both workers are finished? You can use Promises to wait for the 'done' message from each worker. Promise.all is a good tool for this.
  • This is shared mutable state; the correctness edge is the Atomics operation, not the typed-array assignment.

Operational Edge

Buffer memory problems usually come from lifetime, not from the Buffer API alone. Retain views deliberately, measure rss, external, and arrayBuffers together, bound queues before traffic spikes turn into retained memory, and copy small regions when keeping a view would pin a much larger backing store. Reuse buffers only when ownership is explicit; otherwise, a copy is often the safer optimization.