Web NFC: Reading and Writing Tags in the Browser

This guide is part of Native Device Integration Patterns, which covers the full family of browser APIs that bridge web apps to device hardware.

Web NFC lets progressive web apps scan and write NFC tags without native app installation, using the NDEFReader interface in Chrome for Android. Without correct feature gating and permission handling, your app will throw silent NotAllowedError failures or attempt NFC calls on iOS where the API does not exist at all. This guide walks the end-to-end workflow — from secure context validation and NDEF record parsing to writing tag payloads and activating a fallback for unsupported platforms.


Web NFC API read/write flow Flowchart showing the decision path from page load through secure context and feature detection, user gesture, NDEFReader scan or write, NDEF payload decoding, and graceful degradation to a QR/manual fallback on unsupported browsers. Page load isSecureContext? Feature detect 'NDEFReader' in window User gesture click / touchstart NDEFReader .scan() NDEFReader .write(records) Decode NDEF text / url / mime Tag written success path Fallback UI QR code / manual entry Read Write No HTTPS Not supported Dashed = unsupported / error path Solid = happy path

Prerequisites

Before writing a single line of NFC code, confirm:

  • HTTPS or localhostwindow.isSecureContext must be true; NFC is blocked on plain HTTP
  • Chrome for Android 89+ or ChromeOS — no other browser exposes NDEFReader
  • NFC hardware enabled — Android NFC toggle in system Settings must be on
  • nfc permission — Chrome will prompt on first scan() call; repeated denials escalate to the blocked state
  • No third-party iframes calling the API — the top-level frame must initiate the gesture

Browser Support

Browser Platform Minimum version NDEFReader available Notes
Chrome Android 89 Yes Full read + write
Chrome ChromeOS 89 Yes Read + write, hardware-dependent
Chrome Desktop (Win/Mac/Linux) any No NFC hardware not exposed
Safari iOS / macOS any No Use CoreNFC in a native wrapper
Firefox Android / Desktop any No No implementation planned
Edge Android any No Chromium base, feature flag off
Samsung Internet Android any No No implementation

Always gate usage with 'NDEFReader' in window — do not infer support from the user agent string.

Step-by-Step Implementation

Step 1 — Validate the environment

Run both checks synchronously before constructing NDEFReader. Fail fast and activate the fallback immediately if either check fails.

// nfc-environment.js
export function validateNfcEnvironment() {
  if (!window.isSecureContext) {
    throw new DOMException(
      'Web NFC requires a secure context (HTTPS or localhost).',
      'SecurityError'
    );
  }
  if (!('NDEFReader' in window)) {
    throw new DOMException(
      'NDEFReader is not supported in this browser.',
      'NotSupportedError'
    );
  }
}

Log the result to analytics to track NFC hardware adoption across your user base — the data informs how aggressively to promote NFC workflows versus fallbacks.

Step 2 — Gate scanning inside a user gesture

NDEFReader.scan() is a transient user activation-gated call. Calling it programmatically outside a click or touchstart handler causes an immediate NotAllowedError. The AbortController lets you cancel an active scan cleanly when the user dismisses the NFC sheet or navigates away — this is essential for avoiding stale listeners when pairing NFC with async payload formatting patterns.

// nfc-scanner.js
import { validateNfcEnvironment } from './nfc-environment.js';
import { handleNfcError } from './nfc-errors.js';
import { handleRecord } from './nfc-records.js';

const nfcReader = new NDEFReader();
let scanController = null;

export async function startNfcScan() {
  const triggerBtn = document.getElementById('scan-btn');
  triggerBtn.disabled = true;

  try {
    validateNfcEnvironment();

    // Cancel any in-progress scan before starting a new one
    scanController?.abort();
    scanController = new AbortController();

    nfcReader.addEventListener(
      'reading',
      ({ message, serialNumber }) => {
        for (const record of message.records) {
          handleRecord(record);
        }
      },
      { signal: scanController.signal }
    );

    await nfcReader.scan({ signal: scanController.signal });
  } catch (error) {
    handleNfcError(error);
  } finally {
    triggerBtn.disabled = false;
    triggerBtn.textContent = 'Scan Again';
  }
}

// Must be bound to a real user gesture
document.getElementById('scan-btn').addEventListener('click', startNfcScan);

