A successful upload while the iOS Simulator is in the foreground can give a false sense of confidence on a remote development Mac. Background uploads involve a file on disk, a transfer task managed by the system, and callbacks when the app resumes. Checking only whether the request was sent is not enough. Start with a local receiver on the cloud Mac to verify the basic flow, then keep a separate checklist for tests that require a physical device.
Set the acceptance-test boundary
This test covers file upload tasks created with URLSessionConfiguration.background, not uploads that keep Data in memory while waiting for the network to recover. Prepare a sample file with a recognizable size and no real credentials, and record its byte count and SHA-256. The server must at least log the request method, bytes received, and response status. On the client, record the task identifier, transfer error, and HTTP status code separately.
An error of
nilindidCompleteWithErrormeans only that the transport layer reported no error. The server may still return a 4xx or 5xx response. Acceptance testing must check the HTTP response too.
The Simulator is suitable for verifying this basic flow. It cannot establish that the system will relaunch the app under particular conditions, nor can it replace tests involving real mobile-network changes.
Start a minimal receiver on the Mac
Save the following script as receiver.py and run it on the same Mac as the Simulator. It listens only on the loopback address, limits the size of each request, and writes the received content to a fixed test path. Do not treat it as a public upload service.
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
LIMIT = 10 * 1024 * 1024
OUTPUT = Path("/tmp/ios-upload-received.bin")
class Receiver(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length", "0"))
if self.path != "/upload" or not 0 < length <= LIMIT:
self.send_error(400)
return
body = self.rfile.read(length)
if len(body) != length:
self.send_error(400)
return
OUTPUT.write_bytes(body)
self.send_response(201)
self.end_headers()
HTTPServer(("127.0.0.1", 8765), Receiver).serve_forever()
Start it with python3 receiver.py. When the test app accesses http://127.0.0.1:8765/upload, configure a narrowly scoped App Transport Security exception for localhost or the loopback host in the test build only. Release builds should still use HTTPS. If the Simulator cannot connect, first confirm that the receiver is still running and the port matches, then check the exception configuration. Do not start by changing the upload logic.
Create a background task from a file on disk
The following example shows the essential task setup. The caller must provide a file URL for a file that has been fully written and is readable by the current process. Supply endpoint through the test configuration rather than hard-coding the local test address into release code. The background session identifier should remain stable and belong to this upload flow; do not generate a new one on every launch.
import Foundation
final class UploadCoordinator: NSObject, URLSessionDelegate, URLSessionTaskDelegate {
private lazy var session: URLSession = {
let config = URLSessionConfiguration.background(
withIdentifier: "com.example.upload-check"
)
config.isDiscretionary = false
config.sessionSendsLaunchEvents = true
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
func upload(file: URL, endpoint: URL) {
var request = URLRequest(url: endpoint)
request.httpMethod = "POST"
session.uploadTask(with: request, fromFile: file).resume()
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?
) {
let status = (task.response as? HTTPURLResponse)?.statusCode
let accepted = error == nil && status == 201
recordResult(taskID: task.taskIdentifier, accepted: accepted)
}
func urlSessionDidFinishEvents(
forBackgroundURLSession session: URLSession
) {
finishPendingBackgroundEvents()
}
private func recordResult(taskID: Int, accepted: Bool) {
// Pass the result to the app's persistent state recorder
}
private func finishPendingBackgroundEvents() {
// Call the saved system completion handler after updating app state
}
}
The two private methods are integration points, not a complete upload implementation. In a real app, persist the relationship between each task and its business file along with the result. When the app delegate receives background-session events, recreate the session with the same identifier, reattach its delegate, and save the system-provided completion handler by identifier. Call that handler only after processing the events and updating the UI state. Do not delete an unverified business file on the strength of a single success callback.
Verify in stages; do not mistake force-quitting for backgrounding
First, keep the app in the foreground and submit a sample file. After the receiver returns 201, compare the SHA-256 of /tmp/ios-upload-received.bin with the original file. A match establishes that this run delivered the complete expected content. Next, submit a different sample and return to the Home Screen through the Simulator UI. Wait for the callback and check the file again. Do not forcibly terminate the process with a command to simulate ordinary backgrounding: a user-initiated force-quit is not the same scenario as recovery after the system takes over a task.
| Checkpoint | Evidence to record |
|---|---|
| Task creation | Session identifier, task identifier, source file path, and byte count |
| Server receipt | HTTP status, actual byte count, and file SHA-256 |
| Client completion | Transfer error and HTTP status recorded separately |
| App recovery | Session recreation, callback arrival, and the order in which the completion handler is called |
Acceptance testing on a physical device must also cover a locked screen, recovery after a network interruption, changes in network type, and relaunch after the system terminates the app. Test a user-initiated force-quit separately; do not treat its outcome as ordinary background behavior. If the remote Mac cannot connect to a device available to you, hand the Simulator evidence and outstanding test checklist to a colleague with a physical device. Do not report that device testing has passed.
Common failures and cleanup
If the server receives no request, first check that the file URL points to an existing regular file, then check the test address, port, and transport-security configuration. If the file arrives but the client marks the task as failed, inspect the response status: the example accepts only 201. “No transfer error” does not mean “accepted by the server.” If no callback arrives after the app resumes, check that the background session was recreated with its original identifier, that its delegate is still retained, and that the completion handler was not called too early.
Finally, stop the test receiver, delete /tmp/ios-upload-received.bin, and remove test-only source files and network exceptions. The production flow should also define a retry limit, how the server handles duplicate submissions, and when to remove files after the result has been persisted. The handoff to the next engineer should be more than “background uploads work”: it should include repeatable steps, verifiable evidence, and the physical-device tests still outstanding.
Frequently asked questions
Can a background URLSession upload an in-memory Data value directly?
Use a file-backed upload task for this workflow. Write the payload to disk first and create the task with uploadTask(with:fromFile:).
Does a successful Simulator run prove that relaunch works on a device?
No. The Simulator can check requests, responses, and basic callbacks; verify system relaunch, network transitions, and user force-quit behavior separately on a physical device.
Get a Mac ready for your next remote development session
Choose a model and location for your needs, then connect via Screen Sharing, VNC, or SSH. Check the console for current pricing and availability.