Skip to main content

Building the transfer manager

What you’ll build: six simulated downloads running in a worker, with live progress bars — where the worker writes progress far more often than the UI draws it.

The idea to take away: when a producer updates faster than the UI can render, sample the value instead of subscribing to it.

Files: example/src/workers/downloads.ts · example/src/screens/DownloadsScreen.tsx

The transfer manager screen with six progress bars, some finished and some downloading, and a cost line reading 'shared writes by the worker: 1041, frames rendered here: 142'
The finished screen. The cost line at the bottom is the lesson: the worker wrote 1,041 times while the UI drew 142 frames, and no callback was fired for the difference.

Step 1: recognise the access pattern

Six transfers × ~120 updates/sec is ~750 writes a second. The screen redraws 60 times a second at best. Three ways to connect them:

ApproachCost
postMessage per update750 messages/sec, each a clone + thread hop
SharedValue.subscribe750 callbacks/sec on the JS thread, 690 of them wasted
Read the cell when rendering60 reads/sec, each a synchronous native call

The third is what you want. The intermediate updates are not dropped — they happened, and the value you read is current. You simply never had to be told.

Step 2: keep the cells numeric

// workers/downloads.ts
const IDLE = 0, RUNNING = 1, PAUSED = 2, DONE = 3, CANCELLED = 4;

progress: new SharedValue(`${ns}:${i}:p`, 0), // 0..1000 permille
state: new SharedValue(`${ns}:${i}:s`, IDLE),

Numeric SharedValues take a lock-free atomic path. Anything else goes through the structured-clone codec under a lock. So progress is an integer in permille rather than a float fraction, and state is a code rather than a string — both deliberate.

If you find yourself storing { progress, state, name } in one cell, you have left the fast path. Use separate numeric cells, or a SharedStore.

Step 3: let the worker own the names, the host open them

The worker creates the cells and tells the host what they are called:

create(namespace: string, count: number) {
ns = namespace;
for (let i = 0; i < count; i++) {
transfers.push({
progress: new SharedValue(`${ns}:${i}:p`, 0),
state: new SharedValue(`${ns}:${i}:s`, IDLE),
rate: 2 + ((i * 7) % 9),
});
}
return transfers.map((t) => ({
id: t.id, progress: `${ns}:${t.id}:p`, state: `${ns}:${t.id}:s`,
}));
}
// host — opens the SAME cells by name. Nothing is copied.
const created = await w.module('downloads').create(ns, COUNT);
cellsRef.current = created.map((c) => ({
progress: new SharedValue(c.progress, 0),
state: new SharedValue(c.state, 0),
}));

Names are the only thing that crosses. Note the host generates the namespace ('dl-' + Date.now().toString(36)) so a reload cannot inherit the previous run's progress.

Step 4: write from the worker's own clock

function tick() {
for (const t of transfers) {
if (t.state.value !== RUNNING) continue;
t.progress.value = Math.min(1000, t.progress.value + t.rate);
writes++;
if (t.progress.value >= 1000) {
t.state.value = DONE;
app.module('app').onFinished(t.id); // rare → worth a real call
}
}
}
timer = setInterval(tick, 8);

A write is a plain assignment. No await, no message, no serialisation — and no listener dispatch at all unless somebody actually subscribed to that cell.

Step 5: sample from the render loop

loop = setInterval(() => {
setRows(cellsRef.current.map((c) => ({
progress: c.progress.value,
state: c.state.value,
})));
}, 16);

That's the whole read side. Each .value is a synchronous read of native memory on the JS thread.

The screen displays both counters, so the divergence is visible rather than claimed. One measured run on an iOS simulator: 1389 shared writes against 581 rendered frames. The exact ratio depends on device speed — what matters is that the first number climbs faster than the second.

Step 6: use calls for the things that are rare

Progress is sampled; completion is called:

app.module('app').onFinished(t.id);

Six calls across the whole run, each carrying meaning the UI must not miss. The split — sample the firehose, call the events — is the design.

The same applies to the write counter shown on screen: it is not shared state, so the host fetches it with stats() a few times a second rather than every frame.

Step 7: release the cells

reset() {
for (const t of transfers) {
SharedValue.delete(`${ns}:${t.id}:p`);
SharedValue.delete(`${ns}:${t.id}:s`);
}
}

Twelve dynamically named cells per mount. Without cleanup, every visit to this screen leaks twelve more names for the life of the process. This is exactly the case delete() exists for.

(They are reference-counted too, so they would be freed once every handle is dropped — delete() makes it immediate and deterministic.)

What to take away

  • Sample high-frequency values; subscribe to rare ones.
  • Keep hot cells numeric to stay on the atomic path.
  • Pass names between runtimes, never data.
  • Dynamically named cells need deleting; fixed global ones don't.

Try it

cd example && RN_WORKERS_SCREEN=downloads yarn start --reset-cache

Change the worker's setInterval(tick, 8) to 1 and watch the write count pull further ahead while the frame count stays put — the UI cost does not change.

Next: Image filters (SharedBuffer)