Document capture and upload for KYC flows on cheap Android hardware
A logistics client runs KYC onboarding for driver-partners in the field. The drivers show up with Aadhaar cards and PAN cards, the staff photograph them on a Redmi or a Vivo costing around ₹8,000, and the photos need to reach the verification backend. The upload fails constantly.
The failure pattern is consistent: the request gets to 70–85% on the progress bar, the network drops for two seconds, and the whole thing resets. The staff member retries, the same thing happens, the driver goes home. We spent three weeks reworking this flow.
The first problem is image size. A rear camera on a budget Android phone produces a 4000×3000 JPEG at 3–4 MB. Over a patchy 4G connection in a tier-3 city, that is a 15-second upload on a good day. We compress before the upload starts, not on the server.
We use `react-native-image-resizer` to bring the long edge down to 1600px and quality to 60. That produces a 300–500 KB file. KYC verification does not need 12 megapixels — the text needs to be legible, nothing more. We tested with the verification vendor: 1600px on the long edge is their minimum recommendation, and 60% JPEG quality is the floor before OCR accuracy drops.
import ImageResizer from 'react-native-image-resizer';
const compress = async (uri) => {
const result = await ImageResizer.createResizedImage(
uri, 1600, 1600, 'JPEG', 60, 0, undefined
);
return result.uri;
};The second problem is the upload itself. A single PUT request for 400 KB over an unstable connection is fragile. If the TCP connection drops, the server has nothing and the client starts over. We switched to chunked uploads with resumability.
We split the file into 64 KB chunks and upload them sequentially with a session ID. The backend tracks which chunks have arrived. On retry, the client asks the server which chunks are missing and resumes from there. A dropped connection at 80% means re-uploading the last few chunks, not the whole file.
64 KB is a deliberate choice. Smaller chunks mean more HTTP requests and more overhead. Larger chunks mean more data lost on each drop. On the connections we measured — typically 1–2 Mbps with frequent 1–3 second gaps — 64 KB transfers in under a second and survives most interruptions.
const uploadChunk = async (fileUri, sessionId, chunkIndex, totalChunks) => {
const chunk = await readChunk(fileUri, chunkIndex, 65536);
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('sessionId', sessionId);
formData.append('index', String(chunkIndex));
formData.append('total', String(totalChunks));
const response = await fetch(`${API_URL}/upload/chunk`, {
method: 'POST',
body: formData,
});
return response.json();
};The third problem is retry logic. We use exponential backoff with jitter, capped at 3 retries per chunk. Without jitter, multiple staff members hitting the same flaky cell tower all retry simultaneously and compound the problem. The jitter spreads them out.
We also persist upload state to AsyncStorage. If the app crashes or the user backgrounds it, the upload resumes from the last completed chunk when they come back. This matters because budget phones aggressively kill background apps — the OS will terminate the process to reclaim memory while the user switches to WhatsApp to send a message.
One thing we do not do is attempt the upload in the background as a service. We tried it. Android's background execution limits on budget phones are unpredictable, and the user never knows whether the upload completed. We keep the upload in the foreground with a visible progress indicator and a clear retry button. The staff member stays on the screen until it finishes or fails. This is less convenient but more reliable, and for a KYC flow, reliability matters more than convenience.
The progress bar now reflects completed chunks, not bytes transferred. A chunk at 80% means 80% of chunks are confirmed by the server, not 80% of bytes sent into a void. The distinction matters to the person staring at the phone.