Back to all notes
#React Native #Offline-First #Architecture #SQLite
· 7 min read

Building an Offline Barcode Scanner in React Native for Spotty Warehouse Wi-Fi

What I learned getting hardware laser scanners, messy GS1 barcodes, and local SQLite writes to work smoothly inside insulated warehouse freezers.

Muhammad Riyad

Muhammad Riyad

Full-Stack & React Native Engineer

Most mobile apps assume you have a working internet connection. In warehouse logistics especially inside cold-storage facilities wrapped in insulated steel paneling that assumption falls apart immediately.

The first time I tested an early version of our scanner app on-site, the Wi-Fi dropped dead the moment we stepped past the freezer doors. An operator had a 25-kilogram box on a scale, pulled the trigger on a Bluetooth laser scanner, and watched the screen freeze on a spinner waiting for an API call to validate the barcode.

When an operator has 400 boxes to log before a truck leaves at 6:00 AM, a five-second delay per box isn’t just frustrating. It means they will stop using the app and go back to pen and paper.

To make this work, the app had to run completely standalone: zero network calls in the critical scanning loop, synchronous barcode parsing directly on the device, direct interception of hardware laser scanners, and saving every scan locally in SQLite in under 10 milliseconds.


The Hardware and the Barcode Mess

The handheld devices were rugged Android terminals and Bluetooth ring scanners (Zebra and Newland). We were dealing with two completely different barcode formats:

  1. External Supplier Labels (GS1-128): Shipments arrive with standard GS1-128 barcodes containing Application Identifiers (AIs). Net weight is encoded under (310x) for kilograms or (320x) for pounds, alongside batch numbers (10) and serial numbers (21).
  2. Internal Scale Printers: Older scale printers already in the facility generated 13-digit proprietary numbers. A label might start with a prefix like 21, followed by a 5-digit weight value that needs to be divided by 1000.

If we tried to send these raw strings to a backend API to decipher, every single scan would depend on spotty warehouse Wi-Fi. The parsing logic had to live entirely on the phone.

[ Hardware Scanner / Laser Gun ]
                │ (HID Keystrokes @ 5ms interval)

[ Hidden Off-Screen TextInput ]
   ├── Burst accumulator (<50ms inter-key gap)
   └── Laser bounce deduplication window


[ On-Device Parsing Engine ]
   ├── Step 1: Check custom warehouse scale layouts (prefix + slice + divisor)
   ├── Step 2: Decode GS1-128 Application Identifiers (AIM & FNC1 stripping)
   └── Step 3: Validate weight thresholds (minWeight <= weight <= maxWeight)

        ┌───────┴───────┐
        ▼               ▼
 [ Audio + Haptic ]  [ SQLite DB (WAL Mode) ]
   - Instant beep      - <4ms atomic write
   - Glove feedback    - Indexed audit trail

1. Synchronous On-Device Barcode Parsing

I built a dual-priority parser in TypeScript that checks custom scale layouts first, then falls back to standard GS1-128 decoding:

export interface BarcodeLayout {
  id: string;
  prefix: string;
  description: string;
  weightStartIndex: number;
  weightLength: number;
  decimalDivisor: number;
}

export interface WeightResult {
  weight: number | null;
  labelType: "supplier" | "internal";
  error?: "no_weight_ai" | "invalid_weight" | "out_of_range";
  errorMessage?: string;
  parsedAIs: Record<string, string>;
}

export function parseWeightFromBarcode(raw: string, product: Product): WeightResult {
  const cleaned = raw.trim();

  // 1. Try matching custom warehouse scale layouts first
  const layoutWeight = findWeightByLayout(cleaned, product.layoutId);
  if (layoutWeight !== null) {
    if (layoutWeight < product.minWeight || layoutWeight > product.maxWeight) {
      return {
        weight: null,
        labelType: "internal",
        error: "out_of_range",
        errorMessage: `Weight (${layoutWeight.toFixed(product.decimalPlaces)}) out of range (${product.minWeight} - ${product.maxWeight} kg)`,
        parsedAIs: {},
      };
    }
    return {
      weight: layoutWeight,
      labelType: "internal",
      parsedAIs: {},
    };
  }

  // 2. Fall back to standard GS1-128 Application Identifiers
  const parsedAIs = parseGs1Ais(cleaned);
  const { weight, inKg } = extractWeightFromAis(parsedAIs);

  if (weight === null) {
    return {
      weight: null,
      labelType: "supplier",
      error: "invalid_weight",
      errorMessage: "No valid weight Application Identifier found",
      parsedAIs: parsedAIs.reduce((acc, p) => ({ ...acc, [p.ai]: p.value }), {}),
    };
  }

  // Convert pounds (AI 320x) to kilograms if required
  const kgWeight = inKg ? weight : weight / 2.20462262185;

  if (kgWeight < product.minWeight || kgWeight > product.maxWeight) {
    return {
      weight: null,
      labelType: "supplier",
      error: "out_of_range",
      errorMessage: `Weight (${kgWeight.toFixed(product.decimalPlaces)}) out of range`,
      parsedAIs: {},
    };
  }

  return {
    weight: kgWeight,
    labelType: "supplier",
    parsedAIs: {},
  };
}

The GS1 Bug That Almost Tripped Up Production

During early testing, I ran into a subtle GS1 issue that taught me why reading specifications carefully matters.

I initially assumed that AI 3102 (net weight in kg) always meant dividing the raw slice by 100. Then a supplier shipment arrived with 3103 on high-precision cuts. In GS1, the fourth digit of 310x defines the decimal point position ($10^x$). Because I had hardcoded a divisor of 100, a 025280 payload with 3103 got parsed as 252.8 kg instead of 25.28 kg.

