HTTP Cache vs localStorage
Table of Contents
TL;DR
- HTTP Cache: Browser-managed, asynchronous, integrated with HTTP semantics (Cache-Control, ETags). Suffers from network stack serialization (queuing delays) and disk I/O bottlenecks with large files.
- localStorage: Synchronous blocking, zero-millisecond reads for small data. Limited to 5-10MB quota with no built-in eviction.
- Best practice: Use HTTP cache for most resources. Browser’s in-memory cache is blazing fast. Use localStorage only for small data where you need control. Avoid both for auth tokens.
- Cross-platform reality: Firefox dominates large file caching (3-154ms), WebKit/Chrome excel at small files. Same browser shows 10x differences across OSes.
Client‑side caching is one of the highest‑leverage ways to speed up modern web apps. Two mechanisms that often come up are HTTP cache and localStorage. Both persist data in the browser, but they behave very differently under the hood.
The idea behind localStorage (introduced around 2009 as part of the HTML5 Web Storage specification) was to provide a secure, efficient, and much larger alternative to cookies for client-side storage. Before localStorage, web developers were forced to use cookies to store user data, which had a 4KB limit and were automatically sent to the server with every HTTP request, leading to poor performance and security limitation.
Over the past 15 years since localStorage was introduced, we’ve seen various usage patterns emerge. It’s always interesting to observe the difference between an API’s intended purpose and how developers actually use it. This has happened with localStorage too, it became used as a caching mechanism. Most commonly for fonts, does anyone remember the localFont project? Smashing Magazine and The Guardian were among the first to use this technique. I see these misuses of APIs as helpful in identifying gaps in other APIs or browsers themselves. This pushed the browser vendors to improve HTTP cache.
Today, there is a push to discourage using localStorage for two main reasons:
- For storing secure tokens (a separate topic)
- For caching
This post covers the second case, is it truly black and white? Spoiler: it’s not.
HTTP cache
Permalink to "HTTP cache"Let’s start by understanding the HTTP cache and some hidden implications it brings.
HTTP cache is browser‑managed storage for network resources. The focus here is on browser-managed as that brings a lot of benefits you don’t have to think about:
- Asynchronous: reads and writes happen off the main thread.
- Integrated with HTTP semantics:
Cache-Control,Expires,ETag, revalidation, and standard freshness rules. - Managed eviction: the browser expires and evicts items automatically.
The benefits above shift the responsibility of cache busting to server and browser and not the actual client, which is great.
Local Storage
Permalink to "Local Storage"On the other hand, the localStorage doesn’t come with any of these benefits:
- Synchronous: every read and write blocks the main thread.
- String‑only: binary data must be encoded (often base64), which increases size and CPU cost.
- No eviction policy: when you hit quota, writes fail.
Due to synchronous behavior browser vendors limited the storage size to 5MB but Chrome has bumped this up in 2023 to 10MB. Firefox has it still at 5MB.
This brings us to a dangerous edge case: if you cache many files (fonts, images, etc.) in localStorage, you can easily exceed the 5MB limit. While hitting this limit isn’t inherently a problem, you’ll get a QUOTA_EXCEEDED_ERR it becomes critical if your app also stores authentication tokens in localStorage. If localStorage fills with cache and your app tries to log back in or save other data, it will fail.
So, ok this clearly means use only HTTP cache for caching, simple, right? It is never that simple.
HTTP Cache Performance
Permalink to "HTTP Cache Performance"Of course, this is a performance-oriented blog so we must talk about performance. HTTP cache comes with two implications that we will deep dive into.
Network stack
Permalink to "Network stack"When you request a resource in the browser, it calls into the HttpCache to create an HttpCache::Transaction. The cache checks for an entry matching the URL and NetworkIsolationKey. If no entry exists, the transaction calls HttpNetworkLayer to create a network transaction. (Low-level browser networking details are beyond this post’s scope.) The HttpCache::Transaction determines if a request can be served from cache, or if it needs revalidation, it sends a conditional request over the network.
One important detail: the cache has a read/write lock per URL. While the lock allows multiple concurrent reads, HttpCache::Transaction always grabs it for writing and reading before downgrading to read-only mode. This effectively serializes all requests for the same URL. The renderer process mitigates this by merging duplicate requests for the same URL.
This means even cached requests see noticeable queuing time. To demonstrate, I built a few stress tests.
Below you can see a HAR table of what happens when you request 100 small images from cache inside Chromium.
|
Request
|
Status
|
Type
|
Size
|
Timeline
|
|---|---|---|---|---|
| GET image?width=100&height=100&maxAge=3600&id=img0 | 200 OK | image/png | 239 B |
33.7ms
|
| GET image?width=100&height=100&maxAge=3600&id=img1 | 200 OK | image/png | 237 B |
36ms
|
| GET image?width=100&height=100&maxAge=3600&id=img2 | 200 OK | image/png | 238 B |
36.5ms
|
| GET image?width=100&height=100&maxAge=3600&id=img3 | 200 OK | image/png | 238 B |
37.1ms
|
| GET image?width=100&height=100&maxAge=3600&id=img4 | 200 OK | image/png | 238 B |
37.2ms
|
| GET image?width=100&height=100&maxAge=3600&id=img5 | 200 OK | image/png | 239 B |
38ms
|
| GET image?width=100&height=100&maxAge=3600&id=img6 | 200 OK | image/png | 237 B |
38.7ms
|
| GET image?width=100&height=100&maxAge=3600&id=img7 | 200 OK | image/png | 239 B |
40.4ms
|
| GET image?width=100&height=100&maxAge=3600&id=img8 | 200 OK | image/png | 238 B |
41.8ms
|
| GET image?width=100&height=100&maxAge=3600&id=img9 | 200 OK | image/png | 238 B |
43.2ms
|
| GET image?width=100&height=100&maxAge=3600&id=img10 | 200 OK | image/png | 238 B |
43.4ms
|
| GET image?width=100&height=100&maxAge=3600&id=img11 | 200 OK | image/png | 238 B |
44.2ms
|
| GET image?width=100&height=100&maxAge=3600&id=img12 | 200 OK | image/png | 239 B |
44.5ms
|
| GET image?width=100&height=100&maxAge=3600&id=img13 | 200 OK | image/png | 238 B |
45.2ms
|
| GET image?width=100&height=100&maxAge=3600&id=img14 | 200 OK | image/png | 239 B |
47.2ms
|
| GET image?width=100&height=100&maxAge=3600&id=img15 | 200 OK | image/png | 239 B |
47.3ms
|
| GET image?width=100&height=100&maxAge=3600&id=img16 | 200 OK | image/png | 239 B |
47.5ms
|
| GET image?width=100&height=100&maxAge=3600&id=img17 | 200 OK | image/png | 238 B |
48.1ms
|
| GET image?width=100&height=100&maxAge=3600&id=img18 | 200 OK | image/png | 238 B |
47.9ms
|
| GET image?width=100&height=100&maxAge=3600&id=img19 | 200 OK | image/png | 238 B |
48.6ms
|
| GET image?width=100&height=100&maxAge=3600&id=img20 | 200 OK | image/png | 239 B |
50.9ms
|
| GET image?width=100&height=100&maxAge=3600&id=img21 | 200 OK | image/png | 238 B |
51.3ms
|
| GET image?width=100&height=100&maxAge=3600&id=img22 | 200 OK | image/png | 239 B |
51.4ms
|
| GET image?width=100&height=100&maxAge=3600&id=img23 | 200 OK | image/png | 238 B |
50.9ms
|
| GET image?width=100&height=100&maxAge=3600&id=img24 | 200 OK | image/png | 238 B |
51.7ms
|
| GET image?width=100&height=100&maxAge=3600&id=img25 | 200 OK | image/png | 238 B |
51.8ms
|
| GET image?width=100&height=100&maxAge=3600&id=img26 | 200 OK | image/png | 237 B |
54.3ms
|
| GET image?width=100&height=100&maxAge=3600&id=img27 | 200 OK | image/png | 239 B |
55.1ms
|
| GET image?width=100&height=100&maxAge=3600&id=img28 | 200 OK | image/png | 238 B |
54.3ms
|
| GET image?width=100&height=100&maxAge=3600&id=img29 | 200 OK | image/png | 237 B |
54.5ms
|
| GET image?width=100&height=100&maxAge=3600&id=img30 | 200 OK | image/png | 238 B |
55.4ms
|
| GET image?width=100&height=100&maxAge=3600&id=img31 | 200 OK | image/png | 238 B |
55.5ms
|
| GET image?width=100&height=100&maxAge=3600&id=img32 | 200 OK | image/png | 239 B |
57.7ms
|
| GET image?width=100&height=100&maxAge=3600&id=img33 | 200 OK | image/png | 238 B |
57.9ms
|
| GET image?width=100&height=100&maxAge=3600&id=img34 | 200 OK | image/png | 238 B |
58.8ms
|
| GET image?width=100&height=100&maxAge=3600&id=img35 | 200 OK | image/png | 238 B |
58ms
|
| GET image?width=100&height=100&maxAge=3600&id=img36 | 200 OK | image/png | 238 B |
58.3ms
|
| GET image?width=100&height=100&maxAge=3600&id=img37 | 200 OK | image/png | 239 B |
58.4ms
|
| GET image?width=100&height=100&maxAge=3600&id=img38 | 200 OK | image/png | 237 B |
60ms
|
| GET image?width=100&height=100&maxAge=3600&id=img39 | 200 OK | image/png | 238 B |
61.7ms
|
| GET image?width=100&height=100&maxAge=3600&id=img40 | 200 OK | image/png | 238 B |
61.9ms
|
| GET image?width=100&height=100&maxAge=3600&id=img41 | 200 OK | image/png | 239 B |
62.4ms
|
| GET image?width=100&height=100&maxAge=3600&id=img42 | 200 OK | image/png | 238 B |
62.3ms
|
| GET image?width=100&height=100&maxAge=3600&id=img43 | 200 OK | image/png | 238 B |
62.6ms
|
| GET image?width=100&height=100&maxAge=3600&id=img44 | 200 OK | image/png | 238 B |
63.3ms
|
| GET image?width=100&height=100&maxAge=3600&id=img45 | 200 OK | image/png | 239 B |
64.6ms
|
| GET image?width=100&height=100&maxAge=3600&id=img46 | 200 OK | image/png | 238 B |
67.4ms
|
| GET image?width=100&height=100&maxAge=3600&id=img47 | 200 OK | image/png | 238 B |
68.1ms
|
| GET image?width=100&height=100&maxAge=3600&id=img48 | 200 OK | image/png | 238 B |
68.2ms
|
| GET image?width=100&height=100&maxAge=3600&id=img49 | 200 OK | image/png | 238 B |
68.5ms
|
| GET image?width=100&height=100&maxAge=3600&id=img50 | 200 OK | image/png | 239 B |
71.9ms
|
| GET image?width=100&height=100&maxAge=3600&id=img51 | 200 OK | image/png | 238 B |
72.2ms
|
| GET image?width=100&height=100&maxAge=3600&id=img52 | 200 OK | image/png | 238 B |
77.4ms
|
| GET image?width=100&height=100&maxAge=3600&id=img53 | 200 OK | image/png | 239 B |
76.2ms
|
| GET image?width=100&height=100&maxAge=3600&id=img54 | 200 OK | image/png | 238 B |
76.7ms
|
| GET image?width=100&height=100&maxAge=3600&id=img55 | 200 OK | image/png | 239 B |
78.2ms
|
| GET image?width=100&height=100&maxAge=3600&id=img56 | 200 OK | image/png | 239 B |
78.6ms
|
| GET image?width=100&height=100&maxAge=3600&id=img57 | 200 OK | image/png | 238 B |
78.8ms
|
| GET image?width=100&height=100&maxAge=3600&id=img58 | 200 OK | image/png | 238 B |
85.9ms
|
| GET image?width=100&height=100&maxAge=3600&id=img59 | 200 OK | image/png | 239 B |
82.6ms
|
| GET image?width=100&height=100&maxAge=3600&id=img60 | 200 OK | image/png | 336 B |
83.3ms
|
| GET image?width=100&height=100&maxAge=3600&id=img61 | 200 OK | image/png | 238 B |
83.9ms
|
| GET image?width=100&height=100&maxAge=3600&id=img62 | 200 OK | image/png | 239 B |
85.3ms
|
| GET image?width=100&height=100&maxAge=3600&id=img63 | 200 OK | image/png | 238 B |
85.9ms
|
| GET image?width=100&height=100&maxAge=3600&id=img64 | 200 OK | image/png | 238 B |
88.3ms
|
| GET image?width=100&height=100&maxAge=3600&id=img65 | 200 OK | image/png | 239 B |
88.6ms
|
| GET image?width=100&height=100&maxAge=3600&id=img66 | 200 OK | image/png | 238 B |
89.1ms
|
| GET image?width=100&height=100&maxAge=3600&id=img67 | 200 OK | image/png | 239 B |
89.6ms
|
| GET image?width=100&height=100&maxAge=3600&id=img68 | 200 OK | image/png | 238 B |
89.8ms
|
| GET image?width=100&height=100&maxAge=3600&id=img69 | 200 OK | image/png | 239 B |
90.4ms
|
| GET image?width=100&height=100&maxAge=3600&id=img70 | 200 OK | image/png | 239 B |
91.6ms
|
| GET image?width=100&height=100&maxAge=3600&id=img71 | 200 OK | image/png | 238 B |
92.2ms
|
| GET image?width=100&height=100&maxAge=3600&id=img72 | 200 OK | image/png | 239 B |
92.2ms
|
| GET image?width=100&height=100&maxAge=3600&id=img73 | 200 OK | image/png | 238 B |
92.9ms
|
| GET image?width=100&height=100&maxAge=3600&id=img74 | 200 OK | image/png | 238 B |
93.7ms
|
| GET image?width=100&height=100&maxAge=3600&id=img75 | 200 OK | image/png | 238 B |
94.2ms
|
| GET image?width=100&height=100&maxAge=3600&id=img76 | 200 OK | image/png | 238 B |
94.7ms
|
| GET image?width=100&height=100&maxAge=3600&id=img77 | 200 OK | image/png | 238 B |
95.9ms
|
| GET image?width=100&height=100&maxAge=3600&id=img78 | 200 OK | image/png | 239 B |
96ms
|
| GET image?width=100&height=100&maxAge=3600&id=img79 | 200 OK | image/png | 238 B |
96.8ms
|
| GET image?width=100&height=100&maxAge=3600&id=img80 | 200 OK | image/png | 238 B |
96.9ms
|
| GET image?width=100&height=100&maxAge=3600&id=img81 | 200 OK | image/png | 238 B |
97.2ms
|
| GET image?width=100&height=100&maxAge=3600&id=img82 | 200 OK | image/png | 238 B |
97.4ms
|
| GET image?width=100&height=100&maxAge=3600&id=img83 | 200 OK | image/png | 238 B |
98.3ms
|
| GET image?width=100&height=100&maxAge=3600&id=img84 | 200 OK | image/png | 238 B |
100.1ms
|
| GET image?width=100&height=100&maxAge=3600&id=img85 | 200 OK | image/png | 238 B |
101ms
|
| GET image?width=100&height=100&maxAge=3600&id=img86 | 200 OK | image/png | 239 B |
100.5ms
|
| GET image?width=100&height=100&maxAge=3600&id=img87 | 200 OK | image/png | 337 B |
101.7ms
|
| GET image?width=100&height=100&maxAge=3600&id=img88 | 200 OK | image/png | 238 B |
101.9ms
|
| GET image?width=100&height=100&maxAge=3600&id=img89 | 200 OK | image/png | 238 B |
102.3ms
|
| GET image?width=100&height=100&maxAge=3600&id=img90 | 200 OK | image/png | 239 B |
103.3ms
|
| GET image?width=100&height=100&maxAge=3600&id=img91 | 200 OK | image/png | 239 B |
104.1ms
|
| GET image?width=100&height=100&maxAge=3600&id=img92 | 200 OK | image/png | 239 B |
104.8ms
|
| GET image?width=100&height=100&maxAge=3600&id=img93 | 200 OK | image/png | 238 B |
104.4ms
|
| GET image?width=100&height=100&maxAge=3600&id=img94 | 200 OK | image/png | 239 B |
104.5ms
|
| GET image?width=100&height=100&maxAge=3600&id=img95 | 200 OK | image/png | 238 B |
104.7ms
|
| GET image?width=100&height=100&maxAge=3600&id=img96 | 200 OK | image/png | 239 B |
106.4ms
|
| GET image?width=100&height=100&maxAge=3600&id=img97 | 200 OK | image/png | 239 B |
106.5ms
|
| GET image?width=100&height=100&maxAge=3600&id=img98 | 200 OK | image/png | 238 B |
106.6ms
|
| GET image?width=100&height=100&maxAge=3600&id=img99 | 200 OK | image/png | 238 B |
106.8ms
|
You’ll notice that blocking time grows with each request. For the last request, it waits 105 ms to return a 2ms response.
Disk Cache
Permalink to "Disk Cache"The actual HTTP cache is stored on disk. Browsers use a dedicated I/O thread to prevent rendering from blocking the UI. However, they don’t fully use asynchronous I/O—not all operations can be async. For example, opening and closing files are synchronous, making them susceptible to significant delays under heavy I/O load.
The network service runs on its own dedicated thread in the browser process. There is an exception on Chrome OS, where it runs on the I/O thread. This has recently changed.
The disk cache wasn’t visible in the first table because the files were very small. With 1MB+ files, the results change significantly.
|
Request
|
Status
|
Type
|
Size
|
Timeline
|
|---|---|---|---|---|
| GET image?size=5mb&maxAge=3600&id=img0 | 200 OK | image/png | 5 MB |
37.2ms
|
| GET image?size=5mb&maxAge=3600&id=img1 | 200 OK | image/png | 5 MB |
39.9ms
|
| GET image?size=5mb&maxAge=3600&id=img2 | 200 OK | image/png | 5 MB |
41.1ms
|
| GET image?size=5mb&maxAge=3600&id=img3 | 200 OK | image/png | 5 MB |
46.7ms
|
| GET image?size=5mb&maxAge=3600&id=img4 | 200 OK | image/png | 5 MB |
46.3ms
|
| GET image?size=5mb&maxAge=3600&id=img5 | 200 OK | image/png | 5 MB |
49.5ms
|
| GET image?size=5mb&maxAge=3600&id=img6 | 200 OK | image/png | 5 MB |
60.3ms
|
| GET image?size=5mb&maxAge=3600&id=img7 | 200 OK | image/png | 5 MB |
67.5ms
|
| GET image?size=5mb&maxAge=3600&id=img8 | 200 OK | image/png | 5 MB |
69.8ms
|
| GET image?size=5mb&maxAge=3600&id=img9 | 200 OK | image/png | 5 MB |
74.8ms
|
| GET image?size=5mb&maxAge=3600&id=img10 | 200 OK | image/png | 5 MB |
76.7ms
|
| GET image?size=5mb&maxAge=3600&id=img11 | 200 OK | image/png | 5 MB |
79.7ms
|
| GET image?size=5mb&maxAge=3600&id=img12 | 200 OK | image/png | 5 MB |
100.3ms
|
| GET image?size=5mb&maxAge=3600&id=img13 | 200 OK | image/png | 5 MB |
101.9ms
|
| GET image?size=5mb&maxAge=3600&id=img14 | 200 OK | image/png | 5 MB |
125.9ms
|
| GET image?size=5mb&maxAge=3600&id=img15 | 200 OK | image/png | 5 MB |
234ms
|
| GET image?size=5mb&maxAge=3600&id=img16 | 200 OK | image/png | 5 MB |
233.5ms
|
| GET image?size=5mb&maxAge=3600&id=img17 | 200 OK | image/png | 5 MB |
234.2ms
|
| GET image?size=5mb&maxAge=3600&id=img18 | 200 OK | image/png | 5 MB |
240.4ms
|
| GET image?size=5mb&maxAge=3600&id=img19 | 200 OK | image/png | 5 MB |
241ms
|
| GET image?size=5mb&maxAge=3600&id=img20 | 200 OK | image/png | 5 MB |
243.9ms
|
| GET image?size=5mb&maxAge=3600&id=img21 | 200 OK | image/png | 5 MB |
247.5ms
|
| GET image?size=5mb&maxAge=3600&id=img22 | 200 OK | image/png | 5 MB |
248.8ms
|
| GET image?size=5mb&maxAge=3600&id=img23 | 200 OK | image/png | 5 MB |
249.7ms
|
| GET image?size=5mb&maxAge=3600&id=img24 | 200 OK | image/png | 5 MB |
261.3ms
|
| GET image?size=5mb&maxAge=3600&id=img25 | 200 OK | image/png | 5 MB |
262ms
|
| GET image?size=5mb&maxAge=3600&id=img26 | 200 OK | image/png | 5 MB |
267.1ms
|
| GET image?size=5mb&maxAge=3600&id=img27 | 200 OK | image/png | 5 MB |
268.4ms
|
| GET image?size=5mb&maxAge=3600&id=img28 | 200 OK | image/png | 5 MB |
268.8ms
|
| GET image?size=5mb&maxAge=3600&id=img29 | 200 OK | image/png | 5 MB |
270.1ms
|
| GET image?size=5mb&maxAge=3600&id=img30 | 200 OK | image/png | 5 MB |
368ms
|
| GET image?size=5mb&maxAge=3600&id=img31 | 200 OK | image/png | 5 MB |
368.3ms
|
| GET image?size=5mb&maxAge=3600&id=img32 | 200 OK | image/png | 5 MB |
368.8ms
|
| GET image?size=5mb&maxAge=3600&id=img33 | 200 OK | image/png | 5 MB |
376.3ms
|
| GET image?size=5mb&maxAge=3600&id=img34 | 200 OK | image/png | 5 MB |
376.7ms
|
| GET image?size=5mb&maxAge=3600&id=img35 | 200 OK | image/png | 5 MB |
377.4ms
|
| GET image?size=5mb&maxAge=3600&id=img36 | 200 OK | image/png | 5 MB |
407ms
|
| GET image?size=5mb&maxAge=3600&id=img37 | 200 OK | image/png | 5 MB |
407.3ms
|
| GET image?size=5mb&maxAge=3600&id=img38 | 200 OK | image/png | 5 MB |
408.3ms
|
| GET image?size=5mb&maxAge=3600&id=img39 | 200 OK | image/png | 5 MB |
409.1ms
|
| GET image?size=5mb&maxAge=3600&id=img40 | 200 OK | image/png | 5 MB |
410.1ms
|
| GET image?size=5mb&maxAge=3600&id=img41 | 200 OK | image/png | 5 MB |
411.8ms
|
| GET image?size=5mb&maxAge=3600&id=img42 | 200 OK | image/png | 5 MB |
474ms
|
| GET image?size=5mb&maxAge=3600&id=img43 | 200 OK | image/png | 5 MB |
474.6ms
|
| GET image?size=5mb&maxAge=3600&id=img44 | 200 OK | image/png | 5 MB |
475.2ms
|
| GET image?size=5mb&maxAge=3600&id=img45 | 200 OK | image/png | 5 MB |
476.1ms
|
| GET image?size=5mb&maxAge=3600&id=img46 | 200 OK | image/png | 5 MB |
476.6ms
|
| GET image?size=5mb&maxAge=3600&id=img47 | 200 OK | image/png | 5 MB |
477.3ms
|
| GET image?size=5mb&maxAge=3600&id=img48 | 200 OK | image/png | 5 MB |
483.8ms
|
| GET image?size=5mb&maxAge=3600&id=img49 | 200 OK | image/png | 5 MB |
484.4ms
|
| GET image?size=5mb&maxAge=3600&id=img50 | 200 OK | image/png | 5 MB |
485.3ms
|
| GET image?size=5mb&maxAge=3600&id=img51 | 200 OK | image/png | 5 MB |
486.9ms
|
| GET image?size=5mb&maxAge=3600&id=img52 | 200 OK | image/png | 5 MB |
487.5ms
|
| GET image?size=5mb&maxAge=3600&id=img53 | 200 OK | image/png | 5 MB |
489.6ms
|
| GET image?size=5mb&maxAge=3600&id=img54 | 200 OK | image/png | 5 MB |
517.7ms
|
| GET image?size=5mb&maxAge=3600&id=img55 | 200 OK | image/png | 5 MB |
523.9ms
|
| GET image?size=5mb&maxAge=3600&id=img56 | 200 OK | image/png | 5 MB |
524.4ms
|
| GET image?size=5mb&maxAge=3600&id=img57 | 200 OK | image/png | 5 MB |
525.4ms
|
| GET image?size=5mb&maxAge=3600&id=img58 | 200 OK | image/png | 5 MB |
528.1ms
|
| GET image?size=5mb&maxAge=3600&id=img59 | 200 OK | image/png | 5 MB |
548.9ms
|
| GET image?size=5mb&maxAge=3600&id=img60 | 200 OK | image/png | 5 MB |
674.3ms
|
| GET image?size=5mb&maxAge=3600&id=img61 | 200 OK | image/png | 5 MB |
680.7ms
|
| GET image?size=5mb&maxAge=3600&id=img62 | 200 OK | image/png | 5 MB |
681ms
|
| GET image?size=5mb&maxAge=3600&id=img63 | 200 OK | image/png | 5 MB |
682.2ms
|
| GET image?size=5mb&maxAge=3600&id=img64 | 200 OK | image/png | 5 MB |
683.9ms
|
| GET image?size=5mb&maxAge=3600&id=img65 | 200 OK | image/png | 5 MB |
690.3ms
|
| GET image?size=5mb&maxAge=3600&id=img66 | 200 OK | image/png | 5 MB |
757.8ms
|
| GET image?size=5mb&maxAge=3600&id=img67 | 200 OK | image/png | 5 MB |
765.1ms
|
| GET image?size=5mb&maxAge=3600&id=img68 | 200 OK | image/png | 5 MB |
763.6ms
|
| GET image?size=5mb&maxAge=3600&id=img69 | 200 OK | image/png | 5 MB |
778.9ms
|
| GET image?size=5mb&maxAge=3600&id=img70 | 200 OK | image/png | 5 MB |
777.3ms
|
| GET image?size=5mb&maxAge=3600&id=img71 | 200 OK | image/png | 5 MB |
780ms
|
| GET image?size=5mb&maxAge=3600&id=img72 | 200 OK | image/png | 5 MB |
798ms
|
| GET image?size=5mb&maxAge=3600&id=img73 | 200 OK | image/png | 5 MB |
818.4ms
|
| GET image?size=5mb&maxAge=3600&id=img74 | 200 OK | image/png | 5 MB |
821.6ms
|
| GET image?size=5mb&maxAge=3600&id=img75 | 200 OK | image/png | 5 MB |
823.7ms
|
| GET image?size=5mb&maxAge=3600&id=img76 | 200 OK | image/png | 5 MB |
825.3ms
|
| GET image?size=5mb&maxAge=3600&id=img77 | 200 OK | image/png | 5 MB |
831.4ms
|
| GET image?size=5mb&maxAge=3600&id=img78 | 200 OK | image/png | 5 MB |
839.8ms
|
| GET image?size=5mb&maxAge=3600&id=img79 | 200 OK | image/png | 5 MB |
851.9ms
|
| GET image?size=5mb&maxAge=3600&id=img80 | 200 OK | image/png | 5 MB |
852.8ms
|
| GET image?size=5mb&maxAge=3600&id=img81 | 200 OK | image/png | 5 MB |
857ms
|
| GET image?size=5mb&maxAge=3600&id=img82 | 200 OK | image/png | 5 MB |
859.5ms
|
| GET image?size=5mb&maxAge=3600&id=img83 | 200 OK | image/png | 5 MB |
864.8ms
|
| GET image?size=5mb&maxAge=3600&id=img84 | 200 OK | image/png | 5 MB |
866.8ms
|
| GET image?size=5mb&maxAge=3600&id=img85 | 200 OK | image/png | 5 MB |
880.2ms
|
| GET image?size=5mb&maxAge=3600&id=img86 | 200 OK | image/png | 5 MB |
885.3ms
|
| GET image?size=5mb&maxAge=3600&id=img87 | 200 OK | image/png | 5 MB |
909ms
|
If you look at Table 2, you see different results. Notice rows like id=img15: a significant amount of time (~154 ms) is spent downloading. Compare to id=img48 (~8 ms)—same file size. The culprit: hitting the disk cache bottleneck.
So, request-heavy applications face a disadvantage with HTTP cache due to this disk bottleneck.
Real-World Browser Differences
Permalink to "Real-World Browser Differences"Web development is unique because it must run across every OS and browser. As a result, behavior can vary between browsers, and even the same browser can behave differently across operating systems because it relies on OS‑provided APIs.
I tested the HTTP cache across multiple browsers and OSes; see the table below.
Test with two configurations:
- 1KB images (1,000 images, 25 iterations)
- 5MB images (100 images, 10 iterations)
For each browser and configuration, the cache is primed once, the test is run 5 times, and the best run is selected based on the lowest P95 latency. All systems are using an SSD.
| OS / Browser | 1KB Images (1,000 images) | 5MB Images (100 images) | ||
|---|---|---|---|---|
| P95 (ms) | Avg (ms) | P95 (ms) | Avg (ms) | |
| macOS Sequoia 15.2 | ||||
| Chromium | 49.63 | 23.30 | 1,141.85 | 571.46 |
| Firefox | 33.82 | 32.51 | 3.00 | 1.94 |
| WebKit | 15.34 | 22.44 | 373.56 | 33.59 |
| Windows 11 | ||||
| Chromium | 19.24 | 35.21 | 2,058.59 | 970.32 |
| Firefox | 38.68 | 25.98 | 4.16 | 2.70 |
| WebKit | 111.78 | 120.67 | 26.36 | 20.86 |
| Ubuntu 24.04 | ||||
| Chromium | 16.99 | 45.76 | 3,141.46 | 1,447.71 |
| Firefox | 88.22 | 77.64 | 7.46 | 4.75 |
| WebKit | 46.32 | 51.63 | 28.86 | 16.63 |
| Mobile | ||||
| Pixel 9 Pro | ||||
| Firefox | 16.00 | 10.92 | 4.00 | 1.38 |
| Chrome | 16.20 | 9.13 | 584.00 | 287.79 |
| iPhone 16 Pro | ||||
| Safari | 8.00 | 3.72 | 5.00 | 3.95 |
Key Findings:
- Small files (1KB): WebKit generally performs best on macOS, while Chromium leads on Windows and Ubuntu
- Large files (5MB): Firefox dramatically outperforms others across all platforms, while Chromium struggles significantly
- Platform variance: The same browser can show 10x+ performance differences across operating systems
Where localStorage Shines
Permalink to "Where localStorage Shines"Local storage doesn’t have that problem, because it doesn’t pass through the network stack.
If we race localStorage against HTTP cache, loading 100 1kB images from cache, we get:
| # | HTTP (ms) | localStorage (ms) | Diff | Winner |
|---|---|---|---|---|
| 5 | 0.34 | 0.01 | +0.32ms | localStorage |
| 4 | 2.22 | 0.00 | +2.22ms | localStorage |
| 3 | 0.63 | 0.01 | +0.62ms | localStorage |
| 2 | 3.38 | 0.00 | +3.37ms | localStorage |
| 1 | 0.43 | 0.00 | +0.43ms | localStorage |
HTTP cache is slower due to queuing. While the difference here is small, localStorage consistently stays below 1ms. This is consistent across all major browsers and OSes.
Well, Not That Simple
Permalink to "Well, Not That Simple"Nothing is black and white. There are pros and cons to each approach, and you must choose the right solution for your specific problem.
Browsers are smart
Permalink to "Browsers are smart"In my experience, trying to fight the browser is never a good option. Browsers are actually very smart and they do a lot more under the hood than developers realize.
The HTTP cache story is more complex than I’ve shown. I focused on disk cache, which is most common, but browsers also maintain an in-memory cache.

