---
title: Batch Barcode Scanning in a Capacitor App
description: Scan many barcodes in a row in a Capacitor app — fullscreen batch mode or an embedded scan list with duplicate handling, undo, a running count and pause.
date:
  created: 2026-10-16
  updated: 2026-10-16
authors:
  - robingenz
categories:
  - Capacitor
  - Guides
  - SDKs
links:
  - Capacitor Barcode Scanner: sdks/capacitor/barcode-scanner.md
faq: true
---

# Batch Barcode Scanning in a Capacitor App

Capacitor batch barcode scanning means the camera stays open while the user scans one code after another, and the app collects them into a list instead of closing the scanner after the first hit. A stocktake, a picking list in a warehouse and an entrance check at an event all work this way, and each of them has to answer the same questions: what counts as a duplicate, how the user sees the running count, and how they take a wrong scan back. The [Capacitor Barcode Scanner plugin](../../sdks/capacitor/barcode-scanner.md) offers two ways to do this, a ready-made fullscreen batch mode and an embedded session where your app owns the list, and this guide builds both. The plugin is part of [Capawesome Insiders](../../insiders/index.md).

<!-- more -->

<div class="capawesome-z29o10a">
  <a href="https://capawesome.io/" target="_blank">
    <img alt="Ship a fix in one command with Capawesome Cloud Live Updates, no store review" src="https://capawesome.io/assets/banners/cloud-ship-a-fix-in-one-command.png" />
  </a>
</div>

**Key takeaways:**

- `scan({ batch: true })` opens the native fullscreen scanner, collects each barcode once per session and resolves with all of them when the user taps done.
- An embedded session with `startScan(...)` streams `barcodesScanned` events until `stopScan()`, so your app keeps the list and decides what a duplicate is.
- `duplicateTimeout` (default 1500 ms) is a throttle, not a once-per-session filter: a code that stays in view is reported again after the timeout.
- `pauseScan()` stops detection while the camera preview keeps running, which gives the user time to confirm or undo a hit before `resumeScan()`.
- The embedded mode has no built-in beep, haptic or undo; the fullscreen mode has feedback and a count badge but no undo.
- Unlike the ML Kit plugin, which is CocoaPods-only on iOS, the Capacitor Barcode Scanner plugin supports Swift Package Manager and uses AVFoundation and Vision instead of the ML Kit SDK.

## Two batch modes

The Capacitor Barcode Scanner plugin scans many barcodes in a row either in its own fullscreen user interface or in an embedded camera preview that sits inside your layout. Both keep the camera open across scans; they differ in who owns the result list and the feedback:

