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:

  1. For storing secure tokens (a separate topic)
  2. For caching

This post covers the second case, is it truly black and white? Spoiler: it’s not.

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.

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.

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.

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.

Blocking
Resolving
Connecting
Sending
Waiting
Receiving
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
Table 1: Network request timeline showing response times and network phases (blocking, resolving, connecting, sending, waiting, receiving) for 100 cached image requests where each images is less than 1kB in size.

You’ll notice that blocking time grows with each request. For the last request, it waits 105 ms to return a 2ms response.

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.

Blocking
Resolving
Connecting
Sending
Waiting
Receiving
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
Table 2: Network request timeline showing response times and network phases for 100 cached image requests where each images is 5MB in size.

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

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.

Nothing is black and white. There are pros and cons to each approach, and you must choose the right solution for your specific problem.

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.

Dialog of a request cached inside the in-memory cache, which takes 0.2ms.

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.

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.

Image showing Chrome flags list with disk cache backend for HTTP Cache changed to SQL 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

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.

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.