Skip to content
Muhammed Ali
Kocabey

Why NFC Reads Fail on Android (and How to Recover)

Most NFC read failures on Android are not bugs in your code; they are the radio field dropping for a fraction of a second while the user moves the phone. The chip and the antenna have to stay coupled for the entire exchange, and the moment coupling breaks mid-transaction, the current APDU fails and Android raises TagLostException. If you understand that one fact, nearly every "it works on my phone but not on the user's" report starts to make sense, and the fix is almost always better recovery rather than a different reading strategy.

I learned this building a production eID SDK for a banking app, where a single failed read means a user who cannot open an account. If you want the full picture of how an identity chip is read and verified over NFC, I wrote that up in reading an eID chip on Android. This post zooms in on the ugly part: why reads fail, and how to recover cleanly.

The two moments a read can fail

An NFC read has two distinct failure points, and conflating them is the first mistake teams make. The first is discovery and connect: the tag is detected but IsoDep.get(tag) returns null, or connect() throws before you send a single command. The second is mid-read: the secure channel is up, data groups are streaming, and then a transceive call throws because the field dropped. These need different handling. A connect failure usually means the phone never coupled properly with the chip; a mid-read failure means it coupled and then lost it, which is almost always physical.

Treat them as different states in your diagnostics. "Could not reach the chip," "connected but lost it mid-read," and "read fine but verification failed" are three separate problems with three separate fixes, and lumping them into a single "read failed" message is how you end up blind in the field. That same discipline applies to the access-control step; I cover the BAC-versus-PACE handshake and its own failure modes in understanding eID access control.

Why reads fail: it is usually physical, not logical

The dominant cause of NFC failure on Android is the radio field, not your parsing. Four physical realities drive almost every failure:

  • Antenna placement varies by device. There is no standard location for the NFC coil. On one phone it sits near the top of the back, on another near the camera, on another low and off-centre. The user has no idea where it is, so they hold the card over the wrong spot and get weak or no coupling.
  • Cases and thickness attenuate the field. A thick case, a wallet case with metal, or a card held a few millimetres too far all weaken the field below what the chip needs. NFC is near-field: a small distance is a large signal loss.
  • The chip is powered by the field. A contactless chip has no battery. It draws power inductively from the reader's field, and demanding operations draw more. If the field is marginal, the chip can brown out exactly when it is doing cryptographic work, which is why failures cluster around the handshake and the first large data group rather than at idle.
  • Users move. This is the big one. People lift the phone to look at the screen, reposition when they see a spinner, or simply cannot hold still for the two or three seconds a full read takes. Every one of those movements can break coupling.

Logical failures exist too, but they are a minority, and they fail deterministically: the same card fails the same way every time. Physical failures are probabilistic, which is their signature. If a read fails intermittently on the same device and card, it is the field.

Robust recovery starts with reader mode and a sane timeout

The foundation of a resilient reader is enableReaderMode with a presence-check delay and a deliberate timeout. Reader mode is the production choice because it stops the platform and other NFC apps from interfering during a scan, and the presence-check delay controls how aggressively the stack polls the card. EXTRA_READER_PRESENCE_CHECK_DELAY is an integer extra, understood to be in milliseconds (the behaviour is community-confirmed rather than spelled out in the official reference); raising it slightly reduces how often an overly eager presence check tears down a connection on firmware that polls too fast.

import android.app.Activity
import android.nfc.NfcAdapter
import android.nfc.NfcManager
import android.os.Bundle

class EidReaderMode(private val activity: Activity) {

    // getDefaultAdapter is deprecated since API 29; prefer NfcManager.
    private val adapter: NfcAdapter? =
        activity.getSystemService(NfcManager::class.java)?.defaultAdapter

    fun enable(onTag: (android.nfc.Tag) -> Unit) {
        val options = Bundle().apply {
            // Give the presence check a little room so marginal fields
            // are not torn down on firmware that polls too aggressively.
            putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250)
        }
        adapter?.enableReaderMode(
            activity,
            { tag -> onTag(tag) },
            NfcAdapter.FLAG_READER_NFC_A or
                NfcAdapter.FLAG_READER_NFC_B or
                NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,
            options,
        )
    }

    fun disable() = adapter?.disableReaderMode(activity)
}

Once you have a tag, the timeout matters more than people expect. IsoDep.setTimeout sets the timeout in milliseconds for transceive, and the platform default is often too short for cryptographic operations that run on the chip. Raise it before connecting, and read maxTransceiveLength rather than hard-coding a frame size, because standard APDUs cap the payload at 255 bytes (261 total) while some chips and devices support extended-length frames.