// Clean up on navigation
window.addEventListener('beforeunload', () => scanController?.abort());

Step 3 — Decode NDEF records

The 'reading' event fires once per tag presentation. Each NDEFRecord carries a recordType string and a data property typed as DataView. Use TextDecoder for text-based records and JSON.parse for JSON MIME payloads.

// nfc-records.js
export function handleRecord(record) {
  try {
    switch (record.recordType) {
      case 'text': {
        const decoder = new TextDecoder(record.encoding ?? 'utf-8');
        const text = decoder.decode(record.data);
        console.log('Text payload:', text);
        break;
      }
      case 'url':
      case 'absolute-url': {
        const url = new TextDecoder().decode(record.data);
        console.log('URL payload:', url);
        break;
      }
      case 'mime': {
        if (record.mediaType === 'application/json') {
          const json = JSON.parse(new TextDecoder().decode(record.data));
          console.log('JSON payload:', json);
        } else if (record.mediaType === 'application/octet-stream') {
          // Binary payload — process as ArrayBuffer
          const buffer = record.data.buffer;
          console.log('Binary payload bytes:', buffer.byteLength);
        }
        break;
      }
      default:
        console.warn('Unsupported record type:', record.recordType);
    }
  } catch (decodeError) {
    console.error('Payload decode failed:', decodeError);
  }
}

Step 4 — Write NDEF records to a tag

Construct an array of plain NDEFRecord-shaped objects and pass them to NDEFReader.write(). Before transmitting, estimate the encoded byte length and compare against the target tag’s capacity. Common tag capacities:

Tag type Usable storage
NTAG213 144 bytes
MIFARE Ultralight 46–144 bytes
NTAG215 496 bytes
NTAG216 888 bytes

NDEF framing adds roughly 5–10 bytes per record header; account for that in your estimate.

// nfc-writer.js
import { validateNfcEnvironment } from './nfc-environment.js';

const nfcReader = new NDEFReader();

export async function writeNfcTag(payload) {
  validateNfcEnvironment();

  const encoder = new TextEncoder();
  const records = [
    { recordType: 'url', data: payload.url },
    { recordType: 'text', data: payload.label, lang: 'en' },
    {
      recordType: 'mime',
      mediaType: 'application/json',
      data: encoder.encode(JSON.stringify(payload.metadata))
    }
  ];

  // Estimate total encoded bytes (excludes NDEF framing overhead)
  const totalBytes = records.reduce((sum, r) => {
    const raw = typeof r.data === 'string'
      ? encoder.encode(r.data)
      : r.data;
    return sum + raw.byteLength;
  }, 0);

  if (totalBytes > 800) {
    throw new RangeError(
      `Payload (${totalBytes} bytes) likely exceeds tag capacity. Reduce record data.`
    );
  }

  try {
    await nfcReader.write({ records });
    console.info('NFC tag written successfully.');
  } catch (error) {
    if (error.name === 'NotAllowedError') {
      throw new Error('Write permission denied or tag is read-only.');
    }
    if (error.name === 'NotSupportedError') {
      throw new Error('Tag format unsupported or capacity exceeded.');
    }
    throw error;
  }
}

Step 5 — Handle errors and activate the fallback

Every DOMException name maps to a specific user-visible scenario. Handle AbortError silently — it is a deliberate cancellation. For all other failures, hide the NFC primary action and show a fallback, such as the QR code cross-device sharing component, so iOS users and unsupported desktops still get a working share path.

// nfc-errors.js
export function handleNfcError(error) {
  const fallbackContainer = document.getElementById('nfc-fallback');
  const primaryAction = document.getElementById('primary-nfc-action');

  // User cancelled intentionally — do not alter the UI
  if (error.name === 'AbortError') {
    console.warn('NFC scan cancelled by user or signal.');
    return;
  }

  const messages = {
    SecurityError: 'NFC access requires HTTPS. Switching to QR scanner.',
    NotAllowedError: 'NFC permission denied. Please allow NFC in browser settings.',
    NotSupportedError: 'NFC is not available in this browser. Switching to manual entry.',
    NetworkError: 'Tag communication failed. Move closer to the tag and try again.'
  };

  fallbackContainer.textContent =
    messages[error.name] ?? 'An unexpected NFC error occurred. Please refresh.';

  primaryAction.hidden = true;
  fallbackContainer.hidden = false;

  // Emit telemetry without blocking the UI thread
  navigator.sendBeacon?.(
    '/api/telemetry/nfc-errors',
    JSON.stringify({ name: error.name, timestamp: Date.now() })
  );
}