As you can see, this cache is blazing fast, connection timings measured in microseconds (μs). (I’ll explore in a future post how browsers decide what to store where.)
Additionally, caching behavior differs by resource type. Caching JavaScript or WebAssembly is fundamentally different from caching fonts or images, and browsers have optimized accordingly.
Constantly improving
Permalink to "Constantly improving"I love browsers because they’re constantly improving and changing to adapt according to the user’s needs. Historically, during the HTTP 1 days it was better to send one big file compared to multiple smaller ones. With the introduction of HTTP 2, this changed—now it is preferred to have smaller files compared to bigger ones, but as we saw, this can constrain the browser. Chrome is actually experimenting with different backends and you can change them by going to chrome://flags/#http-cache-custom-backend.

You can try to use SQLite, which is the next experiment that should work better with smaller files. I ran a few benchmarks with 1,000 images, each at 1KB, over 25 iterations:
| Backend | P95 | P99 | Max | Average |
|---|---|---|---|---|
| Default | 12.79 ms | 454.06 ms | 517.07 ms | 20.19 ms |
| SQLite | 12.63 ms | 119.46 ms | 150.37 ms | 10.13 ms |
Are you solving the right problem?
Permalink to "Are you solving the right problem?"Always ask: what problem are you solving? If it’s display latency (especially for images), progressive loading often yields better results than repurposing localStorage.
Common approaches:
- Progressive JPEG/PNG
- WebP with progressive behavior or low‑quality first chunks
- Inline tiny placeholders
- Load higher‑resolution versions later
Alternatives
Permalink to "Alternatives"If you got to this part, congratulations and thank you for trying to understand all the little details. You might ask yourself, well none of these solutions look right for me, what are my alternatives? Yes, there are. People that know me know that I love to say, “On the web there are always at least three ways to solve a problem.” Of course IndexedDB is something that pops up, and then the Cache API, the decision just becomes harder.
Because my posts tend to be very technical and deep, I want to keep them focused. Therefore, I plan to cover IndexedDB in upcoming posts.
Conclusion
Permalink to "Conclusion"While localStorage can be faster for serving repeated data, the trade-offs are significant: synchronous access blocks the main thread, storage is capped at 5-10MB, and using it for cache can conflict with legitimate application data. HTTP cache, browser-managed and integrated with HTTP semantics, handles eviction and freshness automatically, but suffers from network stack serialization and disk I/O bottlenecks.
The answer isn’t always one or the other; it depends on your specific problem. For display latency concerns, progressive loading strategies are often better. For raw speed with full control over storage and application needs, localStorage can work. In most cases, though, let the browser do what it does best with HTTP cache, especially its in-memory layer for hot resources.
Choose the tool that solves your actual problem, not the symptom.