Source material for the freeCodeCamp article. Distilled from DEVLOG.md (the
chronological record), PLATFORM-NOTES.md (the comparison) and
GOTCHAS.md (the traps).
Every number here was read out of the code or a passing test, not from memory. Where a claim is unverified it says so. §9 lists the things most likely to be misremembered when drafting — read it before writing anything from recollection.
⛔ throughout means never run on an Android device. No Android hardware has been available since Phase 1.
TapCard — an NFC app for iOS, with an Android counterpart that compiles and has never run. Write your contact details to a physical NFC tag as a URL or a vCard; read any NDEF tag back in human-readable form. No backend; the profile lives on the device and goes straight onto the chip.
The article is not really about business cards. It is about three things the project kept running into:
- NFC has no simulator path on either platform. Every claim must come from hardware. That is the defining constraint, and it shaped the architecture — all the decoding logic is pure and device-free precisely so that something could progress while a phone was unavailable.
- The two platforms are asymmetric in ways a cross-platform library has to hide, and hiding them
is sometimes a lie.
isEnabled()is a real toggle on Android and a meaningless question on iOS. The honest API says so. - What it takes to stop depending on someone else's native module — and what you inherit when you delete one.
It ends with react-native-nfc-manager removed from package.json and the app running entirely on
a hand-written Expo Module in Swift and Kotlin.
The two best beats are both mistakes, and both are documented rather than tidied away: a wrong conclusion about iOS's capabilities that survived three documents (§4), and a bug that 272 passing tests were structurally incapable of catching (§6).
One TypeScript boundary, lib/nfcBackend.ts (121 lines). No screen talks to the native layer.
startNfc(): Promise<boolean>
checkNfcStatus(): Promise<NfcStatus> // checking | ready | disabled | unsupported
readTag(): Promise<ReadResult> // { tag, capacity }
writeTag(bytes): Promise<WriteOutcome> // { status, capacity, written, verified, verifyNote }
cancelScan(): Promise<void> // Android only, by necessityThat indirection is the whole reason swapping the entire native implementation was a one-line change rather than a rewrite. It existed before it was needed, which is the only time such a thing is cheap.
The decoding layer is pure TypeScript and platform-agnostic. Data / ByteArray do not cross
the bridge, so records arrive as number[] from both platforms and one decoder handles both:
| Module | Lines | Job |
|---|---|---|
lib/ndef.ts |
535 | Decoder: TNF dispatch, URI prefix table, text status byte, UTF-8/16 |
lib/ndefEncode.ts |
140 | Encoder: records → bytes, longest-prefix compression |
lib/vcard.ts |
168 | vCard 3.0: escaping, octet-safe folding |
lib/capacity.ts |
~170 | Will it fit — reported vs assumed |
lib/scanError.ts |
234 | Native exceptions → user-facing copy |
lib/tagFacts.ts |
164 | Tag → rows, with an explicit "not reported" state |
Phase 4 replaced the native bridge and left every line of that untouched. Worth saying in the article: the replacement was scoped to the part that actually had to be native.
Session-based. NFCTagReaderSession → delegate → four nested async steps: begin → detect →
connect → queryNDEFStatus → readNDEF. The OS draws a sheet you cannot restyle.
| File | Lines | What |
|---|---|---|
NfcReadSession.swift |
187 | The read, and settling one promise from five paths |
NfcWriteSession.swift |
224 | Query → refuse → write → verify, one session |
NfcExceptions.swift |
135 | 13 typed exceptions with a code and a message |
NfcTagInfo.swift |
110 | Tag → bridge conversions |
NfcNativeModule.swift |
121 | The Expo module definition |
Three things CoreNFC demands and does not tell you:
- The session must be retained by the module, not the function. A local is deallocated on return and the sheet vanishes with no error.
- A successful read also invalidates the session, so
didInvalidateWithErrorfires afterwards and will overwrite your result unless settling is guarded. - Entitlements are gated per polling option.
.iso18092needs its own, and asking without it fails the entire session.
What iOS front-loads: an App ID registered in a web portal, a capability ticked on it, a paid membership, a certificate, a provisioning profile — all before reading a single byte. Get it wrong and the failure is a code-signing error that never says "NFC".
The article is framed as an iOS handbook. Android appears once, at the end, clearly labelled as written-but-never-executed. Do not draft anything that implies parity, and do not show Android output — there isn't any.
Reader mode, not a session. NfcAdapter.enableReaderMode(activity, callback, flags, extras) —
a callback bound to the foreground Activity, firing on every tag, with no UI and no natural end.
| File | Lines | What |
|---|---|---|
NfcReaderSession.kt |
192 | One operation, read or write |
NfcNativeModule.kt |
162 | Module definition, capabilities, 12 typed exceptions |
NfcTagInfo.kt |
60 | Tag → bridge, same shapes as the Swift |
Everything iOS handles for you becomes yours:
| iOS | Android | |
|---|---|---|
| Scanning UI | The OS draws a sheet | The app draws everything |
| Session end | Automatic after one tag | disableReaderMode on every exit path |
| Needs | Nothing on screen | The foreground Activity |
| Callback thread | Main | A binder thread |
| Cancelling | The system sheet | Build it yourself |
| Permission | Entitlement + portal + paid account | One manifest line, free |
Status: compiles clean (BUILD SUCCESSFUL, zero Kotlin errors, zero warnings in our module),
android.permission.NFC confirmed in the merged manifest, and not one line has ever run.
The narrative spine, and the reason it is more interesting than "we wrote a module":
Phase 1–3 build on react-native-nfc-manager. Phase 4 replaces it. The original
justification — iOS cannot report tag capacity — turned out to be false (§4 below), so the
phase was re-decided rather than quietly continued:
in case someone prefers to build their own package, or the company is huge on doing things internally
The evidence that justifies it was gathered by reading the dependency, not by hitting bugs in production:
| Finding | File |
|---|---|
| Text decoder measures the language code's length, then discards the code (the extracting line is commented out) | ndef-lib/ndef-text.js |
UTF-16 flag ignored entirely — an open TODO |
ndef-lib/ndef-text.js |
String.fromCharCode truncates above U+FFFF: U+1F600 → U+F600 |
ndef-lib/util.js |
index.d.ts is invalid TypeScript (TS1246), compiles only because skipLibCheck is on |
index.d.ts |
Package root throws outside a native runtime (builds a NativeEventEmitter at load) |
src/NativeNfcManager.js |
All 24 error classes constructed with no arguments, so message is always '' |
src/NfcError.js |
The switch was made on measurement, not preference. Both implementations read the same physical chip and were diffed field by field: 4 identical, 2 reported only by ours, 0 conflicts.
The evidence was kept after the dependency was deleted — vendor/react-native-nfc-manager/
holds a frozen copy with its MIT licence, imported by nothing but lib/vendorEvidence.test.ts, and
excluded from ESLint and Prettier because its value is being wrong in documented ways.
Phases 1–2 concluded iOS cannot report tag capacity, said so in DEVLOG, PLATFORM-NOTES and the UI, and built Phase 4's motivation on it.
- What was observed (true):
getTag()on iOS returns{ id, tech }— nomaxSize. - What was concluded (false): the platform cannot answer the question.
ndefHandler.getNdefStatus() → CoreNFC's queryNDEFStatus returns both a read/write status and a
real capacity, inside the session requestTechnology already opens. The capability was one call
away from code that had been running since Phase 1.
The generalisable point, and the best line in the project:
The rule was "nothing is written down as fact until observed on a device". That rule was applied to the observation and abandoned for the inference built on top of it. An unverified conclusion is exactly as dangerous as an unverified measurement, and harder to notice, because it arrives wearing the credibility of the real data underneath it.
A contributing bug made it harder to find: the write pre-flight threw the library's own
TagSizeTooSmall, making our refusal indistinguishable from CoreNFC's and erasing the one signal
that would have revealed a capacity had been reported. Never throw a dependency's error type from
your own logic.
After switching to the native module, cancelling a scan rendered a red "Could not read the tag" card instead of nothing.
Our Swift throws UserCancelledException. The mapping table was keyed on UserCancelledException.
They do not match, because Expo derives the code: strip the trailing Exception, split
camelCase, upper-case, prefix ERR_ → ERR_USER_CANCELLED
(expo-modules-core/ios/Core/Exceptions/CodedError.swift:45).
Why every test passed:
const wrapped = (code, message) =>
new Error(`Calling the 'readTag' function has failed → Caused by: ${code}: ${message}`);No code property — because we did not know Expo set one. The code and its tests shared a single
wrong assumption and agreed with each other perfectly.
A fixture you invented can only prove your code is self-consistent. It took a thumb on a Cancel button.
'\;' in a JavaScript string literal is ';' — an unknown escape silently drops the backslash, so
the vCard escaper escaped nothing. Then the test asserted the unescaped result and failed against
correct code. Then a third test used not.toContain('N:'), which can never pass because
BEGIN:VCARD contains N:.
Test-first would not have helped: the test and the code shared the misunderstanding. String escaping is a domain where they usually do.
A dozen of the 60 traps in GOTCHAS.md produce no error at all: the deallocated session, the
removed config plugin, the unescaped semicolon, the truncated emoji, the gitignored native module,
the empty error message. The article's strongest practical advice may simply be: in NFC work,
assume the failure will be silent and design your checks accordingly.
All read from code or passing tests on 2026-09-13.
The tag — a real NTAG213, iPhone 13 Pro, iOS 26.5:
| UID | 04C4FC91DF2A81 (7 bytes; 04 = NXP) |
iOS getTag() returns |
{ "id": "04C4FC91DF2A81", "tech": "mifare" } — two keys |
| Reported capacity | 137 bytes (max NDEF message) |
| Chip user memory | 144 bytes (36 pages × 4, pages 4–39) — not the number that matters |
| NDEF status | 2 = read-write |
Payload sizes (pinned by tests, so the article cannot drift from the app):
| Payload | Encoded message |
|---|---|
https://example.com/fas as a URI record |
20 bytes |
| Realistic vCard, as text | 200 bytes |
| The same vCard as an NDEF message | 213 bytes |
| TLV framing the tag adds | 3 bytes (5 once ≥255) |
| Verdict on a 137-byte tag | URL fits with 117 spare; vCard is 76 over |
The project:
| Tests | 243, 12 suites, ~1s, no device needed |
| Hand-written native | ~777 lines Swift, ~414 lines Kotlin |
| Pure TypeScript logic | ~1,450 lines across 8 modules |
| Runtime dependencies | 28 (NFC: zero) |
| Full iOS rebuild | ~1.5 GB DerivedData |
| First Android build | 13m 40s, cold cache |
expo-doctor |
20/21 (the one failure is upstream SDK patch drift) |
Stack: Expo SDK 57.0.20 · React Native 0.86.3 · React 19.2.3 · expo-router · NativeWind · Zustand + AsyncStorage · pnpm · no third-party NFC dependency.
URI prefix table: 36 entries. https:// is index 0x04 — one byte instead of eight.
- "An unverified conclusion is exactly as dangerous as an unverified measurement, and harder to notice, because it arrives wearing the credibility of the real data underneath it."
- "A fixture you invented can only prove your code is self-consistent."
- "One API's silence is not a platform limitation."
- "Never throw a dependency's error type from your own logic. It collapses 'we refused' and 'they refused' into one signal, and you will want to tell them apart precisely when something is going wrong."
- "Owning the native side does not exempt you from error plumbing; it changes which layer surprises you."
- "Ask only for what you can sign for."
- "Removing a dependency means inheriting its build configuration. The code it exports is the visible half."
- "A tag fact is not just a value or zero — it can be this platform does not tell us, and that is a different thing."
- "Android volunteers this information with an ordinary read; iOS makes you ask a specific question inside a session. Same data, different price of admission."
- "A comment saying 'the library is buggy' rots in silence; a test saying so cannot."
- "The scaffolding is no longer the hard part."
- "There is no simulator path. No amount of unit testing, mocking or CI substitutes for holding a chip against a phone."
- "iOS front-loads the pain and Android back-loads it."
- On the vCard trade: "A URL is tiny and universally handled — and completely dependent on something answering at the other end. A vCard is the whole card, and does not fit."
- On what the replacement bought: "Not speed, and not fewer lines. The next decoder bug is an afternoon's work instead of an issue on someone else's tracker."
Read this before drafting from memory. Two claims were corrected mid-project, and the earlier versions are still present in the repo under "superseded" banners — deliberately, because how the mistake was made is the teaching material. Quoting them as current would be wrong.
| Do not write | Write instead |
|---|---|
| "iOS cannot report tag capacity" | iOS reports it via getNdefStatus() / queryNDEFStatus, inside the session. Only getTag() omits it. |
| "An NTAG213 holds 144 bytes" | 144 is user memory. The max NDEF message is 137, and that is what a writer needs. |
| "Phase 4 exists because iOS can't read capacity" | Phase 4 exists to own the native layer, for teams that cannot take a third-party dependency. |
| "Expo uses your exception class name as the error code" | It derives ERR_USER_CANCELLED from UserCancelledException. |
"The scan sheet shows NFCReaderUsageDescription" |
It shows the per-call alertMessage. The usage description is never user-facing. |
"isEnabled() is false when NFC is off" |
True on Android. On iOS there is no toggle, so the question is meaningless and the honest answer restates "supported". |
Also easy to get wrong:
- The capacity model does not add TLV framing to the message before comparing. It did once; that was double-counting. The framing is displayed, never counted.
lib/writeError.tsand the JS pre-flight no longer exist — the pre-flight moved into Swift, and the file became unreachable.- The parity harness and the capabilities card were deliberately deleted in T10. They existed to compare against a dependency that is gone.
- Every Android claim is unverified. The Kotlin compiles and mirrors the Swift; nothing has run. If the article shows Android screenshots or output, they do not exist yet.
- Android on hardware. Read, write, cancel,
NfcDisabledException,techTypes, and every ⏳ in PLATFORM-NOTES. - Formatting an unformatted tag. Android has
NdefFormatable; iOS exposes no formatting API at all —writeNDEFsimply fails on a non-NDEF tag. Needs a genuinely unformatted chip. - Phase 5 — read-only locking (permanent; needs a chip named as sacrificial first), an EAS build comparison, and article prep.
- Background tag reading — Android's
NDEF_DISCOVEREDintent filter versus iOS's OS-mediated notification. The four-way matrix in PLATFORM-NOTES §4 is still entirely ⏳.