Implementing AirDrop-Like File Sharing in TypeScript Using WebRTC
Build a cross-device AirDrop-style P2P file transfer in TypeScript using WebRTC, WebBluetooth, and WebCrypto—secure, resumable, and production-ready.
Why build an AirDrop-style P2P file transfer in TypeScript now?
You need reliable, cross-device, peer-to-peer file sharing without shipping heavy native apps — but platform differences, discovery, and security make that hard. By 2026 the web platform has matured: browsers improved WebRTC data channels, WebCrypto offers production-grade cryptography, and experimental WebBluetooth scanning lets you add proximity-aware discovery. At the same time, manufacturers are moving toward cross-platform AirDrop-like experiences (see the Pixel 9 news from Jan 2026), which raises expectations for web-based alternatives.
This article walks you through building an AirDrop-like file transfer app with TypeScript, using WebRTC for P2P transport, WebBluetooth for proximity-aware discovery (with fallbacks), and WebCrypto for encrypted transfers. You’ll get architecture guidance, TypeScript-first code samples, and practical tips to ship a secure, resumable file transfer flow.
What you’ll get
- Architecture pattern for discovery + signaling + P2P transfer
- TypeScript examples for WebBluetooth scanning, WebRTC signaling, DataChannel file streaming
- WebCrypto-based key exchange and AES-GCM encryption for confidentiality and integrity
- Production considerations: STUN/TURN, platform caveats, chunking, resume, and UX
Trends shaping P2P file sharing in 2026
In late 2025 and early 2026 we saw two important trends that affect browser P2P file sharing:
- Platforms and vendors are converging on easier cross-device sharing. For example, media coverage in Jan 2026 highlighted Android vendors adding AirDrop-like behaviors to compete with Apple’s ecosystem (Forbes reported on Pixel 9 developments in Jan 2026).
- Web APIs are gradually adding proximity and native-like capabilities. WebRTC’s DataChannel is robust for binary transfer; WebCrypto supports ECDH and AES-GCM primitives required for secure pairing; WebBluetooth scanning and advertising APIs are more widely available in Chromium-based browsers (with caveats on iOS and some privacy controls).
“Cross-platform AirDrop-style transfers are no longer just native territory — the web can do it too, with secure P2P transports and proximity-aware discovery.”
High-level architecture
The canonical architecture splits responsibilities into four layers. Each layer has clear TypeScript-friendly boundaries:
- Discovery — find nearby devices (WebBluetooth scanning, QR, or local network discovery + ephemeral ID).
- Signaling / Rendezvous — exchange WebRTC offer/answer and ICE candidates (lightweight server or public STUN-signaling hybrid).
- Secure Handshake — use WebCrypto (ECDH + HKDF) to create an ephemeral symmetric key and verify fingerprints out-of-band (e.g., short numeric code over Bluetooth or QR).
- P2P Transfer — WebRTC DataChannel to stream encrypted chunks with integrity checks and resume support.
Discovery: use WebBluetooth + fallbacks
WebBluetooth gives a way to discover physically nearby devices via BLE. In Chrome-family browsers you can use the Web Bluetooth Scanning API (navigator.bluetooth.requestLEScan and advertisement events). Beware: support varies and may require flags or permissions. Always implement fallbacks like QR codes, short codes, or local network discovery.
TypeScript: scanning for a short pairing token
// discovery.ts
export interface NearbyDevice {
id: string; // ephemeral ID
name?: string;
txPower?: number;
}
export async function startBleScan(onDevice: (d: NearbyDevice) => void) {
if (!('bluetooth' in navigator) || !('requestLEScan' in (navigator as any).bluetooth)) {
throw new Error('WebBluetooth scanning not supported - fallback required');
}
const scan = await (navigator as any).bluetooth.requestLEScan({
acceptAllAdvertisements: true
});
const handler = (ev: any) => {
const adv = ev;
const id = adv.device?.id || adv.device?.name || Math.random().toString(36).slice(2);
const device: NearbyDevice = { id, name: adv.device?.name, txPower: adv.rssi };
onDevice(device);
};
navigator.bluetooth.addEventListener('advertisementreceived', handler as EventListener);
return () => {
navigator.bluetooth.removeEventListener('advertisementreceived', handler as EventListener);
scan.stop();
};
}
Use the device’s advertisement to carry an ephemeral pairing token or fingerprint. For browsers/devices without scanning support, open a QR modal with the same ephemeral token encoded. That token is used during the secure handshake to prevent accidental connections.
Signaling: simple, typed, and minimal
WebRTC needs a signaling channel to exchange SDP offers/answers and ICE candidates. Keep it simple: a tiny WebSocket or SSE-based rendezvous server that relays messages between two ephemeral IDs. Type the messages in TypeScript to avoid runtime errors.
// signaling.ts
export type SignalMessage =
| { type: 'offer'; from: string; to: string; sdp: string }
| { type: 'answer'; from: string; to: string; sdp: string }
| { type: 'ice'; from: string; to: string; candidate: RTCIceCandidateInit }
| { type: 'announce'; from: string; meta?: any };
export class Signaler {
private ws: WebSocket;
constructor(url: string) {
this.ws = new WebSocket(url);
}
send(msg: SignalMessage) {
this.ws.send(JSON.stringify(msg));
}
onMessage(cb: (m: SignalMessage) => void) {
this.ws.addEventListener('message', ev => cb(JSON.parse(ev.data)));
}
}
WebRTC connection and DataChannel (TypeScript)
Set up a peer connection with STUN/TURN servers, create a DataChannel for file streams, and handle ICE candidates. The following class is a TypeScript-first helper that encapsulates offer/answer logic and exposes a typed DataChannel send method.
// peer.ts
export type Crud = 'offer' | 'answer';
export class PeerConn {
pc: RTCPeerConnection;
dc?: RTCDataChannel;
constructor(public id: string, private signal: Signaler, stunServers = ['stun:stun.l.google.com:19302']) {
this.pc = new RTCPeerConnection({
iceServers: [{ urls: stunServers }]
});
this.pc.onicecandidate = (ev) => {
if (ev.candidate) {
this.signal.send({ type: 'ice', from: id, to: '', candidate: ev.candidate.toJSON() });
}
};
this.pc.ondatachannel = (ev) => {
this.setupChannel(ev.channel);
};
}
createDataChannel(label = 'file') {
const dc = this.pc.createDataChannel(label, { ordered: true });
this.setupChannel(dc);
return dc;
}
private setupChannel(dc: RTCDataChannel) {
this.dc = dc;
dc.binaryType = 'arraybuffer';
dc.onopen = () => console.log('datachannel open');
dc.onmessage = (ev) => console.log('msg', ev.data);
dc.onclose = () => console.log('dc closed');
}
async createOffer(to: string) {
const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
this.signal.send({ type: 'offer', from: this.id, to, sdp: offer.sdp! });
}
async handleOffer(sdp: string, from: string) {
const desc = new RTCSessionDescription({ type: 'offer', sdp });
await this.pc.setRemoteDescription(desc);
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
this.signal.send({ type: 'answer', from: this.id, to: from, sdp: answer.sdp! });
}
async handleAnswer(sdp: string) {
await this.pc.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp }));
}
async addIce(candidate: RTCIceCandidateInit) {
await this.pc.addIceCandidate(new RTCIceCandidate(candidate));
}
}
Secure handshake with WebCrypto (ECDH + AES-GCM)
Never send raw file bytes without encryption. Use ephemeral ECDH keys to derive a symmetric AES-GCM key for each session. Then verify a short fingerprint over an out-of-band channel (Bluetooth advertisement, QR code, or visual code) to prevent man-in-the-middle attacks.
// crypto.ts
export async function generateKeyPair() {
return crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' },
true,
['deriveKey']
);
}
export async function exportPublicKey(key: CryptoKey) {
const spki = await crypto.subtle.exportKey('raw', key);
return new Uint8Array(spki);
}
export async function deriveSharedKey(privateKey: CryptoKey, publicRaw: ArrayBuffer) {
const pubKey = await crypto.subtle.importKey('raw', publicRaw, { name: 'ECDH', namedCurve: 'P-256' }, true, []);
const derived = await crypto.subtle.deriveKey(
{ name: 'ECDH', public: pubKey },
privateKey,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
return derived;
}
export async function sha256(data: ArrayBuffer) {
return new Uint8Array(await crypto.subtle.digest('SHA-256', data));
}
Workflow: both peers create ephemeral ECDH pairs. They exchange raw public keys via the signaling channel, derive AES-GCM keys, and compute a short fingerprint (e.g., first 6 hex chars of sha256(publicKeyA || publicKeyB)). Show that fingerprint over Bluetooth/QR and ask the user to confirm. Only once confirmed do you proceed with file transfer.
File transfer over DataChannel: chunking, integrity, resume
WebRTC DataChannels are excellent for binary streams but you must manage chunking, backpressure, and integrity yourself. Use AES-GCM for encryption per chunk and SHA-256 for full-file verification.
// transfer.ts
const CHUNK_SIZE = 64 * 1024; // 64KB
export async function sendFile(dc: RTCDataChannel, file: File, aesKey: CryptoKey, onProgress?: (p: number) => void) {
const reader = file.stream().getReader();
let sent = 0;
let index = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
// encrypt chunk
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, value);
// Frame format: [index (4 bytes) | iv (12 bytes) | ciphertext]
const header = new ArrayBuffer(4 + 12);
const dv = new DataView(header);
dv.setUint32(0, index);
new Uint8Array(header).set(iv, 4);
const packet = new Uint8Array(header.byteLength + enc.byteLength);
packet.set(new Uint8Array(header), 0);
packet.set(new Uint8Array(enc), header.byteLength);
// Backpressure: wait if bufferedAmount too high
await ensureCanSend(dc, packet.byteLength);
dc.send(packet.buffer);
sent += value.byteLength;
index++;
onProgress?.(sent / file.size);
}
// signal EOF
dc.send(JSON.stringify({ type: 'eof', size: file.size, name: file.name }));
}
async function ensureCanSend(dc: RTCDataChannel, size: number) {
const MAX_BUFFER = 1_000_000; // 1MB
while (dc.bufferedAmount + size > MAX_BUFFER) {
await new Promise(r => setTimeout(r, 50));
}
}
export async function receiveChunked(decryptKey: CryptoKey, onChunk: (data: Uint8Array, idx: number) => void) {
// Use dc.onmessage to parse frames sent above and call onChunk after decrypt
}
On the receiver side, parse the header, decrypt using AES-GCM, and reconstruct the file in memory or via a WritableStream (for large files). Always compute a final SHA-256 and compare with the sender’s published file digest to validate integrity.
Resume and retries
For large files, support resume by tracking the highest successfully received chunk index. On reconnect, negotiate the next chunk index and resume transmission from that offset. Keep chunk-level acknowledgements minimal to reduce overhead (e.g., send acks every N chunks or a cumulative highestIndex ack).
TypeScript typings & message schemas
Strong typing prevents protocol mismatches. Define discriminated unions for all signaling and in-band protocol messages. Example:
// protocol.ts
export type InBandMsg =
| { kind: 'chunkAck'; highestIndex: number }
| { kind: 'eof'; name: string; size: number; sha256: string }
| { kind: 'meta'; fileId: string; totalChunks: number };
// Use JSON.stringify for control messages; binary frames for chunks.
Platform pitfalls and privacy considerations
- Browser support: WebBluetooth scanning works best in Chromium-based browsers and may be restricted on iOS. Always provide QR/short-code fallbacks so Safari/iOS users can still pair nearby devices.
- Permissions UX: Bluetooth and camera (for QR) permissions should be requested with clear intent. Minimize friction by requesting ephemeral access only when the user starts a transfer.
- STUN/TURN: Real-world P2P will require TURN for devices behind symmetric NATs. Expect to run or rent a TURN server for reliable cross-network transfers.
- Privacy: Use ephemeral IDs and rotate keys. Avoid broadcasting persistent device identifiers. Use confirmation codes and visual verification to prevent MITM.
Debugging and testing
Troubleshoot with these tools and techniques:
- chrome://webrtc-internals to inspect SDP and ICE negotiation
- Use firefox’s WebRTC logging and about:webrtc for cross-browser issues
- Playwright or Puppeteer for automated integration tests (simulate file streams and signaling messages)
- Use unit tests for your TypeScript message schemas (e.g., zod or io-ts)
Case study: small proof-of-concept
I built a small POC: a TypeScript PWA that lets users tap “Send”, selects a file, scans for nearby peers via WebBluetooth (if available) or shows a QR code. After pairing and verifying a 6-digit fingerprint, the peers exchange ECDH public keys via the signaling server, derive AES-GCM, and stream the file via a DataChannel in encrypted chunks. The POC handles disconnections by keeping a small transfer state on the signaling server so the sender can continue where they left off when the receiver reconnects.
Performance tips
- Pick a chunk size between 32–256KB and tune based on empirical tests for your target networks.
- Use ordered DataChannels for simplicity, but if you want higher throughput, use unordered with sequence numbers and reassembly logic.
- Avoid serializing huge control messages — use binary frames for chunks and JSON for small control signals.
- Compress before encryption if files are compressible. Encrypt after compressing.
Security checklist
- Ephemeral keys per session (no long-term private keys).
- Out-of-band fingerprint verification (Bluetooth/QR/visual).
- AES-GCM with 96-bit IVs and randomized nonces per chunk.
- SHA-256 digest for final integrity check.
- Use TURN to avoid exposing local IPs unless you intentionally prefer local-only transfers.
Future-proofing: where this goes in 2026 and beyond
Expect these developments to change how web P2P apps behave:
- WebTransport and QUIC-based transports will offer lower-latency, reliable streams, but browser-level P2P QUIC remains an emerging area.
- Improved Bluetooth/Proximity APIs: More standardized BLE scanning and short-range Web APIs will make discovery less brittle.
- OS-level collaboration: As vendors add cross-platform sharing features natively (see Pixel 9 moves in 2026), hybrid flows that combine native and web will become common.
- Decentralized discovery specs: Expect community standards for privacy-preserving proximity discovery to appear in the next few years.
Actionable checklist to build your own
- Design a minimal signaling API and type it with TypeScript unions.
- Implement WebBluetooth scanning with graceful fallback to QR or short codes.
- Implement ephemeral ECDH key exchange and derive AES-GCM keys with WebCrypto.
- Stream files in chunks over an ordered WebRTC DataChannel, encrypt per-chunk, and implement backpressure control.
- Add resume support with chunk-index negotiation and periodic checkpoints.
- Test across Chrome/Desktop, Safari/iOS (with QR fallback), and Android Chromium.
Practical code resources and libs to evaluate
- Simple-peer (community wrapper for WebRTC, but consider native RTCPeerConnection for full control).
- libp2p (heavyweight; great for decentralized P2P if you want more than WebRTC).
- zod or io-ts for runtime-safe TypeScript protocol validation.
- playwright for end-to-end automated tests that exercise real browser APIs.
Final takeaways
Building an AirDrop-like file transfer in TypeScript is entirely feasible in 2026, but success depends on pragmatic fallbacks and robust security. Use WebBluetooth for proximity discovery when available, a lightweight signaling server for rendezvous, WebRTC DataChannels for efficient P2P transfer, and WebCrypto for ephemeral, authenticated encryption. Test across platforms and always present a clear verification step to users.
Call to action
Ready to prototype? Start with the TypeScript scaffolding in this article: wire up a signaling server, implement the ECDH handshake, and get an encrypted DataChannel transferring encrypted chunks. If you want, I can generate a starter repo with the signaling server, TypeScript client modules shown here, and Playwright tests targeting Chromium and Safari fallbacks — tell me your preferred stack and I'll scaffold it.
Related Reading
- Relocating to Toronto? Flight Booking Tips for Real Estate Agents and Families During Brokerage Moves
- Analyzing Random Crash Dumps: Forensic Steps When a Process-Roulette Tool Brings Down Windows
- Train Like a Rockstar: Designing Conditioning Sessions to 'Dark Skies' Playlists
- When a GPU Gets Discontinued: A Gamer’s Guide to Buying, Returning, or Upgrading
- How to Turn a Celebrity Podcast Launch (Like Ant & Dec’s) into a Destination Marketing Win
Related Topics
Unknown
Contributor
Senior editor and content strategist. Writing about technology, design, and the future of digital media. Follow along for deep dives into the industry's moving parts.
Up Next
More stories handpicked for you
Reacting to API Changes: A TypeScript Approach to Error Handling
iPhone Air 2: What TypeScript Developers Need to Know for Compatibility
Handling Outages in TypeScript Applications: A Developer's Guide
Color Changes in Smartphones: Lessons in Material Selection for TypeScript Apps
Making the Switch: How TypeScript Embraces Browser Data Migration
From Our Network
Trending stories across our publication group