
Mobile NFC & Contactless Crash Debugging Guide
Near-field communication (NFC) is the silent engine behind tap-to-pay, transit passes, smart posters, and device pairing, yet when a contactless read fails it usually fails loudly: a frozen payment sheet, a force-quit on tag scan, or a background crash that surfaces weeks later in the field. Debugging mobile NFC and contactless crashes is its own discipline, split across Apple's Core NFC framework and Android's NfcAdapter/NDEF stack, each with distinct session lifecycles, intent dispatch rules, and malformed-data edge cases. This guide walks through the highest-frequency crash sources — Core NFC session invalidation, Android foreground dispatch, malformed NDEF parsing, tag disconnects, and Apple Pay / Google Pay wallet crashes — and shows how to instrument each one so the next tap doesn't take your app down.
Why NFC crashes are easy to ship and hard to reproduce
Contactless bugs escape QA because they depend on physical hardware and real-world tags. A test harness can exercise your network layer thousands of times, but it rarely simulates a tag that disconnects mid-read, a malformed NDEF record written by a third-party system, or a payment sheet whose authorization callback never fires because the merchant identifier wasn't registered. Add that NFC sessions on both platforms are time-boxed and one-shot, and you get a crash category overrepresented in production yet nearly invisible in development. Treat NFC like any other I/O surface — wrap it, validate it, and log the state machine around it.
Core NFC session lifecycle on iOS
On iOS, all tag interaction happens inside a session object. The two you will reach for most are NFCNDEFReaderSession, for reading NDEF-formatted tags, and NFCTagReaderSession, for raw read/write access to technologies like ISO 7816 and MIFARE. Both are delegate-driven and misbehave predictably when the lifecycle is mishandled, as documented in Apple's Core NFC framework reference.
The most common Core NFC failure is not a hard crash but a silent dead-end caused by the one-shot session. An NFCNDEFReaderSession invalidates itself after delivering a single tag's NDEF messages, so a UI that calls begin() again without allocating a fresh session will never fire its delegate again and eventually surfaces as a hung scan sheet. Treat each session as single-use — allocate, begin(), deliver, and invalidate() in one tight scope — and nil out the reference in the invalidation delegate.
final class NFCReader: NSObject, NFCNDEFReaderSessionDelegate {
private var session: NFCNDEFReaderSession?
func startScan() {
guard NFCNDEFReaderSession.readingAvailable else { return }
session = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: true)
session?.alertMessage = "Hold your iPhone near the tag."
session?.begin()
}
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
// Deliver exactly once, then let the session tear itself down.
}
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
self.session = nil
}
}Two error paths deserve special handling. First, readerSession(_:didInvalidateWithError:) fires for both normal completion and user cancellation, and the error's NFCReaderError.Code tells you which — readerSessionInvalidationErrorFirstNDEFTagRead is expected, while readerSessionInvalidationErrorSystemIsBusy and readerSessionInvalidationErrorSessionTimeout are worth logging as breadcrumbs. Second, calling begin() without checking NFCNDEFReaderSession.readingAvailable causes a confusing invalidation. Finally, Core NFC requires the com.apple.developer.nfc.readersession.formats entitlement and the "Near Field Communication Tag Reading" capability; a missing entitlement produces a session-start failure that looks nothing like an NFC bug in the stack trace.
Android NfcAdapter and the intent dispatch minefield
On Android, tag delivery is an intent problem, not a delegate problem. When a tag comes into range, the system routes an intent with ACTION_NDEF_DISCOVERED, ACTION_TECH_DISCOVERED, or ACTION_TAG_DISCOVERED to the best-matching activity, as described in the Android NFC basics guide. Crashes here fall into three buckets: intent-filter mismatches that mean your activity never receives the tag, ActivityNotFoundException when no handler exists, and foreground-dispatch wiring that leaks or never detaches.
For an activity that must read tags while it is in the foreground, enableForegroundDispatch is the standard tool — and the source of the most common lifecycle bug: failing to call disableForegroundDispatch in onPause(). A dispatch registered with a PendingIntent that's still active after the activity stops will deliver tag intents to a paused activity or throw. Pair enable and disable symmetrically in onResume/onPause, per the NfcAdapter reference.
class ScanActivity : AppCompatActivity() {
private var nfcAdapter: NfcAdapter? = null
private var pendingIntent: PendingIntent? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
nfcAdapter = NfcAdapter.getDefaultAdapter(this)
pendingIntent = PendingIntent.getActivity(
this, 0, Intent(this, javaClass).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_MUTABLE
)
}
override fun onResume() {
super.onResume()
val filters = arrayOf(IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED).apply {
addDataType("*/*")
})
nfcAdapter?.enableForegroundDispatch(this, pendingIntent, filters, null)
}
override fun onPause() {
super.onPause()
nfcAdapter?.disableForegroundDispatch(this)
}
}If you need to read raw tags without an intent filter — including tags that are not NDEF-formatted — use enableReaderMode, which delivers tags to a callback and turns the screen off while reading. The callback runs on a binder thread, so touching the UI from onTagDiscovered is an instant crash; marshal work back to the main thread. A tech-list filter that doesn't match the tag's technologies is another silent failure: the callback simply never fires.
Malformed NDEF parsing: the most common hard crash
The NDEF format is compact, and its parsers assume a certain amount of good behavior. In the wild, tags are written by parking garages, hotel key systems, and loyalty cards that do not always follow the spec, and your NdefMessage parsing code is the first thing to break. The classic crash is dereferencing a record's payload without checking for null or an empty byte array — a tag with a zero-length payload parses into a record with null data, and String(payload, Charsets.UTF_8) or payload.copyOfRange(...) throws immediately. See the NdefMessage class reference for the full record model.
Every record carries a Type Name Format (TNF) constant that tells you how to interpret the type and payload. TNF_WELL_KNOWN with an RTD_URI record encodes a URI prefix in the first byte; TNF_MIME_MEDIA, TNF_ABSOLUTE_URI, and TNF_EXTERNAL_TYPE all differ. Parsing a URI record as raw UTF-8, or assuming every MIME record is JSON, produces corrupted output or exceptions. Defend every branch:
fun parseNdef(intent: Intent): String? {
val messages = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES) ?: return null
for (raw in messages) {
val message = raw as? NdefMessage ?: continue
for (record in message.records) {
val payload = record.payload ?: continue
return when (record.tnf) {
NdefRecord.TNF_WELL_KNOWN -> decodeUtf8(payload)
NdefRecord.TNF_MIME_MEDIA -> String(payload, Charsets.UTF_8)
else -> null
}
}
}
return null
}The same posture applies on iOS: an NFCNDEFMessage may contain records with empty payloads, and force-unwrapping String(data:encoding:) on arbitrary tag bytes is the same crash in Swift. Assume every payload is hostile until you've validated its length, TNF or type string, and encoding.
Tag disconnects: TagLostException and IOException
Unlike a database connection, a tag can physically leave the reader's field at any moment, and both platforms surface that as an exception rather than a clean error code. On Android, a TagLostException (a subclass of IOException) is thrown when the tag moves out of range mid-transaction, and it is entirely normal — a user who pulls their phone away, or a tag with a weak antenna, triggers it constantly. The crash happens when your code treats it as fatal. Wrap every transceiver call, especially long ISO-DEP transceive() sequences, in a try/catch that recognizes TagLostException and degrades gracefully.
val isoDep = IsoDep.get(tag) ?: return
try {
isoDep.connect()
isoDep.timeout = 3000
val response = isoDep.transceive(apdu)
} catch (e: TagLostException) {
// Tag left the field: retry once or show "hold steady", never crash.
} catch (e: IOException) {
// Transport-level failure — surface a friendly retry UI.
} finally {
runCatching { isoDep.close() }
}On iOS the equivalent is a session invalidation with an error rather than a thrown exception, but the principle is identical: a tag that disconnects mid-read is expected behavior. Record it as a breadcrumb with the tag technology and the operation in flight, not as a fatal fault.
Tag write failures and NdefFormatable
Writing is where contactless debugging gets tricky, because an unformatted tag cannot be written directly — it must first be formatted using the NdefFormatable technology. Writing an NdefMessage to a non-NDEF tag throws immediately, and formatting a read-only tag fails with an IOException you must distinguish from a transient disconnect. Check the tech list and route through the right path:
fun writeTag(tag: Tag, message: NdefMessage) {
val ndef = Ndef.get(tag)
if (ndef != null) {
try {
ndef.connect()
if (!ndef.isWritable) return // read-only tag, don't attempt the write
ndef.writeNdefMessage(message)
} catch (e: IOException) {
// TagLost or write-protected — log and surface retry.
} finally {
runCatching { ndef.close() }
}
} else {
val formatable = NdefFormatable.get(tag) ?: return
try {
formatable.connect()
formatable.format(message)
} catch (e: IOException) {
// Formatting failed — tag may be permanently read-only.
} finally {
runCatching { formatable.close() }
}
}
}Note the ndef.isWritable guard — teams routinely skip it, assuming every NDEF tag is writable, then ship a write path that throws on the huge population of read-only transit and ID tags.
Apple Pay and Google Pay wallet crashes
Contactless payments add a second failure surface on top of the tag stack: the wallet integration itself. On iOS, Apple Pay flows through PKPaymentAuthorizationController (or the older PKPaymentAuthorizationViewController), and the number-one crash is a missing or mismatched merchant identifier. The merchantIdentifier must exactly match an identifier registered in the Apple Developer portal and enabled for Apple Pay, and a mismatch surfaces as a failed authorization with no obvious NFC fingerprint. Validate the merchant ID at startup and fail fast, as Apple's Apple Pay developer documentation recommends.
let request = PKPaymentRequest()
request.merchantIdentifier = "merchant.com.yourcompany.app"
request.supportedNetworks = [.visa, .masterCard, .amex]
request.countryCode = "US"
request.currencyCode = "USD"
request.paymentSummaryItems = [PKPaymentSummaryItem(label: "Order", amount: NSDecimalNumber(string: "12.99"))]On Android, Google Pay goes through the PaymentsClient and the same class of environment bugs: a build configured with WalletConstants.ENVIRONMENT_TEST in production, or an IsReadyToPayRequest whose allowedPaymentMethods don't match the wallet, will return readiness of false or throw a WalletException you must catch. The most frequent crash is a null dereference on the task result — callers that assume isReadyToPay always succeeds and skip the failure callback. Handle both success and failure listeners, and log the WalletConstants environment as a breadcrumb so a TEST/PRODUCTION mix-up is visible the instant it ships, per the Google Pay API guide.
Instrumenting NFC with BugsPulse breadcrumbs
Because NFC crashes depend on hardware, tag contents, and physical timing, a bare stack trace is rarely enough to reproduce one. Record a breadcrumb at each state transition — session begin, tag detected, record parsed, tag lost, write attempted, payment sheet presented — along with the tag's tech list or record TNF values and a truncated, sanitized payload snapshot. The breadcrumb trail then shows exactly which step failed, turning a "works on my device" mystery into a one-line fix.
Pair that with crash-free-rate alerting: a contactless feature that silently stops working won't always throw, but it will show up as a drop in successful scan or payment conversions. Correlate breadcrumbs with session-level metrics so a regression in tap-to-pay success is caught before it becomes a support-ticket flood. If you're new to these metrics, our guide on session rate versus user rate explains why the distinction matters, and our Bluetooth BLE crash debugging guide covers the same nearby-wireless instrumentation discipline.
NFC and contactless are no longer niche — they are the default way people pay, board transit, and unlock doors. The crash surface is real but tractable: respect the session lifecycle, treat intent dispatch as symmetric, validate every NDEF payload before parsing it, handle tag disconnects as normal, and instrument every state transition. Build that discipline in and the next tap becomes the uneventful, crash-free moment your users never think about.
To see how BugsPulse turns NFC breadcrumbs and crash-free-rate alerting into a single production-grade dashboard, start your free account — or learn more about privacy-first mobile crash reporting on our homepage.