A list thumbnail loads normally the first time and appears almost instantly the second time. It is tempting to conclude that “the cache is working,” but the view may simply still hold the image object. To verify iOS HTTP caching, check what the server actually receives: the first request should return the image body; once the response is stale, the client should revalidate it with an ETag. Test offline reads separately—a fast refresh is not evidence of one.
Define the test boundary
Run the test server and iOS simulator on the same remote Mac. Access localhost from the simulator so cross-region network fluctuations do not distort the cache results. Choose a small PNG, place it in the current working directory, and name it thumb.png. Do not change the URL, account, or image decoder during this test.
Verify three observable outcomes:
| Scenario | What the server should see | What the app should receive |
|---|---|---|
| First request | No If-None-Match; returns 200 |
Image bytes |
| Subsequent request | A matching ETag; returns 304 | The image revalidated through the cache |
| Explicit offline read | No new request | The stored image, or a clear cache miss |
A 304 has no image body. The app can still display the image because the client combines stored content with the revalidation result. Do not describe “the UI received an image” as “the server sent the image again.”
Start a server that logs conditional requests
Save the following script as fixture.py in the image’s directory. It listens only on the local loopback address; it does not expose the test port externally. max-age=0 makes subsequent ordinary requests require revalidation, while a fixed ETag makes the request sequence easy to inspect.
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
BODY = Path("thumb.png").read_bytes()
TAG = '"thumb-v1"'
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path != "/thumb":
self.send_error(404)
return
matched = self.headers.get("If-None-Match") == TAG
status = 304 if matched else 200
print(f"If-None-Match={self.headers.get('If-None-Match')!r} status={status}", flush=True)
self.send_response(status)
self.send_header("ETag", TAG)
self.send_header("Cache-Control", "public, max-age=0")
if not matched:
self.send_header("Content-Type", "image/png")
self.send_header("Content-Length", str(len(BODY)))
self.end_headers()
if not matched:
self.wfile.write(BODY)
HTTPServer(("127.0.0.1", 8765), Handler).serve_forever()
Run python3 fixture.py. In another terminal, use curl -si http://127.0.0.1:8765/thumb -o /dev/null to confirm a 200 response, then run:
curl -si -H 'If-None-Match: "thumb-v1"' \
http://127.0.0.1:8765/thumb -o /dev/null
The log should show 200 and 304, respectively. This confirms only that the test server behaves as expected; it does not prove that iOS sends conditional requests automatically.
Verify simulator requests in one session
Use a dedicated URLSessionConfiguration.default for the test target, configure a URLCache with sufficient memory capacity, and leave the request policy at .useProtocolCachePolicy. Request the same URL twice through the same session. Wait for each task to finish and confirm that each result is a decodable PNG. Inspect the server log: the first request should return 200; if the second reaches the server, it should carry If-None-Match and receive 304.
let configuration = URLSessionConfiguration.default
configuration.urlCache = URLCache(
memoryCapacity: 8 * 1024 * 1024,
diskCapacity: 0
)
configuration.requestCachePolicy = .useProtocolCachePolicy
let session = URLSession(configuration: configuration)
let url = URL(string: "http://localhost:8765/thumb")!
Make both image requests from the app use this session; do not create a new configuration for each request. If transport security blocks local HTTP in the simulator test target, enable a local-network exception only in that target’s configuration. Do not carry a development exception into the production app. If the server log contains no second request, first check whether the app reused an image already in memory and whether it actually started a second load; then investigate the cache policy. Completion time alone cannot distinguish these paths.
Check the ETag when the image changes
Change the server’s TAG to '"thumb-v2"', replace thumb.png, restart the server, and request the image again. The old ETag should no longer match, so the new response should be 200 and the app should receive the new image. In a real project, the ETag must change with the content version. Replacing a file while retaining its old identifier can cause revalidation to preserve the old image incorrectly.
Treat offline reads as a separate test
First, complete a 200 request while online and confirm that the response permits storage. Then stop the test server. For the same URL, create a request using .returnCacheDataDontLoad and start the task through the session that still holds the original cache. Success means this read came from the cache. Record failure as a cache miss rather than automatically calling it a server error. After the test, check the server log: the offline request should have produced no new entry before or after the server stopped.
This offline policy is an explicit app choice; it does not mean a default request will necessarily fall back to an old image when the network is unavailable. Cache capacity, system cleanup, and response headers all affect hit rates. The UI should provide a placeholder for misses and allow a retry when connectivity returns, rather than assuming that an image is readable simply because it was opened before.
Investigate false positives and record the results
For each run, record the test URL, ETag, response cache headers, status sequence in the server log, and image version ultimately displayed by the app. For 200→200, first check whether the response is cacheable and whether both requests share the URL and cache. If the server reports 304 but the UI still shows the old image, check whether an old object in the view layer overwrote the result after response handling. If the server still returns 304 after the image changes, first verify that its identifier was actually updated.
Finally, repeat the initial test with a freshly installed simulator app so a previous experiment’s cache cannot affect the conclusion. These records replace “the image seemed to load quickly” with four verifiable outcomes—200, 304, offline hit, and cache miss—and make it easier for the team to retest after changing the image service or client cache policy.
Frequently asked questions
How can I prove that the server returned 304?
Inspect the server log for If-None-Match and the response status. URLSession may deliver cached image data to the app after revalidation.
Why can an image fail offline after it was loaded once?
The entry may have been evicted or marked uncacheable, or the request may still follow its normal network policy. Handle a cache miss explicitly.
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.