Transfer Data Between Devices with Animated QR Codes in JavaScript

You can transfer files between devices with animated QR codes by splitting the data into chunks, rendering each chunk as a QR code in sequence, and using a barcode reader to decode the frames and reassemble the file. This guide builds a pure-JavaScript animated QR code generator and a browser-based reader that uses the Dynamsoft Barcode Reader (DBR) v11 JavaScript bundle to read the frames from a live camera and restore the original file — with no Internet connection, no wireless setup, and no Bluetooth pairing.

QR codes are a convenient screen-camera way to move data between devices. As discussed in the previous article, a single QR code with version 40 and error correction level L stores up to 2,953 bytes in bytes mode, which is enough for a link or a driver’s license. To transfer more, we separate the data into several QR codes, print them on one page or animate them on a screen, and decode them in sequence. The Dynamsoft Barcode Reader (DBR) can read multiple codes in one image and can also read from a video stream.

There has been research about this and one of them has stood out, which is TXQR. It uses fountain codes to add redundancy to the data so that skipping a few frames will not affect the reading. However, using fountain codes brings extra complexity to the process, and actually, with a good barcode-reading SDK, the repetition mode can also have a satisfying transfer rate.

What you’ll build: A JavaScript animated QR code generator that compresses and splits a file into chunk payloads and renders them as a grid of QR codes, plus a browser-based reader built on the Dynamsoft Barcode Reader v11 CaptureVisionRouter API that decodes the frames from a live camera and reassembles the original file.

Key Takeaways

  • An animated QR code transfer splits a file into index/total|data payloads, renders each chunk as a QR code (or a grid of codes), and reassembles the bytes once every frame is decoded.
  • The generator compresses the payload with the browser’s gzip CompressionStream (falling back to raw) and supports a 1×1, 2×2, or 3×3 grid of QR codes per screen to raise throughput.
  • The reader uses the Dynamsoft Barcode Reader v11 dynamsoft-barcode-reader-bundle with CaptureVisionRouter.capture() to decode QR frames from a live camera stream and stops the camera automatically when the transfer completes.
  • The transfer rate is bounded by how many QR codes the camera can capture and decode per second; chunk size, frame interval, QR-only format, and a scan region are the main tuning knobs.

Common Developer Questions

How do I transfer data between devices with animated QR codes?

Split the file into index/total|data chunks, render each chunk as a QR code in sequence (optionally in a grid), and use a barcode reader such as the Dynamsoft Barcode Reader v11 JavaScript bundle to decode the frames from a camera and reassemble the bytes. This example transfers a file from one device to another with no Internet connection.

How much data can be transferred with animated QR codes?

A single QR code with version 40 and error correction level L holds up to 2,953 bytes, so the generator breaks larger files into chunks and compresses the payload with gzip. The base repetition transfer reliably handles files under about 200 KB; on an iPhone SE 2016, a 15.93 KB file reached 11.25–16.38 KB/s with a 2,900-byte chunk size and a 200 ms interval.

What Dynamsoft API does the reader use to decode QR codes from a camera?

The reader uses the Dynamsoft Barcode Reader v11 dynamsoft-barcode-reader-bundle. It initializes with Dynamsoft.License.LicenseManager.initLicense(), loads the DBR wasm with CoreModule.loadWasm(['DBR']), creates a CaptureVisionRouter instance, and decodes each camera frame with cvr.capture(canvas, 'ReadBarcodes_Default').

How Animated QR Codes Transfer Data

The generator divides the file into a sequence of chunks. Each chunk is prefixed with metadata so the reader knows which frame it is and how many frames to expect. The first chunk also carries the filename and MIME type. In the current version, a single flag byte right after the first chunk’s metadata marks whether the payload is gzip-compressed (Z) or raw (R):

1/7|payload_image.png|image/png|Z|<chunk 1 bytes>
2/7|<chunk 2 bytes>
...
7/7|<chunk 7 bytes>

The reader keeps decoding frames and stores each received chunk. When it has all the frames, it concatenates the payload bytes, decompresses them if the flag byte is Z, and restores the original file.

Prerequisites

Build the Animated QR Code Generator in JavaScript