I updated the parser to compute the divisor dynamically based on that trailing digit:

// Dynamically compute divisor from 4th character: '3102' -> 10^2 = 100, '3103' -> 10^3 = 1000
const decimals = parseInt(aiString.charAt(3), 10);
const divisor = Math.pow(10, decimals);
const parsedWeight = rawValue / divisor;

Another quirk: physical laser decoders often prepend an AIM symbology identifier like ]C1 or delimit variable-length chunks with the non-printable ASCII \x1D (Group Separator). If you don’t strip those before parsing, regex lookups fail silently.


2. Intercepting Hardware Scanners Without Breaking the UI

Industrial barcode guns communicate as HID (Human Interface Device) keyboards. When you pull the trigger, the hardware types out the entire barcode string at roughly 5 milliseconds per character, then presses Enter.

The Soft-Keyboard Problem in React Native

In React Native, capturing typed input usually means mounting a <TextInput>. But on Android devices, focusing a text input immediately triggers the on-screen soft keyboard.

In our case, the soft keyboard took up 50% of the screen, hid the item tally, and pushed the confirmation button off-screen. Setting showSoftInputOnFocus={false} worked on newer Android versions, but on older Android 10 warehouse terminals, the keyboard still flashed intermittently and caused focus drops.

The solution was to place the <TextInput> completely off-screen:

// Off-screen listener that captures hardware bursts without triggering on-screen keyboard
<TextInput
  ref={inputRef}
  onChangeText={handleBufferedInput}
  style={{
    position: 'absolute',
    top: -9999,
    left: -9999,
    width: 1,
    height: 1,
    opacity: 0,
  }}
  autoFocus={true}
  showSoftInputOnFocus={false}
  autoComplete="off"
  autoCorrect={false}
  spellCheck={false}
  keyboardType="default"
/>

Dealing with Laser Bounce

Warehouse operators work fast and often pull the scanner trigger twice in rapid succession. Without debouncing, a single carton would register as two identical scans within 40ms.

I added a short 150ms deduplication window: if the incoming barcode matches the last processed barcode within that interval, it gets ignored. But if a different barcode comes in immediately (e.g. rapid-scanning two distinct boxes), it processes instantly.

When a scan succeeds, the app plays an immediate acoustic chirp via expo-audio and triggers an expo-haptics vibration. In a loud cold-storage room where operators wear thick thermal gloves, physical audio and vibration feedback let workers confirm a scan without looking at the screen every single time.


3. Local Persistence: SQLite over AsyncStorage

When you are logging hundreds of scans in a session, using AsyncStorage is asking for trouble. It serializes data across the bridge as raw JSON strings, and if the handheld runs out of battery mid-shift, you risk corrupted storage.

I used expo-sqlite with Write-Ahead Logging (WAL) enabled:

export async function createTables(db: SQLiteDatabase): Promise<void> {
  // Enable high-performance concurrent writes
  await db.runAsync('PRAGMA journal_mode = WAL');
  await db.runAsync('PRAGMA foreign_keys = ON');

  await db.runAsync(`
    CREATE TABLE IF NOT EXISTS products (
      id TEXT PRIMARY KEY,
      name TEXT NOT NULL,
      minWeight REAL NOT NULL DEFAULT 0,
      maxWeight REAL NOT NULL DEFAULT 0,
      decimalPlaces INTEGER NOT NULL DEFAULT 0,
      layoutId TEXT
    )
  `);

  await db.runAsync(`
    CREATE TABLE IF NOT EXISTS readings (
      id TEXT PRIMARY KEY,
      productId TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
      weight REAL NOT NULL,
      createdAt TEXT NOT NULL
    )
  `);

  await db.runAsync('CREATE INDEX IF NOT EXISTS idx_readings_productId ON readings(productId)');
  await db.runAsync('CREATE INDEX IF NOT EXISTS idx_readings_createdAt ON readings(createdAt)');
}

In WAL mode, SQLite writes take under 4 milliseconds and don’t block UI renders. Even with 1,500 scans logged in a single batch, calculating the running total weight and box count with indexed SQL queries takes less than 2ms.


4. Getting Data Out Without Cloud Sync

Because the app operates air-gapped, we couldn’t rely on background cloud sync. If an operator finishes a shift and needs to hand the tally to dispatch, they need the document immediately.

I built two export paths directly on the device:

  1. On-Device PDF Generation (expo-print): The app formats the session’s readings into a clean HTML table showing carton counts, individual weights, and total gross weight. Calling expo-print compiles this into a PDF directly on the phone in about half a second.
  2. Local CSV Export (expo-file-system): For managers who need to import raw weights into their inventory spreadsheets, the app writes a standard CSV file to device storage.

From there, operators share the file via Bluetooth, Wi-Fi Direct, or a USB-C flash drive using the standard system share sheet.


What I Learned from This Build

  1. Don’t put network calls in the critical loop of physical work. If someone is holding a 25kg box on a scale, your app should never make them wait on an HTTP request. Validate and save everything locally first.
  2. Physical hardware will always surprise you. Scanners prepend non-printable characters, laser triggers bounce, and standard mobile UI components like <TextInput> aren’t designed for barcode guns by default. You have to test on the actual rugged hardware, not just the simulator.
  3. SQLite with WAL mode is rock solid on mobile. For any app doing frequent writes or audit logs, native SQLite is faster, safer, and far more reliable than shoving stringified JSON arrays into key-value storage.
Muhammad Riyad

Have thoughts or a similar problem to solve?

I write about real systems, trade-offs, and software architectures. Feel free to connect or discuss your technical challenges directly.