Platform Gotchas

iOS — no path to NDEFReader. Every iOS browser, including Chrome for iOS, runs on WebKit and does not expose NDEFReader. Any NFC workflow on iOS requires a native wrapper using CoreNFC. For pure web deployments, your only options are QR code generation or a deep link via SMS URI — both of which the permission flows progressive enhancement patterns document in full.

Android — permission lifecycle. Once a user denies the NFC permission twice, Chrome moves it to the “blocked” state. NDEFReader.scan() will then throw NotAllowedError permanently until the user manually resets permissions in Chrome site settings. Consult the Web NFC permission denied troubleshooting guide for the full escalation and retry strategy.

ChromeOS. The NDEFReader API is present but depends on physical NFC hardware. Chromebooks without an NFC antenna will throw NotSupportedError at scan time even though the feature check passes. Add a secondary check: catch NotSupportedError from scan() itself, not only from the feature-detect.

Foreground tab requirement. Web NFC is suspended when the tab is hidden (page visibility 'hidden'). If the user switches apps mid-scan, the scan() promise does not reject — it silently pauses. Resume listening by re-calling scan() inside a visibilitychange handler when document.visibilityState returns to 'visible'.

Read-only tags. Attempting .write() on a factory-locked tag throws NotAllowedError, not NotSupportedError. Check the tag’s lock status separately if your use case requires discriminating locked from permission-denied states.

Testing and Verification

Physical device checklist:

  1. Open DevTools on the connected Android device via chrome://inspect
  2. Confirm window.isSecureContext === true in the console
  3. Confirm 'NDEFReader' in window returns true
  4. Tap the scan button, grant the NFC permission prompt, and present a blank NTAG213 tag
  5. Verify the 'reading' event fires and the console shows the decoded record payloads
  6. Run the write workflow, re-scan the tag, and confirm the payload matches what you wrote

DevTools tips:

  • Use the Sensors panel (More tools → Sensors → NFC) — Chrome 91+ on desktop can simulate NFC tag presentations without physical hardware. Inject a text record and confirm your handleRecord branch runs.
  • The Application → Permissions panel shows the current nfc permission state for the origin; reset it there to simulate a fresh user.
  • Add a nfcreader.addEventListener('readingerror', ...) listener during development to surface tag communication errors that do not propagate through scan()'s promise rejection.

Automated testing note: The Web NFC API is not available in headless Chromium or Playwright/Puppeteer by default. Use the Chrome DevTools Protocol NFC simulation only in integration tests running against a headed Chrome instance.

FAQ

Does Web NFC work on iOS Safari?

No. Web NFC is available only in Chrome for Android (89+) and ChromeOS. iOS requires a native app or a native CoreNFC wrapper. For web-only iOS users, implement a QR code or manual input fallback — the QR code generation guide covers the full rendering and fallback routing workflow.

How do I handle permission denials gracefully?

Catch NotAllowedError from NDEFReader.scan() or .write(), display a clear explanation with an alternative action (QR scanner, manual form), and provide a retry button that calls scan() inside a fresh user gesture. For the full escalation strategy including exponential back-off, see the Web NFC permission denied troubleshooting guide.

What NDEF record types are supported?

The Web NFC API supports text, url, absolute-url, and mime (for typed media). The recordType field uses NDEF well-known type short names. Custom binary payloads use mime with mediaType: 'application/octet-stream' and an ArrayBuffer in data.

Can Web NFC run in the background?

No. Web NFC requires an active, visible tab and explicit user activation. Background execution is restricted by browser security policies to prevent unauthorised tag scanning. Scanning pauses automatically when the tab is hidden.

How do I structure a multi-record NDEF message?

Pass an array of record objects to write(). The first record should be the most actionable type — url if the tag is intended to open a page, text for human-readable metadata, then any mime records for structured data. Android’s default NFC dispatcher reads only the first record to determine launch intent, so ordering matters for cross-app compatibility.