Integrity & resume
A file arrives over an RTCDataChannel. The progress bar reaches 100%. The bytes assemble into a Blob. The receiver saves it.
Then the file is corrupt. Or truncated. Or the connection dropped at 80% and the whole transfer has to start over.
This page covers two distinct problems that the data channel alone does not solve.
- Integrity. Are the received bytes exactly the bytes the sender intended? A reliable channel guarantees delivery, not correctness of the source.
- Resume. When the connection fails mid-transfer, can you continue from where you stopped instead of restarting from byte zero?
Both problems live above the WebRTC layer. WebRTC moves bytes; it does not check that the bytes mean what you think, and it does not remember anything once a connection dies. You solve both in application code, with a hash function and a small acknowledgement protocol that you build on top of the channel.
This builds on chunking and pairs with sending large files. The resume logic depends on the channel's reliability mode (see messaging reliability).
The constraints of this stack shape the design. Transfers are pure peer-to-peer over RTCDataChannel. Signaling may pass through a server, but no byte of file data ever does. Peering is STUN-only, with no TURN relay to fall back on, so a network change can drop the path with no second route. That makes resume not a nicety but the only recovery you have.
What the data channel already gives you
Before adding anything, know what you start with. An RTCDataChannel in its default mode is reliable and ordered. It runs over SCTP, which runs over DTLS, which runs over the ICE-negotiated UDP path. Each layer adds something.
- SCTP provides reliability and ordering. It assigns sequence numbers, retransmits lost segments, and reorders out-of-order arrivals before handing them to your
onmessagehandler. In the default configuration it behaves like TCP: no loss, no duplication, strict order. - DTLS provides confidentiality and integrity in transit. It encrypts every segment and authenticates it. An attacker on the wire cannot read the bytes or flip them undetected. DTLS drops a tampered segment, and SCTP treats that as a loss and retransmits.
- ICE provides the path. It found a route between the two peers through NATs using STUN.
That gives you four guarantees inside a single live connection:
| Property | Guaranteed by reliable+ordered channel? | By which layer |
|---|---|---|
| Every byte sent is delivered | Yes, losses are retransmitted | SCTP |
| Bytes arrive in send order | Yes, reordered before delivery | SCTP |
| No byte is delivered twice | Yes, duplicates are discarded | SCTP |
| Bytes are not readable or alterable on the wire | Yes, encrypted and authenticated | DTLS |
These are strong guarantees. They are also narrower than they look. Each one is scoped to the transport, during one connection. None of them says anything about the two places where file transfers actually go wrong: the ends, and time.
What it does not guarantee
The source bytes are correct. The channel delivers what you hand it. If your slicing code reads the wrong offset, holds a stale File reference, or has an off-by-one at the chunk boundary, you send a valid stream of wrong bytes. SCTP delivers your mistake in perfect order with no loss. DTLS authenticates it. The receiver gets exactly the bytes you sent, which are not the bytes the file holds.
The reassembly is correct. The receiver gets a sequence of messages and rebuilds a file from them. The channel guarantees the messages arrive in order, but it knows nothing about your reassembly logic. Append chunks to the wrong array index, mishandle the final short chunk, concatenate a JSON header into the binary buffer, and the assembled file differs from the source. The channel never saw the assembled file. It saw individual messages, each delivered faithfully.
Anything survives a dropped connection. SCTP reliability is scoped to one association. When the peer connection enters failed, or the channel fires close, the association is gone. Every retransmission timer, every in-flight segment, every ordering guarantee evaporates. There is no built-in concept of "continue where you left off." A new connection is a blank slate. WebRTC has no resume.
Content authenticity over time. DTLS authenticates that the bytes came from the peer you handshook with, in transit, right now. It does not give you a durable fingerprint you can re-check later, compare against what was actually stored, or use to confirm a resumed transfer matches the original file.
A reliable channel means "the bytes I sent arrived in order." It does not mean "the file you saved equals the file I picked." Integrity checks close the first gap. Resume closes the second.
Everything below assumes the default reliable, ordered channel. If you trade reliability for throughput (maxRetransmits: 0, or ordered: false), you give up the in-order and no-loss guarantees, and your application layer must then handle gaps and reordering itself. Integrity hashing still works (a hash either matches or it does not), but in an unreliable mode a single whole-file hash mismatch tells you the file is wrong without telling you which bytes are missing. Per-chunk hashing and sequence numbers become mandatory rather than optional. See messaging reliability.
Verifying content with a hash
A cryptographic hash maps any byte sequence to a fixed-size digest. The mapping is deterministic: the same input always yields the same digest. It is also avalanche-sensitive: change one bit of input and roughly half the output bits flip, unpredictably. And it is collision-resistant: finding two different inputs with the same SHA-256 digest is not something you achieve or observe by accident.
Those three properties make a digest a fingerprint. If the sender's digest equals the receiver's digest, the files are equal. If they differ, the files differ: somewhere, somehow, the bytes are not the same.
The browser exposes hashing through the Web Crypto API: crypto.subtle.digest(algorithm, data). It takes an algorithm name and an ArrayBuffer (or typed-array view) and returns a promise of an ArrayBuffer holding the raw digest.
// SHA-256 of any buffer, as a lowercase hex string.
// Hex is convenient for comparison, logging, and using as a storage key.
async function sha256Hex(buffer) {
const digest = await crypto.subtle.digest('SHA-256', buffer);
return [...new Uint8Array(digest)]
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
SHA-256 produces a 32-byte digest, 64 hex characters. It is the right default: available in every browser's crypto.subtle, fast, and with no realistic collision risk for file verification. SHA-1 is faster but its collisions are demonstrated; do not use it for integrity. SHA-512 is also available and slightly faster on 64-bit hardware for large inputs, at the cost of a longer digest. SHA-256 is the safe choice unless you have a specific reason.
crypto.subtle requires a secure context. That means HTTPS, or localhost during development. A page served over plain HTTP has no crypto.subtle, so verify your dev server matches.
Hashing the whole file
The sender computes the digest of the entire file before (or while) sending, transmits it with the transfer metadata, and the receiver computes the digest of what it assembled and compares.
// Sender: announce the file before the first chunk.
async function announceFile(channel, file) {
const buffer = await file.arrayBuffer();
const hash = await sha256Hex(buffer);
channel.send(JSON.stringify({
t: 'file-meta',
name: file.name,
size: file.size, // total bytes, used to detect truncation
hash, // SHA-256 of the whole file, hex
}));
return { buffer, hash };
}
// Receiver: after the last chunk, assemble and verify.
async function verifyReceived(chunks, meta) {
const blob = new Blob(chunks);
const buffer = await blob.arrayBuffer();
// 1. Length check catches truncation cheaply, before hashing.
if (buffer.byteLength !== meta.size) {
return { ok: false, reason: 'size', got: buffer.byteLength, want: meta.size };
}
// 2. Hash check catches any content difference.
const hash = await sha256Hex(buffer);
if (hash !== meta.hash) {
return { ok: false, reason: 'hash', got: hash, want: meta.hash };
}
return { ok: true, blob };
}
The size check runs first because it is cheap and catches the most common failure: a transfer that ended early. Comparing two integers is free; hashing is O(n) over every byte. Skip the expensive check when the cheap one already disqualifies the result.
Comparing digests is a plain string comparison once both are hex. Length and content authenticity are answered in two lines.
Why a whole-file hash at the end is not enough
A whole-file hash answers exactly one question: is the final, complete file correct? It has two limits that matter for real transfers.
It tells you nothing about where the failure is. A mismatch means some byte, somewhere in a multi-megabyte file, is wrong. The digest does not point at it. You re-send the entire file and hope the second attempt is clean.
It requires the whole file before it produces any answer. For a transfer that fails at 90%, the whole-file hash is unreachable: you never receive the last 10%, so you never compute the digest, so you learn nothing about the 90% you did receive. That 90% might be perfect and reusable, but a whole-file hash cannot confirm it.
Both limits push toward hashing smaller units.
Per-chunk hashing
Hash each chunk independently. The sender sends a digest with (or before) each chunk; the receiver verifies each chunk as it arrives.
// Sender: a JSON header, then the raw bytes, per slice.
async function sendChunk(channel, blobSlice, index, offset) {
const buffer = await blobSlice.arrayBuffer();
const hash = await sha256Hex(buffer);
channel.send(JSON.stringify({
t: 'chunk-meta',
index, // sequence number of this chunk
offset, // byte offset of this chunk in the file
length: buffer.byteLength,
hash,
}));
channel.send(buffer); // the raw bytes follow the header
}
// Receiver: pair each header with the next binary message, then verify.
let pendingMeta = null;
async function onMessage(data) {
if (typeof data === 'string') {
pendingMeta = JSON.parse(data);
return;
}
// data is an ArrayBuffer: the bytes for pendingMeta.
const meta = pendingMeta;
pendingMeta = null;
const hash = await sha256Hex(data);
if (hash !== meta.hash) {
// This single chunk is wrong. Ask for just this index again.
requestResend(meta.index);
return;
}
storeChunk(meta.index, meta.offset, data);
acknowledge(meta.index);
}
Pairing a string header with the following binary message relies on the channel's ordering guarantee: the header always arrives immediately before its bytes. On an unordered channel this pairing breaks and you must put the index inside the binary frame instead. That dependency is the reliability-mode link again.
Per-chunk hashing costs more (one digest per chunk instead of one per file), but it buys two things a whole-file hash cannot:
- Locality. A failed chunk identifies itself. Re-send only that index, not the whole file.
- Progressive verification. Each chunk is confirmed the moment it lands, so the receiver can persist verified progress as it goes. That is what makes resume practical.
The tradeoff in one view:
| Approach | Detects corruption | Locates the bad bytes | Verifies progressively | Enables resume | Hash cost |
|---|---|---|---|---|---|
| Whole-file hash at end | Yes | No | No | No | One digest |
| Per-chunk hash | Yes | Per chunk | Yes | Yes | One digest per chunk |
| Both | Yes | Per chunk, plus final check | Yes | Yes | Per chunk + one final |
Using both is the strongest option and the small extra cost is usually worth it: per-chunk hashes during transfer for locality and resume, plus one whole-file hash at the end as a check on your own reassembly. The final hash catches reassembly bugs that per-chunk hashes cannot: stitch correct chunks together in the wrong order and every chunk passes while the file is wrong.
Hashing incrementally as chunks arrive
crypto.subtle.digest is one-shot: it hashes a complete buffer and has no streaming or update-style API. To compute a whole-file digest without holding the entire file in memory at once, you have two practical options.
Concatenate per chunk and rely on per-chunk hashes for in-flight checks, then hash the assembled Blob once at the end. The Blob holds its data in the browser's storage layer, not necessarily in the JS heap, so blob.arrayBuffer() at the end reads it back in one pass. This is simple and works for moderate files.
Maintain a running digest with a streaming hasher. The Web Crypto one-shot API cannot do this, so a running digest means a hand-written hash implementation that supports update() and digest(). Given the no-dependency rule of this stack, a streaming SHA-256 means writing it yourself, a real cost. For most transfers, per-chunk Web Crypto digests plus a final one-shot whole-file digest avoid that work entirely.
// Incremental approach using only Web Crypto: hash each chunk on arrival,
// keep the digests, and verify the final file with one whole-file digest.
const chunkHashes = []; // parallel to chunk indices
async function onVerifiedChunk(index, buffer) {
chunkHashes[index] = await sha256Hex(buffer); // per-chunk integrity
storeChunk(index, buffer);
}
// At the end, one whole-file digest over the reassembled Blob.
async function finalVerify(chunks, meta) {
const buffer = await new Blob(chunks).arrayBuffer();
return (await sha256Hex(buffer)) === meta.hash;
}
Performance and Web Worker offload
Hashing is CPU work proportional to file size. crypto.subtle.digest runs on the calling thread. For small chunks the cost is invisible. For a large whole-file hash on the main thread, the call blocks long enough to stutter rendering and input.
Two ways to keep the main thread responsive:
- Hash per chunk, not whole-file, during transfer. Each chunk is small (typically 16 KB to 256 KB), so each digest is quick and yields control between chunks. Spread across the transfer, the cost never lands all at once.
- Offload to a Web Worker for the final whole-file hash.
crypto.subtleis available in worker scope. Post theArrayBufferto a worker (transfer it, do not copy), hash it there, post the hex digest back. The main thread stays free.
// worker: hash off the main thread.
self.onmessage = async (e) => {
const digest = await crypto.subtle.digest('SHA-256', e.data);
const hex = [...new Uint8Array(digest)]
.map((b) => b.toString(16).padStart(2, '0')).join('');
self.postMessage(hex);
};
// main: transfer the buffer so it is moved, not copied.
function hashInWorker(worker, buffer) {
return new Promise((resolve) => {
worker.onmessage = (e) => resolve(e.data);
worker.postMessage(buffer, [buffer]); // [buffer] = transfer list
});
}
Transferring the buffer (the second argument to postMessage) moves ownership to the worker with no copy. After the transfer the buffer is detached on the main thread, so only do this when the main thread no longer needs it.
Cost comparison at a glance:
| Hashing strategy | Main-thread blocking | Memory | Best for |
|---|---|---|---|
| Whole-file on main thread | High, one large stall | Whole file in memory | Small files |
| Per-chunk on main thread | Low, spread out | One chunk at a time | Most transfers |
| Whole-file in a worker | None on main thread | Whole file, in worker | Large final check |
Detecting truncation
Truncation is the case where the receiver got fewer bytes than the file holds. It happens when the connection drops, when the sender's read stream ends early, or when a bug stops the send loop.
The announced size is the detector. Compare total received bytes to it:
function totalReceived(chunks) {
return chunks.reduce((sum, c) => sum + c.byteLength, 0);
}
if (totalReceived(chunks) < meta.size) {
// Incomplete. Do not present this as a finished file.
// This is the trigger for resume, not an error to discard.
}
Truncation is not corruption. Corrupt bytes fail the hash. Truncated bytes are usually correct as far as they go; the file is just unfinished. A truncated transfer that stopped at a clean chunk boundary has a perfectly valid prefix.
That distinction drives the recovery. Treat truncation as the entry point to resume, not as a reason to throw the partial data away. The verified prefix is exactly what resume continues from.
A length check also catches the opposite, rarer bug: receiving more bytes than size, which means a chunk was duplicated into the buffer or a length field was wrong. Both an under-count and an over-count are failures; only an exact match is a complete file.
Acknowledgements
To resume, the sender needs to know what the receiver actually has. The receiver tells it with acknowledgements (ACKs) sent back over the same data channel.
An ACK is a small control message. It carries no file bytes, only a position: an index, an offset, or a range. The sender uses ACKs to track durable progress and to bound how far ahead it runs.
Note the role here. On a reliable channel, SCTP already confirms delivery; you do not need ACKs to know a chunk arrived. You need ACKs to know a chunk was verified and persisted by your application, a different fact, and the only one that makes resume safe. The ACK is your durable checkpoint, not a delivery receipt.
Sequence numbers
Every chunk carries a sequence number, the index field above. The index is the spine of the whole protocol:
- It pairs a chunk with its byte offset (
offset = index * chunkSize, for fixed chunk sizes). - It lets the receiver detect gaps (a missing index in the sequence).
- It lets the sender re-send a specific chunk on request.
- It lets the receiver dedup re-sends (an index it already holds).
Keep indices dense and zero-based. The last chunk may be shorter than the rest, but it still gets the next index in sequence.
Per-chunk ACK
The simplest scheme: ACK every chunk by index after storing it.
function acknowledge(index) {
channel.send(JSON.stringify({ t: 'ack', index }));
}
This works but is chatty: one ACK per chunk doubles the message count. If the sender waits for each ACK before sending the next chunk, every chunk also pays a full round-trip of latency, which collapses throughput. Per-chunk stop-and-wait is fine for correctness and bad for speed.
Windowed, cumulative ACK
Better: let the sender push several chunks ahead of the latest ACK, up to a window. The receiver ACKs the highest contiguous index it has stored, a cumulative ACK. One message confirms everything up to that point.
// Receiver: track the highest contiguous index stored.
// Chunks may be processed slightly out of order; ack only the solid prefix.
let highestContiguous = -1;
const have = new Set();
function onChunkStored(index) {
have.add(index);
while (have.has(highestContiguous + 1)) {
highestContiguous += 1;
}
// Cumulative ACK: "I have everything through highestContiguous."
channel.send(JSON.stringify({ t: 'ack', through: highestContiguous }));
}
// Sender: keep up to `window` chunks in flight beyond the last ACK.
const WINDOW = 8;
let lastAcked = -1; // highest index the receiver confirmed
let nextToSend = 0; // next index to push
function onAck(msg) {
lastAcked = Math.max(lastAcked, msg.through);
pump();
}
async function pump() {
while (nextToSend - lastAcked <= WINDOW && nextToSend < totalChunks) {
await sendChunk(channel, sliceAt(nextToSend), nextToSend, offsetOf(nextToSend));
nextToSend += 1;
}
}
The window bounds how far ahead the sender runs without confirmation. A cumulative ACK is compact and self-healing: a lost ACK is harmless because the next ACK supersedes it (through: 12 makes any earlier through: 8 irrelevant).
Two limits act at once here, and they are independent:
- The ACK window bounds unconfirmed chunks: how much progress could be lost on a disconnect.
bufferedAmountbackpressure bounds unsent chunks queued in the channel's own buffer (covered in large files).
Respect both. A large ACK window with no buffer backpressure floods the channel's send queue; tight backpressure with a tiny window starves throughput. Tune the window against the buffer threshold together.
Detecting gaps and retransmit-on-NACK
On a reliable channel, gaps in the stored sequence come from your application logic (a chunk that failed its hash and was dropped), not from the transport. The receiver detects a gap when it stores an index past highestContiguous + 1: there is a hole below it.
A NACK (negative acknowledgement) names the missing index and asks for it again:
// Receiver: a chunk arrived past the contiguous prefix. There is a hole.
function onChunkStored(index) {
have.add(index);
// Find the holes below the new index and ask for each.
for (let i = highestContiguous + 1; i < index; i++) {
if (!have.has(i)) {
channel.send(JSON.stringify({ t: 'nack', index: i })); // ask for it
}
}
while (have.has(highestContiguous + 1)) highestContiguous += 1;
channel.send(JSON.stringify({ t: 'ack', through: highestContiguous }));
}
// Sender: re-send exactly the named chunk.
function onNack(msg) {
const i = msg.index;
sendChunk(channel, sliceAt(i), i, offsetOf(i));
}
NACK plus per-chunk hashing forms a targeted repair loop: a chunk that fails its hash is dropped, the gap is detected, a NACK is sent, the sender re-slices and re-sends only that index. The rest of the transfer never pauses.
Guard against a NACK storm: the same hole NACKed on every later chunk arrival. Track which indices are already NACKed and outstanding, and re-NACK only after a timeout:
const nacked = new Map(); // index -> timestamp last NACKed
const NACK_COOLDOWN = 1000; // ms before re-asking
function maybeNack(i) {
const now = Date.now();
const last = nacked.get(i) ?? 0;
if (now - last < NACK_COOLDOWN) return; // already outstanding
nacked.set(i, now);
channel.send(JSON.stringify({ t: 'nack', index: i }));
}
The protocol's control messages, in summary:
| Message | Direction | Carries | Meaning |
|---|---|---|---|
file-meta |
sender → receiver | name, size, hash | Start of a transfer; the integrity target |
chunk-meta |
sender → receiver | index, offset, length, hash | Header for the chunk bytes that follow |
ack |
receiver → sender | through | "Verified and stored everything up to here" |
nack |
receiver → sender | index | "Index is missing; re-send it" |
resume |
receiver → sender | hash, offset | "Continue from this byte after reconnect" |
Resume
Resume turns a failed transfer into a continued one. The mechanism: the receiver persists how much it has, and after reconnecting, the sender re-slices the file from that offset.
Track the received offset
The received offset is the number of contiguous, verified bytes the receiver holds. Compute it from the highest contiguous chunk:
// The resume point: total verified contiguous bytes.
function receivedOffset(chunks) {
let offset = 0;
for (let i = 0; i <= highestContiguous; i++) {
offset += chunks[i].byteLength; // last chunk may be shorter
}
return offset;
}
Only count the contiguous prefix. A chunk received past a gap cannot extend the resume point: the sender will re-send from the gap, and counting the stray chunk would skip the missing bytes between. The resume offset and the cumulative ACK are two views of the same fact: the solid prefix.
Persist progress so a reload survives
In-memory state dies with the page. A reload, a crash, or the user closing the tab wipes the chunk array and every counter. Resume across those events requires durable storage. IndexedDB holds both the offset and the partial bytes; it is the only browser store sized for multi-megabyte binary blobs.
// Persist the partial transfer, keyed by the file's whole-file hash so the
// same file resumes even across a full page reload.
async function persistProgress(db, fileHash, offset, chunks) {
const tx = db.transaction('transfers', 'readwrite');
tx.objectStore('transfers').put({
hash: fileHash, // stable identity of the file
offset, // verified bytes so far
blob: new Blob(chunks.slice(0, highestContiguous + 1)),
}, fileHash);
return tx.complete;
}
async function loadProgress(db, fileHash) {
const tx = db.transaction('transfers', 'readonly');
const record = await tx.objectStore('transfers').get(fileHash);
return record ?? { offset: 0, blob: new Blob() };
}
Key the record by the file's whole-file hash, not its name. Two files named report.pdf are different transfers; the same file resumed after a reload is the same transfer. Only the hash gives a stable identity across both cases.
Persist after each ACK, or batch persists every N chunks to limit IndexedDB writes. The ACK cadence and the persist cadence can differ: ACK often (cheap, just a message), persist less often (a disk write), accepting that a crash loses at most the un-persisted tail. That loss is harmless: resume re-sends it anyway, and idempotent storage absorbs the overlap.
Store the partial bytes as a Blob, not an array of ArrayBuffers. IndexedDB stores Blobs by reference to the browser's blob store, so writing one does not serialize every byte into the database on each call. Appending to a Blob with new Blob([oldBlob, newChunk]) is cheap.
| State to persist | Why | Lost if not persisted |
|---|---|---|
| Received offset | The resume point | Restart from zero |
| Partial bytes (Blob) | The verified prefix to keep | Re-download everything |
| File hash | Match a resumed file to its record | Cannot identify the transfer |
| File size | Detect completion after resume | Cannot tell when done |
Restart from the offset after reconnection
After the connection fails, a new one is built through the signaling path: a fresh RTCPeerConnection, a new handshake, a new data channel. The old association is gone; the persisted offset is the only thing that carried over.
The receiver opens the resume handshake by announcing where to continue:
// Receiver, on the reconnected channel: announce the resume point.
async function requestResume(channel, fileHash, db) {
const meta = await loadProgress(db, fileHash);
channel.send(JSON.stringify({
t: 'resume',
hash: fileHash,
offset: meta.offset, // "send me everything from this byte on"
}));
}
The sender re-slices the original File from that offset:
// Sender, on a resume request: re-slice from the offset and continue.
const CHUNK_SIZE = 64 * 1024;
function onResume(msg, file, fileHash) {
if (msg.hash !== fileHash) {
// Different file than the one being resumed: restart cleanly.
return startFresh(file);
}
let pos = msg.offset; // start where the receiver stopped
while (pos < file.size) {
const end = Math.min(pos + CHUNK_SIZE, file.size);
sendChunk(channel, file.slice(pos, end), pos / CHUNK_SIZE, pos);
pos = end;
}
}
File.slice(start, end) is the resume primitive on the sender. It produces a Blob view over the file's bytes from start to end without reading them into memory until you call .arrayBuffer(). The original File object stays valid across a reconnection as long as the sender's page is not reloaded: the user does not re-pick the file, and the browser reads from the same file on disk. Slicing from offset yields exactly the bytes the receiver still needs.
If the sender's page did reload, the File reference is gone and the user must re-select the file. The whole-file hash then confirms it is the same file before resuming; a mismatch means a different file, and the transfer restarts from zero.
The full resume handshake
- Sender announces
{ name, size, hash }. - Sender pushes chunks within the window. The receiver verifies each chunk, stores it, ACKs the contiguous prefix, and persists the offset and partial blob to
IndexedDB. - The connection fails:
connectionState === 'failed', or the channel'scloseevent fires. - Both sides halt. The receiver's persisted offset is the durable checkpoint; everything below it is verified and kept.
- A new connection is established through signaling: new
RTCPeerConnection, new handshake, new channel. - The receiver loads its progress from
IndexedDBand sends{ t: 'resume', hash, offset }. - The sender confirms the hash identifies the same file, re-slices the original
Filefromoffset, and continues from there. - After the last chunk, the receiver runs the whole-file hash check against the original
file-meta.hash. A match means the resumed file is byte-identical to the source.
Handling the connection or channel closing mid-transfer
A transfer can stop for several reasons. Detect each one and route them all to the same recovery path: halt, keep the verified prefix, reconnect, resume.
The channel closes
The RTCDataChannel fires close when it shuts down and error on failure:
channel.addEventListener('close', () => {
// Stop sending. The receiver's persisted offset is the resume point.
haltTransfer();
});
channel.addEventListener('error', () => {
// SCTP-level failure. Treat like a close: halt, prepare to resume.
haltTransfer();
});
The peer connection fails
ICE can lose connectivity while the channel object still exists. Watch the connection state directly:
pc.addEventListener('connectionstatechange', () => {
const s = pc.connectionState;
if (s === 'failed') {
haltTransfer();
reconnectAndResume(); // new pc, new handshake, then resume
} else if (s === 'disconnected') {
haltTransfer();
waitForRecovery(); // ICE may restore the path on its own
}
});
disconnected and failed are different signals:
| State | Meaning | Recovers on its own | Action |
|---|---|---|---|
disconnected |
Path lost, may come back | Sometimes (ICE re-checks) | Pause, grace period |
failed |
Path gone, will not recover | No | Reconnect and resume |
Give disconnected a short grace period before tearing down: a brief Wi-Fi blip or NAT rebind often resolves without a full reconnect, and a needless reconnect throws away a recoverable path. Treat failed as the definitive signal to build a new connection.
With STUN-only peering and no TURN relay, a network change (Wi-Fi to cellular, a NAT timeout, a roaming address) can drop the path with no fallback route. Resume is the recovery. The bytes already verified stay in the receiver's IndexedDB, a fresh handshake establishes a new path, and the transfer continues from the saved offset. The dropped path costs nothing already transferred.
Track the last acked offset on the sender
The sender keeps the highest acknowledged offset, because that is what it will be asked to resume from, and what bounds how much progress a failure can cost.
let lastAckedOffset = 0;
function onAck(msg) {
lastAcked = Math.max(lastAcked, msg.through);
lastAckedOffset = offsetOf(lastAcked) + lengthOf(lastAcked);
}
Everything above lastAckedOffset is unconfirmed. On a failure, treat all of it as potentially lost and let the receiver's resume offset decide what to actually re-send. The sender never assumes a chunk landed just because it was sent; only an ACK makes it durable. This also means the maximum re-sent work on any single failure is bounded by the ACK window: pick a window small enough that re-sending it is cheap.
Idempotent re-send and dedup
Resume and NACK both re-send chunks. Some of those re-sends are redundant: the receiver already has the chunk. This happens whenever an ACK was sent but the connection died before the sender processed it, so the sender's lastAcked lags the receiver's real state and the sender re-sends a chunk already stored.
Make every re-send harmless. The receiver keys stored chunks by index (or offset) and ignores any chunk it already holds:
function storeChunk(index, offset, buffer) {
if (have.has(index)) {
return; // already stored; re-send is a no-op
}
chunks[index] = buffer;
have.add(index);
onChunkStored(index); // advance the contiguous prefix, ack
}
Idempotent storage means the resume offset can be conservative. Re-sending a few already-received chunks wastes a little bandwidth but never corrupts the result. The asymmetry is the key safety property:
- An offset slightly behind the truth re-sends bytes the receiver already has: wasted bandwidth, correct file.
- An offset ahead of the truth skips bytes the receiver is missing: a permanently broken file.
When in doubt, resume from earlier. Round the resume offset down to a chunk boundary so a partial chunk is always re-sent whole rather than stitched onto a fragment. A conservative, boundary-aligned offset plus idempotent storage makes resume correct under every ordering of lost ACKs and re-sends.
| Hazard | Cause | Guard |
|---|---|---|
| Duplicate chunk in buffer | ACK lost; sender re-sends | have set rejects known indices |
| Resume skips bytes | Offset ahead of stored prefix | Count only the contiguous prefix |
| Partial chunk stitched | Resume offset mid-chunk | Round offset down to a boundary |
| Re-sent bad bytes overwrite good | Non-idempotent overwrite | Store-once; never overwrite a held index |
Recap
A reliable ordered data channel delivers your bytes in order, without loss or duplication, encrypted and authenticated in transit, inside one connection. It does not prove the bytes match the sender's intent, and it does not survive a dropped connection. Those two gaps are yours to close.
- Integrity comes from hashing. Compute SHA-256 with
crypto.subtle.digest('SHA-256', buffer). Compare the receiver's digest to a sender-announced digest. Check received length against the announcedsizeto catch truncation before paying for a hash. - Whole-file vs per-chunk. Whole-file hashing confirms the final result but locates nothing and needs the complete file. Per-chunk hashing locates failures and verifies progressively, which is what makes resume practical. Use both: per-chunk during transfer, one whole-file check at the end to catch reassembly bugs.
- Performance. Hash per chunk to spread the cost, and offload the final whole-file hash to a Web Worker with a transferred buffer to keep the main thread responsive.
- Acknowledgements tell the sender what the receiver has verified and persisted, not merely delivered. A cumulative windowed ACK confirms a contiguous prefix with one message; NACK plus per-chunk hashing repairs a single bad chunk without pausing the rest.
- Resume persists the contiguous received offset and partial bytes to
IndexedDB, keyed by file hash, and after reconnection has the sender re-slice the originalFilefrom that offset withFile.slice. - Closing mid-transfer surfaces through the channel's
close/errorevents and the connection'sfailed/disconnectedstates. Reconnect onfailed; givedisconnecteda grace period. STUN-only peering makes resume the only recovery from a dropped path. - Idempotent re-send lets resume be conservative. Re-sending a stored chunk is a no-op, so an offset slightly behind the truth is always safe; an offset ahead is never safe.
Going further
- Chunking: splitting a file and pairing binary chunks with their headers.
- Sending large files: backpressure on
bufferedAmount, streaming reads, and memory limits. - Messaging reliability: ordered vs unordered, reliable vs partial reliability, and how the mode changes what your application layer must handle. The whole integrity-and-resume design above assumes the default reliable, ordered mode; this is where that assumption is set.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Hash mismatch on a complete file | Chunks stored out of arrival order, or a wrong File slice |
Store by index/offset, not arrival order; verify per-chunk hashes |
| Size check passes but hash fails | A chunk's bytes were replaced, not lost: a source bug | Per-chunk hashing to find the bad index |
| Whole-file hash passes, file still wrong | Chunks reassembled in the wrong order | Reassemble by index; the per-chunk pass cannot catch ordering |
crypto.subtle is undefined |
Page served over plain HTTP | Serve over HTTPS or localhost; crypto.subtle needs a secure context |
| Resume skips bytes / file is short | Offset counted a chunk past a gap | Count only the contiguous prefix |
| Partial chunk corrupts the resume | Resume offset landed mid-chunk | Round the offset down to a chunk boundary |
| Resume re-sends from zero after reload | Sender's File reference lost on reload |
Re-prompt for the file; confirm identity with the whole-file hash |
Transfer never resumes after disconnected |
Tore down too early on a recoverable blip | Give disconnected a grace period; reconnect only on failed |
| Duplicate chunks corrupt the buffer | Non-idempotent storage on re-send | Guard storeChunk with a have set; store once, never overwrite |
| NACK storm floods the channel | Same hole re-NACKed on every later arrival | Track outstanding NACKs; re-ask only after a cooldown |
| Main thread stutters during transfer | Whole-file hash on the main thread | Hash per chunk; offload the final hash to a Web Worker |
IndexedDB writes stall the transfer |
Persisting on every chunk | Persist every N chunks; ACK more often than you persist |
| Throughput collapses to a crawl | Sender waits for an ACK per chunk | Use a windowed cumulative ACK, not per-chunk stop-and-wait |
| Header bytes appear inside a chunk | String/binary pairing broke on an unordered channel | Use the default ordered mode, or embed the index in the binary frame |