Why image upload breaks so often on real Android devices

An image upload flow can look perfectly fine during development and still fail in production for predictable reasons. The test device stays on Wi-Fi, the screen remains on, the app stays in the foreground, and the file is small. Real users do not behave like that. They switch apps, lose connectivity, walk between Wi-Fi and mobile data, enable battery optimizations, lock the phone, or start an upload with a queue of large photos.

That gap between lab conditions and production conditions is where most upload bugs come from. The common symptoms are familiar:

  • the user tapped upload, but nothing reached the server;
  • the app retried and created duplicate images or duplicate database rows;
  • the upload died when the app moved to the background;
  • a release build still talked to an insecure HTTP endpoint left over from testing.

If you want an Android upload flow that survives the real world, the architecture matters more than the request itself. A durable baseline is to split responsibilities clearly:

  • the UI only creates upload jobs;
  • WorkManager owns scheduling and retries;
  • long-running work moves into a foreground worker with a visible notification;
  • the network layer defaults to HTTPS, with any cleartext exceptions isolated to development;
  • the server accepts an idempotency key so client retries do not create duplicate state.

That design is not flashy, but it solves the problems that actually consume time in production support.

When WorkManager is the right tool

Android’s current guidance recommends WorkManager for persistent background work, meaning tasks that should continue even if the app leaves the visible state and that may need to survive process death or device reboot. Image upload often fits that model better than developers initially expect.

Use WorkManager when at least one of these is true:

  • the upload may take long enough that the user can background the app;
  • failures should be retried later rather than abandoned;
  • a queue of files must complete eventually, not only while a screen is open;
  • execution should wait for conditions such as network availability, charging, or acceptable battery state.

Not every upload belongs there. A single small avatar upload that must complete immediately while the user waits on the same screen can still be handled inline. But once the requirement becomes “eventually complete, even under interruptions,” WorkManager is the safer default.

Android’s documentation also makes an important distinction between immediate work, long-running work, and deferrable work. Upload pipelines often move between those categories depending on file size and product expectations, which is exactly why explicit scheduling is useful.

Step 1: enqueue unique upload work instead of firing raw requests

A practical first improvement is to stop letting the UI issue duplicate uploads. In production, users tap twice, screens recreate, and retry buttons get pressed before previous work has finished. If the upload request is created directly from the UI each time, duplicate jobs are inevitable.

Generate a stable uploadId for the logical upload, package the required metadata into input data, and enqueue unique work:

val input = workDataOf(
    "uploadId" to uploadId,
    "filePath" to filePath,
    "mimeType" to "image/jpeg"
)

val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.CONNECTED)
    .setRequiresBatteryNotLow(true)
    .build()

val request = OneTimeWorkRequestBuilder<ImageUploadWorker>()
    .setInputData(input)
    .setConstraints(constraints)
    .build()

WorkManager.getInstance(context).enqueueUniqueWork(
    "image-upload-$uploadId",
    ExistingWorkPolicy.KEEP,
    request
)

KEEP is often the best policy for uploads because it prevents the same logical item from being scheduled more than once. If your product intentionally supports re-uploading the same source file, generate a new uploadId for that new attempt rather than letting the old and new jobs collide implicitly.

This is a small design choice, but it eliminates a surprising number of support issues.

Step 2: move long uploads into a foreground worker

Android documents long-running workers explicitly. A worker can call setForeground() so the OS knows the task is important to the user and should be allowed to keep running with a visible notification. That matters for bulk uploads, original-resolution photos, and poor network conditions where completion can take far longer than developers expect.

A Kotlin CoroutineWorker usually gives the cleanest implementation shape:

class ImageUploadWorker(
    appContext: Context,
    params: WorkerParameters
) : CoroutineWorker(appContext, params) {

    override suspend fun doWork(): Result {
        setForeground(createForegroundInfo("Uploading image"))

        return runCatching { uploadOnce() }
            .fold(
                onSuccess = { Result.success() },
                onFailure = {
                    if (runAttemptCount < 3) Result.retry() else Result.failure()
                }
            )
    }
}

This pattern is useful for two reasons. First, the worker is no longer coupled to an activity lifecycle. Second, the retry behavior is centralized and measurable.

There is also a current platform detail that teams need to account for. Android’s documentation states that if your app targets Android 14 (API level 34) or higher, long-running workers must specify a foreground service type. In practice, that means you need to declare the type for WorkManager’s internal foreground service in the manifest and provide the matching type at runtime through ForegroundInfo.

For a typical upload worker, dataSync is a reasonable semantic fit:

<service
    android:name="androidx.work.impl.foreground.SystemForegroundService"
    android:foregroundServiceType="dataSync"
    tools:node="merge" />

The important rule is accuracy. If the worker actually relies on location, camera, or microphone, you must declare the correct type rather than choosing a generic one for convenience.

