JavaScript Web NFC Table (NDEFReader)
| Piece | What it does | Field note |
|---|---|---|
new NDEFReader() | The reader handle | one object drives the whole session: arm it, listen on it, write through it - stateless, cheap, one per interaction |
ndef.scan() | The read session | promise resolves once reading is scheduled; triggers the nfc permission prompt on first use - call it from a visible user gesture |
ndef.onreading | The tag feed | NDEFMessage event each time a tag enters the field; message.records is the payload array, serialNumber a bonus identifier |
ndef.onreadingerror | The dead-tag lane | tag present but unreadable: corrupted sector, foreign layout, locked region - show a retry affordance, not a stack trace |
ndef.write(data) | The tag write | string, BufferSource or records array; MDN runs it inside the reading handler with an ignoreRead flag - write the tag you verified, not the one that wandered past |
record.recordType | The payload typing | empty, text, url, smart-poster, absolute-url, mime, unknown, plus local and external type names - the switch that routes your decoder |
record.data + toRecords() | The bytes | data is a DataView (byteLength tells the size); toRecords() unwraps container types into nested records |
Chromium on Android only | The platform gate | secure context, Experimental not Baseline, no iOS Safari, desktop usually lacks the hardware - and the page must stay visible while reading |
Web NFC is the browser window onto NFC tags - the little passive chips inside posters, product labels, transit cards and desk badges. The interface is NDEFReader, named after NDEF (NFC Data Exchange Format), the standardized payload layout most tags carry. The flow is deliberately small: you construct a reader, call scan() to arm it, and every time a tag enters the phone field an NDEFMessage event arrives with its records; call write() and the next tap pushes your message onto a tag instead. No pairing, no device discovery, no sockets - the tag has no power and no opinion, the phone supplies the field and the browser supplies the prompt.
Bottom line: one constructor, two promises and two events. scan() arms reading and triggers the NFC permission prompt on first use, so call it from a visible user gesture; onreading hands you message.records each time a tag lands; onreadingerror covers the tag that is physically present but returns garbage - corrupted sectors, unknown layouts, locked regions. Writing has one proven shape: do it from inside the reading handler, exactly the way the MDN example does with an ignoreRead flag. You read the tag, decide it is the right one, then write - instead of blind-firing a write at whichever tag wanders past and hoping. NDEF writes replace the tag message wholesale, so verify-then-write is not paranoia, it is the data-safety model.
The platform reality: this is Chromium-on-Android territory. Secure context required, marked Experimental (not Baseline), no iOS Safari, and desktop machines usually lack NFC hardware entirely - the permission prompt may succeed and then no tag ever arrives. The records themselves are typed: recordType discriminates text, url, mime, smart-poster, absolute-url and more, and each record carries data as a DataView plus metadata (encoding and language for text, mediaType for mime). Treat the API as tags-only infrastructure - it reads and writes NDEF payloads, not payment terminals, not peer devices, not card emulation - and it only runs while your page is at least partially visible. For everything background or peer-to-peer shaped, that is a native app conversation.
How to use
- Feature-detect before you promise NFC in the UI: if (!('NDEFReader' in window)) route to a QR code or manual-entry fallback - on iOS Safari and most desktops the constructor is absent, and that check is one line that saves you a support thread.
- Arm and listen: const ndef = new NDEFReader(); await ndef.scan(); ndef.onreading = ({message, serialNumber}) => message.records.forEach(r => decode(r)); - scan() prompts for the nfc permission on first use and resolves once reading is scheduled, not when a tag arrives.
- Decode a record by its type: switch (r.recordType) - for 'text' use new TextDecoder(r.encoding).decode(r.data), for 'url' the same decoder on r.data, for 'mime' inspect r.mediaType first (application/json, image/png...); r.data is always a DataView, so slice it for binary types.
- Write from the reading handler: ndef.onreading = async (event) => { if (!ignoreRead) return; ignoreRead = false; await ndef.write('Hello NDEF'); } - the MDN-documented read-then-write pattern lands your payload on the tag you just verified instead of the next tag in the room, and NDEF writing replaces the whole message.
- Abort the session when the page moves on: pass an AbortSignal - ndef.scan({signal: controller.signal}) and ndef.write(data, {signal: controller.signal}) both accept one - and abort on pagehide so an armed reader does not survive the context that wanted it.
Frequently asked questions
Why is the recommended write pattern read-first, and what does write() actually do to a tag?
Because NDEF writing is message-level replacement, not an append. A compatible tag holds one NDEF message - an array of typed records - and ndef.write(data) swaps that whole message for yours: pass a string (one text record), an ArrayBuffer or TypedArray (raw bytes), or a records array to compose multiple payloads. Fire a write blind and you overwrite whatever the tag carried - a URL someone printed on packaging, a maintenance history, anything - with no undo. The read-first pattern fixes targeting, not just safety: onreading fires for every tag that enters the field, so a write issued outside the handler lands on whichever tag the user happens to tap next. Run the write inside the handler and you are writing the tag whose records you just inspected - the MDN example formalizes this with an ignoreRead flag: first tap reads and sets the flag, second tap (same tag back in the field, or held steady) performs the write. Two practical notes: the write also goes through the permission prompt on first use, and if the tag region is locked or the layout is foreign, the attempt surfaces through onreadingerror or a rejected write promise - read the tag you intend to erase, and show its decoded content for confirmation before you do.
What record types exist, and how do I turn record.data into something human-readable?
recordType is the switch statement: 'empty' (nothing to decode), 'text' (r.data decoded with new TextDecoder(r.encoding) - r.encoding names utf-8 or utf-16, r.lang the language tag), 'url' (same TextDecoder move, payload is a URI string), 'absolute-url' (a URL that doubles as the record identifier), 'mime' (arbitrary media - check r.mediaType, then parse: application/json gets JSON.parse on the decoded string, image bytes go to a Blob), 'smart-poster' (a poster container holding nested url/text/action records - unwrap with r.toRecords()), 'unknown' (opaque bytes, no structure promised), plus 'local type name' and 'external type name' entries for organization-defined and namespaced payloads. The two accessors: r.data is a DataView whose byteLength tells you the payload size, and r.toRecords() returns a promise of the nested NDEFRecord array for container types - the same record shape recursively, so one decoder function handles posters that hold URLs that hold text. Every record also carries r.id when the tag author named it, which is worth surfacing in your UI before deciding what the payload means - an id like 'maintenance-log' is intent documentation the bytes cannot give you.
Which browsers and devices actually run Web NFC, and what happens where it is missing?
The supported set is Chromium-based browsers on Android - Chrome and Edge on NFC-equipped Android phones are the real audience; Apple ships no Web NFC in iOS Safari, and Firefox has it behind flags at best, so treat everything outside Android Chromium as unsupported. It is marked Experimental on MDN (not Baseline), meaning the surface can still move - pin your expectations to scan, write, reading and the record model, which have been stable for years. Two hardware notes: desktop Chrome on a laptop without an NFC reader will happily show the permission prompt and then never produce a reading event - your UI should present scanning as a waiting state, not an instant success; and the phone NFC reader must be enabled at the OS level, which is a setting your page cannot flip. Secure context is mandatory (HTTPS or localhost). Missing-API handling is a one-liner: 'NDEFReader' in window is false everywhere unsupported, so gate the whole feature tree on it and keep a QR-code fallback warm. The visibility rule matters for UX copy: the reader only works while the page is at least partially on screen - switch apps and the session goes quiet until you return.
What can Web NFC not do - where are the hard walls of the API?
Five walls, in descending order of surprise. No background operation: reading happens only while your page is visible - there is no watch-a-tag-in-my-pocket mode; if the use case needs background triggers (automation on tap, badge-in readers), that is a native platform conversation. No payments and no card emulation: payment terminals talk to the secure element through protocols Web NFC never exposes - you cannot tap-to-pay or emulate a access card, and transit/ banking interactions that look like NFC are HCE (host card emulation), a different stack. No peer-to-peer: the old Android Beam device-to-device transfers are gone from the platform and were never in scope - NDEFReader sees passive tags, not other phones. No tag lifecycle surgery in the documented surface: MDN documents scan and write; formatting virgin tags, setting access bits and permanent locking live in vendor tooling territory, not the cross-browser web surface - plan around pre-formatted NDEF tags. No iOS: every Safari answer is a wrapper app or a native app. Within those walls the honest sweet spot is exactly what the API was scoped for: read a poster URL, provision a tag with a config payload, put a check-in or inventory action behind a physical tap - deliberate, foreground, user-gestured interactions with dumb little chips.