import android.nfc.tech.IsoDep

fun open(tag: android.nfc.Tag): IsoDep {
    val isoDep = IsoDep.get(tag)
        ?: throw IllegalStateException("Tag is not ISO-DEP (IsoDep.get returned null)")
    isoDep.timeout = 10_000 // ms; chip crypto can be slow, do not starve it
    isoDep.connect()
    return isoDep
}

A retry-and-reconnect pattern for "Tag was lost"

The single most valuable recovery pattern is catching TagLostException and reconnecting rather than aborting. TagLostException is a subclass of IOException thrown when the tag leaves the field during transceive. Because the loss is usually momentary, reconnecting on the same tag and retrying the failed command recovers a large fraction of reads that would otherwise surface to the user as a hard error.

import android.nfc.TagLostException
import java.io.IOException

/**
 * Sends one APDU, recovering from brief field loss by reconnecting and
 * retrying. Keep attempts small; if the field is truly gone, fail fast
 * and tell the user to reposition rather than spinning forever.
 */
fun IsoDep.transceiveWithRecovery(
    apdu: ByteArray,
    maxAttempts: Int = 3,
): ByteArray {
    var lastError: IOException? = null
    repeat(maxAttempts) { attempt ->
        try {
            if (!isConnected) connect()
            return transceive(apdu)
        } catch (e: TagLostException) {
            lastError = e
            // Field dropped mid-command. Give it a beat and reconnect.
            runCatching { if (isConnected) close() }
            Thread.sleep(50L * (attempt + 1))
            runCatching {
                connect()
                // close() resets the timeout to its default, so re-apply
                // the same value used in open() before retrying.
                timeout = 10_000
            }
        }
    }
    throw lastError ?: IOException("transceive failed after $maxAttempts attempts")
}

Three cautions. First, transceive blocks and must never run on the main thread; keep the whole read on a background dispatcher. Second, re-set the timeout after every reconnect: on Android IsoDep.close() resets the timeout to its default, so the generous value you set in open() is lost the moment you close the connection to recover, and without re-applying it your retried commands run with the short default — the opposite of what you want. Third, do not retry forever. If the field is genuinely gone, a tight retry loop just burns time while the user stares at a spinner. Three attempts with a short backoff is a good default, after which you fail fast and tell the user to reposition.

Make each read step idempotent so retries are safe

Retries are only safe if repeating a read step cannot corrupt your state, so design each step to be idempotent. Selecting a file and reading it is naturally repeatable: SELECT the data group, READ BINARY its bytes, and if the read is interrupted, start that data group again from offset zero rather than trying to resume from a half-read buffer. Treat a partial read as a discarded attempt, not as progress to be salvaged.

/**
 * Reads one data group whole. On interruption the caller retries this
 * function from the start; partial buffers are never reused, so a retry
 * can never stitch together bytes from two different attempts.
 */
fun readDataGroup(isoDep: IsoDep, selectApdu: ByteArray, readApdu: ByteArray): ByteArray {
    isoDep.transceiveWithRecovery(selectApdu) // re-select every attempt
    return isoDep.transceiveWithRecovery(readApdu)
}

The access-control handshake is the one step you generally cannot simply replay, because it establishes session keys; if it fails, restart the whole flow from a fresh connection rather than retrying a single handshake message. Keeping the reusable steps idempotent and the stateful step restartable is what lets recovery stay simple.

User guidance is a feature, not a fallback

The highest-leverage fix for NFC failure is not code at all; it is telling the user where to hold the card and asking them to hold still. Because antenna placement varies and users move, clear on-screen guidance ("hold the card flat against the top of the phone, do not move it") raises the success rate more than any timeout tweak. When a read fails, say why in human terms: "We lost the connection, hold the card still against the back of the phone" is actionable; "Read failed" is not. Treating every failure as something you can explain, rather than a black-box boolean, is the same principle I argue for in SecureCheck.

NFC reads fail on Android mostly because the field is fragile and hands are not steady. You cannot change physics, but you can meet it honestly: reader mode with a sane presence delay, a generous timeout, reconnect-and-retry on TagLostException, idempotent read steps, and guidance that tells the user exactly what to do. Get those five right and the intermittent failures that once looked random become rare, and recoverable when they happen.

WORK TOGETHER

Let’s start with a good problem.

Looking for an Android engineer for your team, or someone to think through a technical problem with? Let’s talk.

Book a 30-minute conversation[email protected]All contact and podcast links