“Electron, so it is slow” is the first objection any Electron file manager meets, and it is a fair one to raise, because the obvious architecture is slow. This is what cwdio does instead, retold from ADR 0015, the decision that fixed the data path before the remote and archive providers were built on top of it.
The trap
Electron’s inter-process messaging copies its payloads. Structured clone across one boundary costs on the
order of 12 ms per 256 KB and scales linearly with size — so a multi-gigabyte file pushed through IPC is
not slow, it is catastrophic. The invoke/send channels do not take transferables at all, and the
cross-process ArrayBuffer transfer that postMessage nominally offers has documented payload-loss and
crash bugs. A file manager whose renderer reads a file and hands the bytes to the main process to write
would pay that cost twice, once per hop, plus main-thread CPU for the privilege.
Two more traps sit next to it. Reading a whole file into memory before writing it uses roughly seventeen times the memory of a backpressured stream on a large file. And a copy engine that emits a progress event per chunk fires thousands of IPC messages for one big file — each cheap, together a storm.
The rule
Bytes stay in the host. All filesystem providers — local, SFTP/FTP, archive — live in one services
process (an Electron utilityProcess), co-located with the provider registry and the operation queue.
When you press F5, the renderer sends a request the size of a tweet; the queue in the host resolves
the source and target providers and moves bytes disk-to-disk, or socket-to-disk, inside that one process.
What comes back across IPC is control and progress — never content.
The corollaries in ADR 0015 are what make the rule hold under real workloads:
- Stream, don’t buffer. Providers expose read and write streams; the generic executor pipes them with backpressure and an abort signal. Same-provider fast paths are preferred when both ends share a scheme.
- Coalesce progress in the host. At most one progress event per ~100 ms and per few megabytes, publishing cumulative counters; start, complete, error and cancel are immediate. A multi-gigabyte copy produces hundreds of events, not thousands.
- Move bytes to the renderer only when it truly needs them — a preview, a hex view — and even then over a brokered direct port with chunked, flow-controlled messages, never through the main broker.
- List by attributes. Remote and archive listings are built from the directory’s own metadata (SFTP
readdirattributes, the ZIP central directory), never a stat per entry over the network. - Plugins that provide filesystems load into the same host. A provider running in the command-plugin process would put every read and write back on the wrong side of the boundary.
What the copy engine does with the freedom
Once the bytes are in one process, the engine can be honest about correctness in ways a renderer-mediated copy never could.
Temp, then rename. A streamed copy writes to a sibling temp file (<target>.cwdio-copy-<id>.tmp) and
publishes it with a rename only when the stream completes. A failure mid-stream — a dropped SFTP socket, a
wrong archive password discovered mid-decrypt, a corrupt entry — deletes the temp and leaves the target
untouched. Nothing is ever a partial or zero-byte file where a good one used to be.
Verify (View ▸ Verify copies, off by default) accumulates a CRC-32 over the source bytes as they
stream, reads the temp back, and fails with a typed INTEGRITY error before the rename. The queue carries
verified: true / false / null all the way to the Operations drawer, and honestly reports null when a
target cannot be read back.
Resume treats a vanished drive or a dropped connection as UNAVAILABLE, not as failure. The operation
moves to a paused state, keeps its partial temps, and a watcher polls the endpoints; when they return,
the run continues each partial temp at its byte offset and counts already-copied files as done.
Per-file skip and retry. One locked file in a tree records a failure and its siblings carry on; the drawer shows N failed, what is locking each one, and a retry button. A move keeps its entire source when anything failed.
The fast paths, and their honest trade-off
The engine streams with a 4 MiB high-water mark rather than Node’s 64 KiB default, and files up to 64 MiB
skip the JavaScript stream entirely for the kernel’s own copy call — CopyFileW on Windows — which was
measured ~1.7× faster than a stream on a single file and picks up SMB copy-offload and ReFS block cloning
that user-space code can never get. Above 64 MiB, and always under verify or resume, the stream is used
because progress and mid-file cancel matter more than the last percent.
One trade-off is stated plainly in the reference: a fresh small file inside a freshly created
folder copies straight to its target with no temp — a folder that did not exist a moment ago has nothing to
protect, and skipping the second directory write is what took a 20,000-small-files copy from ~985 to ~1,837
files per second. A hard crash mid-copy can therefore leave a partial new file — the same as Explorer,
robocopy and fs.cp — and a resumed run re-copies it. Overwrites, large files, verified and resumed copies
keep temp-and-rename.
What it deliberately does not do
- It does not raise the libuv thread-pool size; that measured worse for local copies, which are write-bandwidth-bound.
- It does not hand-roll a chunked read/write loop below 64 MiB — that loses the kernel offload.
- It does not put file bytes on the renderer’s plate to draw a progress bar. The renderer gets counters.
The numbers behind this — listing, copy throughput, traversal — are published on the benchmarks page with the environment they were measured on, and regenerated per release. They are an order-of-magnitude answer to “is Electron slow?”, not a boast; the architecture is why they can be that answer.