The generator uses the qrcode-generator library to build QR codes and a <canvas> to render them. The file is read as bytes, compressed with CompressionStream('gzip') when it shrinks the payload, then split into fixed-size chunks.

Split the File into Chunks and Compress It

qrcode.stringToBytes is set to return the raw bytes so the QR code stores the chunk data directly.

const FLAG_RAW = 0x52;  // 'R'
const FLAG_GZIP = 0x5A; // 'Z'

async function gzipBytes(buffer) {
    const stream = new Blob([buffer]).stream().pipeThrough(new CompressionStream('gzip'));
    return new Uint8Array(await new Response(stream).arrayBuffer());
}

async function loadArrayBufferToChunks(bytes,filename,type){
    bytes = new Uint8Array(bytes);
    try {
        // compress the whole payload (header + file); PNG/JPEG data is already
        // compressed, in which case the raw flag is kept.
        var compressed = new Uint8Array(await gzipBytes(bytes));
        var raw = new Uint8Array(bytes);
        var flag = compressed.length < raw.length ? FLAG_GZIP : FLAG_RAW;
        var body = flag === FLAG_GZIP ? compressed : raw;
    } catch (ex) {
        console.warn('CompressionStream unavailable, fall back to raw.');
        body = new Uint8Array(bytes);
        flag = FLAG_RAW;
    }
    var header = stringToBytes(encodeURIComponent(filename)+"|"+type+"|");
    var data = concatTypedArrays(new Uint8Array([flag]), concatTypedArrays(header, body));
    var chunkSize = parseInt(document.getElementById("chunkSize").value);
    var num = Math.ceil(data.length / chunkSize)
    chunks=[];
    for (var i=0;i<num;i++){
        var start = i*chunkSize;
        var chunk = data.slice(start,start+chunkSize);
        var meta = (i+1)+"/"+num+"|";
        chunk = concatTypedArrays(stringToBytes(meta),chunk);
        chunks.push(chunk);
    }
    rebuildScreens();
}

Render a Grid of QR Codes per Screen

To raise throughput, the generator can render up to a 3×3 grid of QR codes in a single screen, so the receiver decodes several frames per camera frame. Each QR still carries the unchanged index/total|data payload, so the reader treats every QR as an independent frame. The QR version is fixed for the whole transfer in rebuildScreens(), so every screen renders at the same size even when the last batch has fewer chunks:

function getGridSize() {
    return parseInt(document.getElementById("grid").value);
}

function renderScreen(chunkList) {
    var gridSize = getGridSize();
    var qrs = [];
    var maxModuleCount = 0;
    for (var i = 0; i < chunkList.length; i++) {
        var qr = qrcode(fixedTypeNumber, 'L'); // fixed version per transfer, error correction L
        qr.addData(chunkList[i]);
        qr.make();
        qrs.push(qr);
        maxModuleCount = Math.max(maxModuleCount, qr.getModuleCount());
    }
    var cell = 8;
    var margin = 4; // quiet zone modules
    var qrSize = (maxModuleCount + margin * 2) * cell;
    var canvas = document.createElement('canvas');
    canvas.width = qrSize * gridSize;
    canvas.height = qrSize * gridSize;
    canvas.style.display = "block";
    canvas.style.margin = "0 auto";
    canvas.style.maxWidth = "100%";
    canvas.style.maxHeight = "85vh";
    var ctx = canvas.getContext('2d');
    ctx.fillStyle = '#ffffff';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = '#000000';
    for (var idx = 0; idx < qrs.length; idx++) {
        var col = idx % gridSize;
        var row = Math.floor(idx / gridSize);
        var px = col * qrSize;
        var py = row * qrSize;
        var count = qrs[idx].getModuleCount();
        var off = (qrSize - count * cell) / 2;
        for (var r = 0; r < count; r++) {
            for (var c = 0; c < count; c++) {
                if (qrs[idx].isDark(r, c)) {
                    ctx.fillRect(px + off + c * cell, py + off + r * cell, cell, cell);
                }
            }
        }
    }
    return canvas;
}