| | Fullscreen batch | Embedded session |
| --- | --- | --- |
| Method | [`scan({ batch: true })`](../../sdks/capacitor/barcode-scanner.md#scan) | [`startScan(...)`](../../sdks/capacitor/barcode-scanner.md#startscan) with `barcodesScanned` |
| User interface | Native viewfinder, torch button, done button with a count badge | Your HTML around a native preview in a frame you measure |
| Result | One promise with all barcodes when the user taps done | One event per detection until `stopScan()` |
| Duplicates | Each format and value collected once per session | `duplicateTimeout` throttle, the rest is your code |
| Feedback | Haptic feedback by default, optional beep | None built in |
| Undo | No | Your list, your undo |
| Cancel | Closing the scanner discards every collected barcode | Nothing is lost, the list is yours |
| Web | Not available | Available through the `BarcodeDetector` API |

Pick the fullscreen mode when the scan session is short and its result is consumed as a whole, such as ten parcels that are confirmed together. Pick the embedded session when the user needs to see and edit the list while scanning, when each scan triggers a lookup, or when the same code may be scanned several times on purpose. The layout work for the embedded preview, the frame, the placement and the overlays, is covered in [Embedded Barcode Scanner with Camera Preview in Capacitor](./capacitor-embedded-barcode-scanner.md) and not repeated here.

## Fullscreen batch scanning

The fullscreen batch mode of the Capacitor Barcode Scanner plugin is one option on the `scan(...)` call. With `batch: true`, the scanner collects every barcode it detects inside the viewfinder and resolves when the user taps the done button:

```typescript
import { BarcodeFormat, BarcodeScanner } from '@capawesome-team/capacitor-barcode-scanner';

const scanParcels = async () => {
  const { barcodes } = await BarcodeScanner.scan({
    batch: true,
    formats: [BarcodeFormat.Code128, BarcodeFormat.DataMatrix],
    ui: {
      title: 'Scan parcels',
      instructions: 'Scan every label, then tap done.',
      beep: true,
    },
  });
  return barcodes.map(barcode => barcode.rawValue);
};
```

Inside the session, the plugin keys each barcode on its format and value and collects it once; a label held in front of the camera for three seconds produces one entry, and the badge on the done button shows the count. The haptic feedback, on by default, and the beep, off by default, fire only when a new barcode joins the collection, so the user hears one tone per label rather than a stream. Only barcodes that lie fully inside the viewfinder are detected, which keeps the neighboring label on a shelf out of the result.

Two limits decide whether this mode fits. There is no undo: the user cannot remove a barcode from the badge count, so a wrong scan has to be removed in your app after the promise resolves. And closing the scanner without tapping done rejects the promise with `SCAN_CANCELED` and discards everything collected so far. For a session of a few labels, both are acceptable; for a stocktake of two hundred items, they are not. The fullscreen mode is native only, so a web build needs the embedded session anyway.

## Embedded scan list

An embedded scan list is a listener, a `startScan(...)` call and an array in your app. The listener goes first, so that no detection is missed between the start and the subscription, and the frame is measured from the element that reserves the space for the preview:

```typescript
import { BarcodeFormat, BarcodeScanner } from '@capawesome-team/capacitor-barcode-scanner';

interface ScanItem {
  key: string;
  value: string;
  count: number;
}

const items = new Map<string, ScanItem>();

const startStocktake = async () => {
  await BarcodeScanner.addListener('barcodesScanned', event => {
    for (const barcode of event.barcodes) {
      recordScan(`${barcode.format}|${barcode.rawValue}`, barcode.rawValue);
    }
  });
  const rect = document.querySelector('#scanner')!.getBoundingClientRect();
  await BarcodeScanner.startScan({
    frame: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
    formats: [BarcodeFormat.Ean13, BarcodeFormat.Ean8],
    duplicateTimeout: 3000,
  });
};

const stopStocktake = async () => {
  await BarcodeScanner.stopScan();
  await BarcodeScanner.removeAllListeners();
};
```

One [`barcodesScanned`](../../sdks/capacitor/barcode-scanner.md#addlistenerbarcodesscanned-) event can carry several barcodes, when two labels are in the frame at once, so the handler iterates over `event.barcodes` rather than taking the first one. Each `Barcode` has a `rawValue`, a `displayValue`, a `format` and `cornerPoints`; the raw value is the one to store, and the format belongs in the key, because an EAN-8 and a Code 128 can encode the same digits. Restricting `formats` to what the use case needs makes detection faster and removes false hits from other symbologies on the same label.

## Deduplicating scans

Deduplication in the Capacitor Barcode Scanner plugin is a throttle. The `duplicateTimeout` option, 1500 milliseconds by default, is "the time in milliseconds after which the same barcode is emitted again", and the plugin implements it by remembering when it last reported each format and value. A label that stays in front of the camera is reported again every 1.5 seconds, as implemented in version 0.1.5 on Android, iOS and the web. The timer is per barcode, it starts only when a barcode is reported, so a barcode outside the `detectionArea` never starts one, and `stopScan()` clears all timers while `pauseScan()` keeps them.

That makes two scanning policies possible, and the `recordScan` function from the sample decides which one applies:

```typescript
const countOnce = true;

const recordScan = (key: string, value: string) => {
  const existing = items.get(key);
  if (existing && countOnce) {
    return;
  }
  if (existing) {
    existing.count += 1;
  } else {
    items.set(key, { key, value, count: 1 });
  }
  renderList();
};
```

With `countOnce`, the map is the filter: a serial number that was already scanned is ignored for the rest of the session, no matter how often it comes back into view, which is the behavior an entrance check or an asset inventory needs. Without it, every report after the timeout counts as a scan, which is what quantity picking needs when the same article is scanned five times for five units. For the second policy, the timeout is the only protection against counting one label twice because the user held it still, so raise it to 3000 milliseconds or more, or pause after each hit as the next section shows.

The same decision applies to the fullscreen mode, where the plugin already behaves like `countOnce` and cannot count quantities. That is the reason an embedded session is the right tool for picking.

## Pause after each hit

[`pauseScan()`](../../sdks/capacitor/barcode-scanner.md#pausescan) stops barcode detection while the camera preview keeps running, and [`resumeScan()`](../../sdks/capacitor/barcode-scanner.md#resumescan) continues it, which turns a continuous stream into a confirm-each-hit flow without restarting the camera:

```typescript
import { BarcodeScanner } from '@capawesome-team/capacitor-barcode-scanner';

const confirmScan = async (key: string, value: string) => {
  await BarcodeScanner.pauseScan();
  const keep = await showConfirmation(value);
  if (keep) {
    recordScan(key, value);
  }
  await BarcodeScanner.resumeScan();
};

const undoLastScan = (key: string) => {
  const item = items.get(key);
  if (!item) {
    return;
  }
  if (item.count > 1) {
    item.count -= 1;
  } else {
    items.delete(key);
  }
  renderList();
};
```

To switch the stocktake to this flow, call `confirmScan(...)` from the listener in place of `recordScan(...)` and `await` it inside the `for` loop, so that two labels in one frame open their dialogs one after the other instead of at the same time.

While the session is paused, the plugin drops every detected frame before the duplicate check, so nothing queues up and no timers are set; the first detection after `resumeScan()` is a fresh one. Both methods reject with `NOT_SCANNING` when no session is active, which happens when the user navigated away and `stopScan()` ran in between, so a confirmation dialog that outlives the page should catch that rejection rather than treat it as a scanner failure.

Undo is an operation on your list, because the plugin has nothing to take back. Removing the last item or lowering its count is enough for the embedded session, and the `Map` keyed on format and value makes "undo the last scan of this article" a lookup rather than a search. For a confirm-each-hit flow, a toast with an undo action that lasts a few seconds is less work for the user than a modal, since most scans are right.

## Running count and feedback

An embedded session gives no feedback on its own, so the app renders the count and plays the sound. The running count is the size of the map for a count-once list, or the sum of the counts for quantity picking, rendered into a badge next to the preview on every `renderList()` call. For an audible or tactile signal, the [`@capacitor/haptics`](https://capacitorjs.com/docs/apis/haptics){:target="_blank"} plugin provides a notification vibration that fits a successful scan, and a short audio element handles the beep that warehouse users expect.

Two more options sharpen the input. `detectionArea` limits detection to a region of the frame, in CSS pixels relative to the frame, so that a scanner view that shows a whole shelf only reads the label under a drawn guide line. And `formats` restricted to the one or two symbologies on your labels makes each frame cheaper to analyze and keeps the QR code on the shipping box out of a list that expects EAN-13 article numbers. Because the preview is a native view, the badge, the guide line and the undo toast are HTML elements next to it, or above it with the `Behind` placement that the embedded scanner guide explains.

## Compared with ML Kit

The difference that drives most migrations has nothing to do with batch scanning. The ML Kit Barcode Scanning plugin "only supports CocoaPods" on iOS, as its README states, because Google's ML Kit SDK has [no Swift Package Manager support](https://github.com/googlesamples/mlkit/issues/180){:target="_blank"}, and the SDK's missing arm64 simulator slice has [broken simulator builds on Apple Silicon](https://github.com/capawesome-team/capacitor-mlkit/issues/319){:target="_blank"}. The Capacitor Barcode Scanner plugin ships a `Package.swift` and is built on AVFoundation and Vision on iOS, with no third-party pods, so a project that moved to Swift Package Manager keeps scanning without a Podfile for this plugin.

Beyond that, the free [ML Kit Barcode Scanning plugin](../../sdks/capacitor/mlkit/barcode-scanning.md) scans continuously as well, through its own [`startScan(...)`](../../sdks/capacitor/mlkit/barcode-scanning.md#startscan) and a `barcodesScanned` listener, and it is a maintained option for a scan list. The differences lie in what the app has to build itself. The ML Kit plugin has no `duplicateTimeout`, so the throttle from the dedup section moves into your handler with a timestamp per key; it has no `pauseScan()` and `resumeScan()`, so a confirm-each-hit flow stops and restarts the camera or ignores events in JavaScript; and it has no `detectionArea`. Its camera preview renders behind the web view, which requires making the app's background transparent while scanning, where the Capacitor Barcode Scanner plugin renders the preview above the web view inside a frame by default.

Its ready-made `scan()` interface has no batch mode and uses Google's code scanner, which needs Google Play services on Android; [Capacitor Barcode Scanning Without Google Play Services](./capacitor-barcode-scanning-without-google-play-services.md) covers that distinction. If a scan list with duplicate handling is the whole feature, the ML Kit plugin and a hundred lines of JavaScript get there; the Insiders plugin removes those lines and adds the fullscreen batch mode.

## FAQ

### How do I scan multiple barcodes in a row in a Capacitor app?

Use a scanner that keeps the camera open. With the [Capacitor Barcode Scanner plugin](../../sdks/capacitor/barcode-scanner.md), `scan({ batch: true })` collects barcodes in its native user interface until the user taps done, and `startScan(...)` with a `barcodesScanned` listener streams every detection into your own list until `stopScan()`. The embedded session is the one to use when the user should see the list, undo a scan or count quantities.

### Why is the same barcode reported again?

Because `duplicateTimeout` is a throttle. The plugin reports a barcode again once the timeout, 1500 milliseconds by default, has passed since the last report, so a label that stays in view keeps producing events. Keep a `Map` keyed on format and value in your app to count each barcode once, or raise the timeout and pause after each hit when repeat scans should count.

### Can the user undo a scan in batch mode?

Not in the fullscreen batch mode, which only shows a count badge and resolves with the full set when the user taps done; closing it discards everything. In an embedded session, the list lives in your app, so undo is a removal from that list, with `pauseScan()` giving the user time to decide.

### Does batch scanning work on the web?

The embedded session does, through the `BarcodeDetector` API, including `pauseScan()`, `resumeScan()` and `duplicateTimeout`, with the `barcode-detector` polyfill for browsers without native support. The fullscreen `scan(...)` mode, batch or not, is available on Android and iOS only.

### Is the Capacitor Barcode Scanner plugin free?

No. The plugin is part of the [Capawesome Insiders](../../insiders/index.md) subscription and is installed from the Capawesome npm registry with the license key that comes with it.

## Related posts

- [Embedded Barcode Scanner with Camera Preview in Capacitor](./capacitor-embedded-barcode-scanner.md)
- [Capacitor Barcode Scanning Without Google Play Services](./capacitor-barcode-scanning-without-google-play-services.md)
- [How to Build an Ionic Barcode Scanner with Capacitor](./how-to-build-an-ionic-barcode-scanner-with-capacitor.md)

## Try Capawesome Cloud

A scan list changes often, from the formats on the labels to the undo timing, and most of those changes live in the web layer. Capawesome Cloud Live Updates ship them to installed apps without a store review, and the native build with the scanner plugin itself comes from Capawesome Cloud Native Builds, with no Mac required for iOS. [How to Sign & Build Capacitor Apps in the Cloud](./how-to-sign-and-build-your-capacitor-app-in-the-cloud.md) shows the steps.

[Try Capawesome Cloud Free](https://capawesome.io){ .md-button .md-button--primary }

## Conclusion

Use the fullscreen batch mode for short sessions whose result is confirmed as a whole, and an embedded session for everything the user edits while scanning. Decide the duplicate policy before you write the handler, count once for identities and count repeats for quantities, and set `duplicateTimeout` and `pauseScan()` for the second case rather than relying on the default. For the layout of the embedded preview, read [Embedded Barcode Scanner with Camera Preview in Capacitor](./capacitor-embedded-barcode-scanner.md).

If you have any questions, join us on the [Capawesome Discord server](https://discord.gg/VCXxSVjefW){:target="_blank"}. To stay updated on the latest news, subscribe to the [Capawesome newsletter](https://capawesome.io/newsletter/){:target="_blank"}.