Step 3: use constraints to express when the upload is allowed to run

One mistake in many upload flows is treating all uploads as equally urgent. They are not. A profile photo upload has very different expectations from a background media backup.

WorkManager constraints let you encode that difference directly:

val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.UNMETERED)
    .setRequiresCharging(true)
    .setRequiresBatteryNotLow(true)
    .build()

For user-facing uploads that must happen soon, CONNECTED is usually enough. For large background sync or archival uploads, UNMETERED plus charging is often the better choice. The value is not just battery friendliness. It also reduces the chance that users perceive the app as unreliable because it started expensive work under bad conditions.

Android’s documentation also notes that if constraints become unmet while work is running, WorkManager may stop that worker and reschedule it later. That has an architectural consequence: the upload code must be re-entrant. Do not assume the transfer runs once from start to finish without interruption.

Step 4: keep release uploads on HTTPS and isolate cleartext exceptions

Transport security is where many teams leave dangerous leftovers from development. A test server runs on http://10.0.2.2, the app is configured to allow cleartext traffic, and that allowance quietly survives into a release build.

Android’s Network Security Configuration gives you a cleaner boundary. The production posture should be “cleartext denied by default.” If you absolutely need a local development exception, define it narrowly.

AndroidManifest.xml:

<application
    android:usesCleartextTraffic="false"
    android:networkSecurityConfig="@xml/network_security_config" />

res/xml/network_security_config.xml:

<network-security-config>
    <base-config cleartextTrafficPermitted="false" />

    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="true">10.0.2.2</domain>
    </domain-config>
</network-security-config>

That structure gives you three practical benefits.

First, release traffic does not silently fall back to HTTP. Second, the development exception is visible and reviewable. Third, the app’s network policy is externalized instead of buried in ad hoc conditionals.

The Android security documentation also covers certificate pinning. That can be appropriate in some environments, but it should be treated as an operational commitment, not a checkbox. If your certificate rotation process is weak, pinning can create avoidable outages. For most teams, the first priority is to run HTTPS correctly everywhere and remove accidental cleartext usage.

Step 5: retries only work safely if the server is idempotent

This is the part many mobile teams miss. Result.retry() handles client-side scheduling, but it does not prove that the previous attempt failed on the server.

A timeout is the classic example. The client may see a network failure after the server has already stored the image and committed metadata. If the worker retries with a brand new logical request, the backend now sees two uploads and may create duplicate files, duplicate rows, or duplicate billing events.

The solution is an idempotency key. Reuse the same logical uploadId on every retry and send it to the backend in a header or form field. The server should treat that key as the identity of the upload operation:

  • if the key is new, process the upload normally and persist the result;
  • if the same key appears again, return the existing result instead of creating a new record.

That principle is backend-agnostic. It works with Flask, FastAPI, Spring Boot, Rails, or anything else.

If you use OkHttp, a multipart upload can stay straightforward:

val body = MultipartBody.Builder()
    .setType(MultipartBody.FORM)
    .addFormDataPart("uploadId", uploadId)
    .addFormDataPart(
        "image",
        file.name,
        file.asRequestBody("image/jpeg".toMediaType())
    )
    .build()

val request = Request.Builder()
    .url(uploadUrl)
    .header("Authorization", "Bearer $token")
    .header("X-Upload-Id", uploadId)
    .post(body)
    .build()

OkHttp’s own documentation shows MultipartBody.Builder as the standard way to build multipart form uploads, which keeps the request readable and avoids hand-building MIME boundaries.

Common mistakes worth eliminating early

Running the upload inside an Activity or ViewModel only

That is acceptable for strictly foreground-only work. It is the wrong foundation for uploads that are expected to finish under app backgrounding, process recreation, or delayed retry.

Retrying without a stable operation identifier

If you cannot answer “how does the server know this retry belongs to the same logical upload,” you do not have a reliable upload pipeline yet.

Treating foreground notifications as optional for long transfers

For trivial uploads, they may be unnecessary. For long-running work, they materially improve task survivability and make the user experience less confusing.

Allowing global cleartext traffic in release builds

That configuration often begins as a shortcut and ends as a security incident waiting to happen.

Final takeaway

Reliable Android image upload is not mainly about choosing one HTTP client over another. It is about shaping the workflow so interruptions are expected instead of exceptional.

WorkManager gives the upload a persistent scheduler. Foreground execution gives long-running transfers a better chance to finish. Constraints let you pick sane operating conditions. HTTPS and Network Security Config define a transport boundary that survives release pressure. Server-side idempotency turns retries from a data-corruption risk into a normal recovery path.

If your current implementation is still “tap button, fire request, hope for success,” the highest-leverage upgrade is not another dialog or spinner. It is this baseline. Once you have it, later improvements such as compression, resumable uploads, CDN ingestion, or moderation workflows become much easier to add without rebuilding the fundamentals.