// Groups the chunks into screens of gridSize*gridSize QR codes.
function rebuildScreens() {
    var gridSize = getGridSize();
    var perScreen = gridSize * gridSize;
    totalScreens = Math.ceil(chunks.length / perScreen);
    // Find the largest QR version any chunk needs and use it for every chunk,
    // otherwise screens with smaller payloads render smaller QR codes.
    var maxModules = 0;
    for (var i = 0; i < chunks.length; i++) {
        var qr = qrcode(0, 'L');
        qr.addData(chunks[i]);
        qr.make();
        maxModules = Math.max(maxModules, qr.getModuleCount());
    }
    fixedTypeNumber = maxModules > 0 ? (maxModules - 17) / 4 : 0;
}

showAnimatedQRCode() cycles the screens in a loop (or stops when the Loop checkbox is cleared). Each screen shows a batch of gridSize × gridSize codes, and the progress is shown as currentIndex/totalScreens.

The HTML controls let users pick a file, set the chunk size and interval, choose the grid layout, and start or stop the animation:

<input type="file" id="file" onchange="loadfile()"/>
<input type="checkbox" id="loopChk" value="Loop" checked>
<label for="loopChk">Loop</label>
<label for="name">Chunk size (bytes):</label>
<input type="text" id="chunkSize" name="chunkSize" value="1000">
<label for="name">Extra interval (ms):</label>
<input type="text" id="interval" name="interval" value="200">
<label for="name">QR codes per screen:</label>
<select id="grid" onchange="onGridChange()">
    <option value="1">1 (single)</option>
    <option value="2" selected>2x2 (4)</option>
    <option value="3">3x3 (9)</option>
</select>
<input type="button" value="Stop" onclick="stop();" />
<input type="button" value="Start" onclick="start();" />
<div id="progress"></div>
<div id="placeHolder"></div>

Build the Animated QR Code Reader in JavaScript

Now that we have a generator, we need a reader. We can create native mobile applications with high performance using the Dynamsoft Barcode Reader’s mobile SDK. But for ease of use and cross-platform concerns, here we use the JavaScript bundle of DBR to create the reader.

Initialize the Dynamsoft Barcode Reader Bundle

The reader uses the dynamsoft-barcode-reader-bundle v11 from jsdelivr. Initialize the license, load the DBR wasm module, and create a CaptureVisionRouter instance:

<script src="https://cdn.jsdelivr.net/npm/dynamsoft-barcode-reader-bundle@11.6.2100/dist/dbr.bundle.js"></script>
await Dynamsoft.License.LicenseManager.initLicense(LICENSE_KEY, true);
await Dynamsoft.Core.CoreModule.loadWasm(['DBR']);
cvr = await Dynamsoft.CVR.CaptureVisionRouter.createInstance();

Read QR Codes from the Live Camera Stream

Open the camera and run a loop that draws each video frame onto a single reused offscreen canvas, then decodes it with cvr.capture():

const scanLoop = async () => {
    if (!scanning) return;
    if (videoEl.readyState >= 2 && videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
        if (captureCanvas.width !== videoEl.videoWidth || captureCanvas.height !== videoEl.videoHeight) {
            captureCanvas.width = videoEl.videoWidth;
            captureCanvas.height = videoEl.videoHeight;
            overlayCanvas.width = videoEl.videoWidth;
            overlayCanvas.height = videoEl.videoHeight;
        }
        captureCtx.drawImage(videoEl, 0, 0, captureCanvas.width, captureCanvas.height);
        try {
            const result = await cvr.capture(captureCanvas, 'ReadBarcodes_Default');
            if (scanning) onFrameRead(result);
        } catch(e){
            console.error(e);
        }
    }
    if (scanning) requestAnimationFrame(scanLoop);
};

The camera is requested with a rear-facing 1920×1080 stream:

stream = await navigator.mediaDevices.getUserMedia({
    video: {
        facingMode: 'environment',
        width: { ideal: 1920 },
        height: { ideal: 1080 }
    }
});
videoEl.srcObject = stream;
await videoEl.play();

