I run a DXF viewer that works entirely in the browser. DXF is the text format CAD programs use to exchange drawings. A Web Worker reads the file and builds the drawing (a collection of typed arrays of coordinates), hands it to the page with postMessage, and the page draws it on a canvas.
A Worker's postMessage can transfer an ArrayBuffer instead of copying it: buffers listed in the second argument are handed to the other side without a copy, and become unusable on the sending side. It is the standard advice for passing large data to and from a Worker, and when I added the DXF viewer, I made the Worker move every typed array in its result that way.
Then a drawing with 100,000 layers took almost 14 seconds to open in Chromium. The time was not spent building the drawing. It was spent handing the result to the page. This post is about that.
What happened
The drawing is 100,000 lines, each on its own layer (6.4 MB). The viewer keeps three typed arrays (coordinates, indexes and arcs) per drawing unit, a unit being a combination of layer, color and line type. Here every unit is one line, so the built result holds 100,000 arrays of each size (16, 8 and 0 bytes): 300,000 arrays, 2.4 MB in all. All of them went into the transfer list.
I timed opening this drawing until it was drawn, changing only what gets moved (built site, median of 3 runs). "Before" was measured again on today's code with the threshold set to 0.
| What is moved | Chromium 151 | WebKit 26.5 |
|---|---|---|
| Every typed array (before) | 13.6 s | 1.7 s |
| Only arrays of 64 KB or more; the rest copied (after) | 2.8 s | 1.7 s |
Only Chromium showed the 10-second difference (when I made the fix, I measured 14 s → 2.0 s).
Chromium ran on a Linux machine (AMD Ryzen 9 6900HX) and WebKit on a MacBook Pro (Apple M5 Pro), both driven by Playwright. The machines differ, so the browsers cannot be compared with each other. Only "move" and "copy" within the same browser can.
Measuring move and copy
The Worker sends N Uint8Arrays of the same size to the page. To move them, every ArrayBuffer goes in the second argument; to copy them, none does.
The time runs from just before the Worker calls postMessage to the page having event.data (performance.timeOrigin + performance.now() on both sides). Each value is the median of 3 runs. Chromium's values are rounded to 0.1 ms, and 0 means below what the clock could resolve; WebKit's clock reported whole milliseconds.
// In the Worker (the measured part only) const arrays = []; for (let i = 0; i < n; i++) arrays.push(new Uint8Array(size)); const buffers = mode === 'transfer' ? arrays.map((a) => a.buffer) : []; const t0 = performance.timeOrigin + performance.now(); postMessage({ arrays }, buffers); Many small arrays (64 bytes each)
| Count | Chromium move | Chromium copy | WebKit move | WebKit copy |
|---|---|---|---|---|
| 1,000 | 1.3 ms | 1.0 ms | 1 ms | 1 ms |
| 10,000 | 26.6 ms | 12.5 ms | 4 ms | 2 ms |
| 100,000 | 1,271.6 ms | 91.8 ms | 31 ms | 28 ms |
| 300,000 | 10,653 ms | 406.7 ms | 120 ms | 107 ms |
In Chromium, moving grew faster than the count: about 48 times longer for 10 times as many (10,000 → 100,000), about 8 times longer for 3 times as many (100,000 → 300,000). Per array, that is about 1.3 µs at 1,000 and about 35 µs at 300,000. Copying did not grow like that (about 1.4 µs per array at 300,000).
Of the 10.7 seconds it took to move 300,000, 10.4 seconds were inside the Worker's postMessage call.
In WebKit, moving and copying were close; at 300,000, moving was about 10% slower.
64 MB in total, split into arrays of different sizes
| Size × count | Chromium move | Chromium copy | WebKit move | WebKit copy |
|---|---|---|---|---|
| 1 KB × 65,536 | 567.4 ms | 205.7 ms | 21 ms | 25 ms |
| 4 KB × 16,384 | 53.6 ms | 145.8 ms | 6 ms | 12 ms |
| 16 KB × 4,096 | 7.6 ms | 119.1 ms | 2 ms | 10 ms |
| 64 KB × 1,024 | 1.5 ms | 137.4 ms | 1 ms | 15 ms |
| 256 KB × 256 | 0.4 ms | 124.3 ms | 0 ms | 7 ms |
| 1 MB × 64 | 0.2 ms | 106.9 ms | 0 ms | 8 ms |
| 4 MB × 16 | 0 ms | 104.3 ms | 0 ms | 6 ms |
Large arrays are faster to move in both browsers. Copying 64 MB takes about 100 ms in Chromium; split into pieces of 64 KB or more, moving takes under 2 ms.
In this table, the only case where copying won in Chromium was 1 KB pieces (65,536 of them).
"Transfer large ArrayBuffers" holds. What I had wrong was assuming that moving pays off however small and however many the buffers are. In Chromium, moving costs something per buffer, and the cost per buffer went up as the count went up. Copying also costs something per array, but that barely grew with the count. So many small arrays are cheaper to copy.
Where to split
The function that collects what to move now looks like this:
/** * Buffers smaller than this are copied with the message rather than moved: moving costs per * buffer (Chromium took 12 s to move 300,000 small ones, 0.25 s to copy them), copying per byte. * So the number moved is at most the result's size / 64 KB. */ export const MIN_TRANSFER_BYTES = 64 * 1024; export function collectBuffers(v: unknown, out: Transferable[], seen = new Set<ArrayBuffer>()): void { if (ArrayBuffer.isView(v)) { const buf = v.buffer; // A Set, not out.includes: a drawing has thousands of arrays, and includes() made it quadratic. if (buf instanceof ArrayBuffer && buf.byteLength >= MIN_TRANSFER_BYTES && v.byteOffset === 0 && v.byteLength === buf.byteLength && !seen.has(buf)) { seen.add(buf); out.push(buf); } } else if (Array.isArray(v)) for (const x of v) collectBuffers(x, out, seen); else if (v && typeof v === 'object') for (const x of Object.values(v)) collectBuffers(x, out, seen); } The 12 s and 0.25 s in the comment are from when I made the fix; the table above (300,000 arrays of 64 bytes) gave 10.7 s and 0.41 s. The comment's "copying per byte" overstates it, as the measurements show: copying has a per-array cost too.
Moving only 64 KB and up keeps the number moved at or below the result's size divided by 64 KB. A 64 MB result moves at most 1,024 buffers, which took 1.5 ms above. Smaller arrays are copied. For the drawing at the top, that means copying 300,000 arrays, 2.4 MB; copying 300,000 arrays took about 0.4 s in the table, far less than the 10.7 s to move them.
A typed array that covers only part of its buffer (a non-zero byteOffset, or shorter than the buffer) is copied too: moving its buffer would make the rest of that buffer unusable on the sending side. But copying sends the whole buffer, not just the part the array covers. structuredClone of a 4-byte view on a 16 MB buffer delivered a 16 MB buffer (checked in Node 22). If you return a small view on a big buffer, slice it out first.
64 KB leans toward keeping the per-count cost bounded; it is not the fastest split. In the table, moving already won in Chromium from 4 KB up. A result made entirely of 4–64 KB arrays loses by copying them (64 MB in 16 KB pieces: 7.6 ms to move, 119 ms to copy).
I checked with the drawings I have: 24 publicly available JW_CAD drawings and samples, converted to DXF with my own prototype converter (17 KB to 2.1 MB). Built and counted, the 4–64 KB arrays came to 55 in all, 0.74 MB (under 4 KB: 1,064 arrays, 0.27 MB; 64 KB and up: 2 arrays, 0.21 MB). Copying that much costs little.
Two more fixes in the same function
The first version of the function was written together with the Workers of the DocuWorks and Ichitaro viewers and the archive extractor, whose typed arrays were Uint8Arrays: PDF bytes, images, and file contents.
// The first version function collectBuffers(v: unknown, out: Transferable[]): void { if (v instanceof Uint8Array) { if (v.byteOffset === 0 && v.byteLength === v.buffer.byteLength && !out.includes(v.buffer as ArrayBuffer)) out.push(v.buffer as ArrayBuffer); } else if (Array.isArray(v)) for (const x of v) collectBuffers(x, out); else if (v && typeof v === 'object') for (const x of Object.values(v)) collectBuffers(x, out); } A DXF drawing is a collection of Float32Arrays (coordinates) and Uint32Arrays (indexes), and far more of them. When I added the DXF viewer, review found two problems in this function, and the "move everything" version above came out of fixing them. In order, with numbers measured again in Node 22 (V8):
It walked into typed arrays (the first version). A Float32Array is not a Uint8Array, so it was treated as a plain object, passed to Object.values, and walked element by element. One Float32Array of 4 million coordinates (16 MB) took 89 ms, and not a single buffer was found to move. Copying the same 16 MB takes 9.2 ms, so it spent ten times the cost of the copy it meant to save, and then copied anyway. With ArrayBuffer.isView recognizing every typed array, the buffer is found in 0.1 ms. That is when every typed array started being moved.
It looked for duplicates with includes. The check keeps a buffer from being listed twice. In the first version, which only looked at Uint8Arrays, a drawing's arrays never reached it. Once every typed array was considered, the search ran over all of a drawing's arrays and grew with the square of their number (review at the time measured 3.2 s for 50,000 pattern hatches, 150,000 arrays). Comparing just the duplicate check:
| Arrays | out.includes | Set |
|---|---|---|
| 10,000 | 13.7 ms | 2.3 ms |
| 30,000 | 110.3 ms | 3.3 ms |
| 100,000 | 1,188.6 ms | 10.9 ms |
What remained after the switch to Set was the cost of moving, the subject of this post. None of these show while the typed arrays are Uint8Arrays and fewer than about 10,000. They start to matter with a result like a drawing, made of tens of thousands of small arrays or more.
Limits
- Chromium was measured on Linux (AMD Ryzen 9 6900HX) and WebKit on a MacBook Pro (Apple M5 Pro), one machine each, 3 runs each. The machines differ, so this does not compare the browsers' speed
- Chromium here is Playwright's headless build (chromium-headless-shell 151), not Chrome with a window. WebKit is Playwright's WebKit 26.5, not Safari itself
- Firefox was not measured (I don't have it at hand)
- The move/copy measurements used 64-byte
Uint8Arrays, not the drawing's arrays (0, 8 and 16-byteFloat32Arrays andUint32Arrays) - The time to open the drawing includes handing the 6.4 MB file to the page (for WebKit, sending it to the Mac over ssh too)
- The 24 drawings I counted are JW_CAD drawings converted with a prototype; large DXF files written by CAD programs may split into arrays differently
- I did not look into why moving grows faster than the count in Chromium. Searching Chromium's issue tracker, I could not find the same report
- 64 KB comes from the Chromium measurements; the best split depends on the browser, the machine and the shape of the result
The viewer
A DXF viewer that runs entirely in the browser: zoom, pan, toggle layers, save as PNG, and print fitted to one A4 or A3 sheet (or save as PDF). The file is not sent to a server.
- Article for readers (the viewer is embedded): How to open a .dxf file
- Tool: DXF viewer
If you measure the same thing in Firefox, or in Chromium on another machine, I would like to hear the numbers.
This article was written with the help of AI (Claude). The code and details come from the actual implementation and were checked before publishing.