Note that getUserMedia requires HTTPS or localhost. If you open scanner.html directly from the file system (file://), serve the folder with a small web server (for example python -m http.server in that directory) or use the online demo, otherwise the camera request fails.

Read Chunks and Restore the Original Data

Each frame returns barcode items. Filter for barcode items, draw their locations on an overlay (guarding against incomplete locations so a bad result cannot break the loop), and feed each decoded text to processRead(). When all chunks arrive, the scanner stops the camera and reassembles the file. A plain QR code that does not carry index/total metadata is shown as text instead of being treated as a transfer frame:

function onFrameRead(result){
    framesRead = framesRead + 1;
    const items = (result.items || []).filter(it => it.type === Dynamsoft.Core.EnumCapturedResultItemType.CRIT_BARCODE);
    lastDetected = items.length;
    overlayCtx.clearRect(0,0,overlayCanvas.width,overlayCanvas.height);
    if (items.length > 0) {
        successNum = successNum + 1;
        var decoded = [];
        for (const item of items){
            // Guard the overlay drawing: some results may carry an incomplete
            // location, and a throw here would break the whole scan loop.
            try {
                const location = item.location;
                if (location && location.points && location.points.length >= 4){
                    overlayCtx.strokeStyle = '#00ff00';
                    overlayCtx.lineWidth = 4;
                    const points = location.points;
                    overlayCtx.beginPath();
                    overlayCtx.moveTo(points[0].x, points[0].y);
                    for (let i=1;i<points.length;i++) overlayCtx.lineTo(points[i].x, points[i].y);
                    overlayCtx.closePath();
                    overlayCtx.stroke();
                }
            } catch (e) {
                console.error(e);
            }
            try {
                processRead(item);
            } catch(e) {
                console.error(e);
            }
            decoded.push(item.text);
        }
        var received = document.getElementById("received");
        received.style.display = "block";
        var receivedCount = getObjectLength(code_results);
        if (total > 0){
            received.textContent = "frame " + receivedCount + "/" + total;
        }else{
            received.textContent = decoded[0].substring(0, 160);
        }
    }
    updateStatistics(performance.now()-startTime);
}

function processRead(item){
    var text = item.text;
    try {
        var meta = text.split("|")[0];
        // Only animated QR frames carry the "index/total" metadata; a plain QR
        // code is shown as text and must not pollute the transfer state.
        if (!/^\d+\/\d+$/.test(meta)) {
            return;
        }
        var totalOfThisOne = parseInt(meta.split("/")[1]);
        if (total!=0 && total != totalOfThisOne){ // QR codes for another file
            total = totalOfThisOne;
            code_results={};
            return;
        }
        total = totalOfThisOne;
        var index = parseInt(meta.split("/")[0]);
        code_results[index]=item;
        if (getObjectLength(code_results)==total){
            onCompleted();
        }
    } catch(error) {
        console.log(error);
    }
}

In showResult(), the reader recovers the filename and MIME type from the first frame’s text (skipping the flag character that sits right after 1/N|), accumulates the chunk payloads into a typed array, and decompresses the gzip stream if the flag byte is Z:

async function showResult(timeElapsed){
    if (getObjectLength(code_results) < total){
        alert("Incomplete transfer: received " + getObjectLength(code_results) + " of " + total + " frames. Please scan again.");
        resetResults();
        return;
    }
    var jointData = new Uint8Array(0);
    for (var i=0;i<getObjectLength(code_results);i++){
        var index = i+1;
        var result = code_results[index];
        var bytes = result.bytes;
        var text = result.text;
        if (index == 1){
            // "1/N|Rfilename|mime|data..." - the flag char right after "1/N|" is
            // skipped when parsing the filename; separators come from the bytes.
            var parts = text.split("|");
            var filename = decodeURIComponent(parts[1].substring(1));
            var mimeType = parts[2];
            var sep1 = bytes.indexOf(0x7C); // after "1/N"
            var sep2 = bytes.indexOf(0x7C, sep1+1); // after filename
            var sep3 = bytes.indexOf(0x7C, sep2+1); // after mime
            var dataStart = sep3 + 1;
            data = bytes.slice(dataStart,bytes.length);
        }else{
            var dataStart = bytes.indexOf(0x7C)+1;
            data = bytes.slice(dataStart,bytes.length);
        }
        // Accumulate with a typed array; Array.concat would treat each Uint8Array
        // chunk as a single element and corrupt the payload.
        var combined = new Uint8Array(jointData.length + data.length);
        combined.set(jointData, 0);
        combined.set(data, jointData.length);
        jointData = combined;
    }
    var flag = code_results[1].text.charAt(code_results[1].text.indexOf("|")+1);
    var rawBytes = new Uint8Array(jointData);
    if (flag === 'Z') {
        const stream = new Blob([rawBytes]).stream().pipeThrough(new DecompressionStream('gzip'));
        rawBytes = new Uint8Array(await new Response(stream).arrayBuffer());
    }
    jointData = rawBytes;
    // build a download link and, for images, an <img> preview
}

The decoded results are displayed in a list on the web page. One list item contains a link to download the file, the elapsed time and the download speed. If the file is an image, an img element is appended as well:

async function showResult(timeElapsed){
    //......
    var decodedList = document.getElementById("decodedList");
    var item = document.createElement("li");
    decodedList.appendChild(item);
    var dataURL = await ArraybufferAsDataURL(jointData,mimeType);
    appendDownloadLink(item, dataURL,filename);
    var info = document.createElement("span");
    info.innerText = " elapsed time: " + timeElapsed + "ms" +" speed: "+ (jointData.length/1024/(timeElapsed/1000)).toFixed(2) +"KB/s";
    item.appendChild(info);
    if (dataURL.indexOf("image")!=-1) {
        var img = document.createElement("img");
        img.src = dataURL;
        img.style.display = "block";
        img.style.maxHeight = "350px";
        img.style.maxWidth = "100%";
        item.appendChild(img);
    }
}

During the decoding process, it also shows the statistics like time elapsed, frames processed, successfully-read frames and the progress:

function updateStatistics(timeElapsed){
    var statisticsPre = document.getElementById("statisticsPre");
    statistics = "elapsed time: " + (timeElapsed)/1000 +"s";
    statistics = statistics +"\ntotal frame number: " + framesRead;
    statistics = statistics +"\nsuccessful number: " + successNum;
    statistics = statistics +"\nlast frame detected: " + lastDetected;
    statistics = statistics +"\nsuccess fps: " + (successNum/(timeElapsed/1000)).toFixed(2);
    statistics = statistics +"\nprogress: " + getObjectLength(code_results) + "/" + total;
    statisticsPre.innerHTML=statistics;
}

Decode a QR Code from an Image File

The scanner can also decode a single image (or a screenshot that contains several codes) rather than a live camera stream. The file is read as a data URL and passed to cvr.capture():

async function decodefile(){
    let files = document.getElementById('file').files;
    if (files.length == 0) return;
    await updateSettings(cvr);
    var file = files[0];
    const dataUrl = await new Promise((r) => {
        var reader = new FileReader();
        reader.onload = () => r(reader.result);
        reader.readAsDataURL(file);
    });
    let result = await cvr.capture(dataUrl, 'ReadBarcodes_Default');
    var items = (result.items || []).filter(it => it.type === Dynamsoft.Core.EnumCapturedResultItemType.CRIT_BARCODE);
    if (items.length>0){
        alert("Detected. Elapsed time: "+(performance.now()-timeOfBegin));
    }else{
        alert("No barcodes detected.")
    }
}

Define the Scanner UI

The scanner UI binds the camera preview and draws a green outline around each detected QR code. The camera select is provided by the pre-defined dce-sel-camera class:

<div id="scanner" style="display:none;position:fixed;left:0;top:0;right:0;bottom:0;background:#000;">
    <div class="dce-video-container" style="position:absolute;left:0;top:0;width:100%;height:100%;background:#000;">
        <video id="video" autoplay playsinline style="position:absolute;left:0;top:0;width:100%;height:100%;object-fit:contain;"></video>
        <canvas id="overlay" style="position:absolute;left:0;top:0;width:100%;height:100%;object-fit:contain;pointer-events:none;z-index:2;"></canvas>
    </div>
    <div style="position: absolute;left: 0;top: 0;z-index:3;">
        <select class="dce-sel-camera" style="display: block;"></select>
    </div>
    <input type="button" value="Stop" onclick="stop();"  style="position:absolute;right:0;top:0;z-index:3;"/>
</div>

The decoded item’s location is drawn on the overlay canvas:

const location = item.location;
if (location && location.points && location.points.length >= 4){
    overlayCtx.strokeStyle = '#00ff00';
    overlayCtx.lineWidth = 4;
    const points = location.points;
    overlayCtx.beginPath();
    overlayCtx.moveTo(points[0].x, points[0].y);
    for (let i=1;i<points.length;i++) overlayCtx.lineTo(points[i].x, points[i].y);
    overlayCtx.closePath();
    overlayCtx.stroke();
}

Improve the Reading Speed

The transferring speed of this screen-camera solution is mainly decided by how many QR code images the receiver can capture and decode in a fixed period. Mobile devices can capture 30 frames per second but it may take hundreds of milliseconds to decode one frame, so the decoding performance affects the speed the most.

Scan Only QR Codes

The Dynamsoft Barcode Reader supports a multitude of barcode formats. We can update the settings to scan QR codes only so that it will not spend extra effort finding other barcode formats:

async function updateSettings(instance){
    let settings = await instance.getSimplifiedSettings('ReadBarcodes_Default');
    settings.barcodeSettings.barcodeFormatIds = Dynamsoft.DBR.EnumBarcodeFormat.BF_QR_CODE;
    await instance.updateSettings('ReadBarcodes_Default', settings);
}

Set Up a Scan Region

The QR code is just a part of the entire video frame. We can set up a scan region so that the QR code takes up most of the frame. The scanner exposes this as a “Regional Scanning” checkbox:

async function updateSettings(instance){
    let settings = await instance.getSimplifiedSettings('ReadBarcodes_Default');
    settings.barcodeSettings.barcodeFormatIds = Dynamsoft.DBR.EnumBarcodeFormat.BF_QR_CODE;
    if (document.getElementById("scanRegionChk").checked){
        settings.roiMeasuredInPercentage = true;
        const videoW = videoEl.videoWidth;
        const videoH = videoEl.videoHeight;
        if (videoH > videoW){
            settings.roi = {points: [{x:0,y:25},{x:100,y:25},{x:100,y:75},{x:0,y:75}], id: 0};
        }else{
            settings.roi = {points: [{x:25,y:0},{x:75,y:0},{x:75,y:100},{x:25,y:100}], id: 0};
        }
    }
    await instance.updateSettings('ReadBarcodes_Default', settings);
}

Scan Region

Demo

Here is a demo of the final result running on iOS:

Video

You can try the live demos online:

Build an Android Reader

The same transfer can run on a native Android app. The reader/dcesample project in the repository uses the Dynamsoft Barcode Reader bundle com.dynamsoft:barcodereaderbundle:11.6.2000 from the Dynamsoft Maven repo and can be built with Gradle:

cd reader/dcesample
./gradlew assembleDebug

The app has three screens:

  • Home: plays the embedded animated QR video (animated_payload.mp4). Press Simulate Receive to decode the frames locally with CaptureVisionRouter and reassemble the file without a camera. Press Open Camera to start live scanning.
  • Camera: live scanning with Dynamsoft Camera Enhancer; each decoded frame is appended to the transfer, and the received file is shown in the result screen when all frames arrive.
  • Result: previews the reassembled file and offers a Save button.

Test and Conclusion

A test is run for this combination of animated QR code generator and scanner to examine how it performs with different chunk sizes and intervals on an iOS device and an Android device. The speed results of three continuous readings are recorded (for large files, only one record).

Here are the test results on iPhone SE 2016:

  • File size: 15.93KB, Chunk size: 1800, Interval: 100, Speed: 6.90KB/s, 10.68KB/s, 5.35KB/s
  • File size: 15.93KB, Chunk size: 1800, Interval: 200, Speed: 7.22KB/s, 7.17KB/s, 7.47KB/s
  • File size: 15.93KB, Chunk size: 1800, Interval: 400, Speed: 3.94KB/s, 3.99KB/s, 3.90KB/s
  • File size: 15.93KB, Chunk size: 1800, Interval: 800, Speed: 2.09KB/s, 2.08KB/s, 2.05KB/s
  • File size: 15.93KB, Chunk size: 2900, Interval: 100, Speed: 16.38KB/s, 4.26KB/s, 8.56KB/s
  • File size: 15.93KB, Chunk size: 2900, Interval: 200, Speed: 11.25KB/s, 11.33KB/s, 11.32KB/s
  • File size: 15.93KB, Chunk size: 2900, Interval: 400, Speed: 6.78KB/s, 6.74KB/s, 6.74KB/s
  • File size: 15.93KB, Chunk size: 2900, Interval: 800, Speed: 3.56KB/s, 3.61KB/s, 3.70KB/s
  • File size: 231KB, Chunk size: 1800, Interval: 400, Speed: 3.01KB/s
  • File size: 231KB, Chunk size: 2900, Interval: 400, Speed: 2.12KB/s

Here are the test results on Sharp AQUOS S2 (the CPU power is weaker):

  • File size: 15.93KB, Chunk size: 1800, Interval: 100, Speed: 1.15KB/s, 1.24KB/s, 0.74KB/s
  • File size: 15.93KB, Chunk size: 1800, Interval: 200, Speed: 2.18KB/s, 1.38KB/s, 1.91KB/s
  • File size: 15.93KB, Chunk size: 1800, Interval: 400, Speed: 3.44KB/s, 2.60KB/s, 4.06KB/s
  • File size: 15.93KB, Chunk size: 1800, Interval: 800, Speed: 2.03/KB/s, 2.08KB/s, 2.07KB/s
  • File size: 15.93KB, Chunk size: 2900, Interval: 100, Speed: 1.05KB/s, 1.80KB/s, 0.93KB/s
  • File size: 15.93KB, Chunk size: 2900, Interval: 200, Speed: 5.42KB/s, 5.37KB/s, 9.19KB/s
  • File size: 15.93KB, Chunk size: 2900, Interval: 400, Speed: 5.93KB/s, 7.10KB/s, 3.12KB/s
  • File size: 15.93KB, Chunk size: 2900, Interval: 800, Speed: 3.90KB/s, 3.53KB/s, 3.68KB/s
  • File size: 231KB, Chunk size: 1800, Interval: 400, Speed: 2.06KB/s
  • File size: 231KB, Chunk size: 2900, Interval: 400, Speed: 1.05KB/s

We can make a conclusion based on this test heuristically.

  1. The solution works great for transferring small-sized files which are under 200KB. Because the speed is limited and since more QR codes are needed to encode a large file, the chance of missing frames for large files is high.
  2. The chunk size and interval should be adjusted accordingly. If the chunk size and the interval are small, the generator can generate QR codes fast, however, the receiver may not catch them in time. If the chunk size is large, more data can be transferred in the same time span, but the receiver has to spend more time decoding and it may miss frames, especially for low-end devices.

The current generator adds two throughput optimizations that the base test does not measure: gzip compression of the payload and a grid of QR codes per screen. Both increase the amount of data transferred per camera frame.

This solution may be improved in the following ways:

  1. Improve the speed by using a new format, like colored QR code, which can have a larger data capacity.
  2. Improve the missed frames problem by using fountain codes.
  3. Improve the missed frames problem by making the current unidirectional communication to a bidirectional communication so that the generator will only show the undecoded QR codes. The cross-platform Ionic QRTransfer version implements this two-way communication.
  4. Use data compression. Instead of transferring JPEG files, transfer WebP files. (conversion tool)

Common Issues & Edge Cases

  • Missed frames on large files. If the file is large, a frame can be missed when the interval is too short or the chunk size is too large, especially on low-end devices. Increase the interval or reduce the chunk size; for larger payloads, the generator’s gzip option and higher grid density help.
  • Low-end devices decode slowly. Decoding a QR frame can take hundreds of milliseconds, so the camera may skip frames. Restrict the reader to QR codes only and set a scan region so the code fills the frame.
  • Camera or wasm load fails. The web scanner needs a secure context and a working camera. Serve the page over HTTPS, grant camera permission in the browser, and if the wasm fails to load, check the CDN URL and the CoreModule.loadWasm(['DBR']) call.

Source Code

Get the complete sample project source code on GitHub: AnimatedQRCodeReader.