Mert Özen Further With Every Line
Developer Tools Browser DevTools — #05

The Application Panel: Storage, Cookie Flags, Cache Storage and the Angular Service Worker

Mert Özen Nov 8, 2026 10 min 4 views
The Application Panel: Storage, Cookie Flags, Cache Storage and the Angular Service Worker

The Application panel shows everything the browser stores: localStorage, IndexedDB, quota, HttpOnly/Secure/SameSite/Partitioned cookies and the reason a cookie was rejected (SameSiteNoneInsecure). After a deploy, the Angular Service Worker serves the old bundle on the first reload; diagnosing it with /ngsw/state, Cache Storage, SwUpdate VERSION_READY, Update on reload and Bypass for network, plus the manifest and a bfcache test.

Version note: The examples in this post were run with Chrome 156 (156.0.8078.80), Angular 22.2 (@angular/service-worker 22.2) and .NET 10 (ASP.NET Core 10). Storage, cookie and Service Worker outputs were captured from the same Chrome version in headless mode through the Chrome DevTools Protocol (CDP), using the domains the Application panel relies on; panel steps come from the Chrome DevTools documentation.

Predict First

Bookstore's production build is served from ASP.NET Core static files, with the Angular Service Worker. You renamed the "Books" link in the top menu to "Catalog", built the app and copied the files to the server. A user who has opened the site before reloads the page. What do they see: "Books" or "Catalog"? And if they reload once more?

Second question: the API (Application Programming Interface) writes these four cookies. Which ones are stored in the browser, and which ones show up in document.cookie?

Set-Cookie: cart_id=c-1042; path=/; secure; samesite=lax; httponly
Set-Cookie: theme=dark; path=/; samesite=lax
Set-Cookie: tracking=t-77; path=/; samesite=none
Set-Cookie: widget_pref=compact; path=/; secure; samesite=none; Partitioned

The site is http://localhost:5391, so not HTTPS (HTTP Secure). If you think the answer depends on that detail, you are on the right track.

localStorage, sessionStorage and IndexedDB

On the left of the Application panel, under Storage, you find Local storage, Session storage, IndexedDB and Cookies, each listed by origin. Bookstore writes the cart to localStorage and recently viewed books to IndexedDB:

remember(bookId: number): void {
  const open = indexedDB.open('bookstore-db', 1);
  open.onupgradeneeded = () => open.result.createObjectStore('recentlyViewed', { keyPath: 'bookId' });
  open.onsuccess = () => {
    const tx = open.result.transaction('recentlyViewed', 'readwrite');
    tx.objectStore('recentlyViewed').put({ bookId, viewedAt: new Date().toISOString() });
    tx.oncomplete = () => open.result.close();
  };
}

After adding two books to the cart and opening one book's details, this is what the panel shows:

--- localStorage
   bookstore.cart = [{"bookId":1,"title":"Compilers Volume 1","price":21,"quantity":1},{"bookId":2,"title":"Databases Volume 2","price":22,"
--- IndexedDB databases: ["bookstore-db"]
   version 1 stores recentlyViewed keyPath=bookId
   entry 1 {"bookId":1,"viewedAt":"2026-11-08T12:03:17.254Z"}

In the Local storage table, double-click a row to edit its key or value, select a row and press Delete to remove it, and use the filter above to search keys. Corrupting the cart by hand (make "price" a string, cut the JSON (JavaScript Object Notation) in half) is the fastest way to see how the app reacts to bad data; without a try/catch around JSON.parse, the page stays blank. Session storage is the same table, but it only lives as long as that tab.

The IndexedDB view is not live: when code writes a new record, the table does not refresh by itself; you press the refresh button above the object store. Since Chrome 155–156, nested objects in the IndexedDB table expand with growing row heights while keeping the scroll position. Right-click a database and choose Delete database to wipe that origin's database completely; you use it to test your version upgrade (onupgradeneeded) code from scratch. The APIs themselves are covered in the Intermediate JavaScript series post "JavaScript Web Storage and Cookies: localStorage, sessionStorage and document.cookie".

Storage Quota and Clear Site Data

The Application > Storage page shows how much space the origin uses, by type. Bookstore's values:

--- quota: usage 423559 bytes, quota 555.8 GiB, breakdown [{"storageType":"indexeddb","usage":2276},{"storageType":"cache_storage","usage":336640},{"storageType":"service_workers","usage":84643}]

Most of the 423 KB in use is the Service Worker's Cache Storage; IndexedDB is only 2 KB. The quota is derived from disk size, so the 555.8 GiB here is specific to this machine; it will be much smaller on a user's phone. On the same page, "Simulate custom storage quota" lets you define a low quota and try the "what happens when the quota is full" scenario.

The Clear site data button deletes the types you select (cookies, storage, cache, the Service Worker registration) in one click. In our test, after Storage.clearDataForOrigin, the button's CDP counterpart:

--- after Clear site data: localStorage entries 0 | cookies 0

Most "broken for me, fine in a fresh profile" bugs are an old storage entry or an old Service Worker. This button is the shortest way to reproduce the bug from a clean state; but read what is broken from the tables first, because clearing also deletes the evidence.

Cookies: HttpOnly, Secure, SameSite and Partitioned

The answer to the second question in Predict First. What remains in the Cookies table after /api/session, and what the page sees:

   stored cookies:
     theme=dark httpOnly=false secure=false sameSite=Lax partitionKey=- session=true
     cart_id=c-1042 httpOnly=true secure=true sameSite=Lax partitionKey=- session=true
     widget_pref=compact httpOnly=false secure=true sameSite=None partitionKey={"topLevelSite":"http://localhost","hasCrossSiteAncestor":false} session=true
   document.cookie -> "theme=dark; widget_pref=compact"

Three of the four cookies were stored. cart_id is missing from document.cookie because it is HttpOnly: JavaScript cannot read it, so it cannot be stolen even through an XSS (Cross-Site Scripting) hole. Those are the cookies with a tick in the table's HttpOnly column. A Secure cookie like cart_id being stored over plain HTTP may look surprising; Chrome treats localhost as a secure context, so it works locally. Do not expect the same on a real HTTP domain.

widget_pref carries the Partitioned attribute, so it was stored under CHIPS (Cookies Having Independent Partitioned State) rules: the Partition Key Site column in the Cookies table shows the top-level site (http://localhost). A partitioned cookie written by a third party inside an iframe is only readable under that top-level site; the same widget embedded on another site uses a separate cookie jar. ASP.NET Core's CookieOptions has no dedicated property for this, so we added the attribute through Extensions:

CookieOptions partitioned = new() { Secure = true, SameSite = SameSiteMode.None, Path = "/" };
partitioned.Extensions.Add("Partitioned");
http.Response.Cookies.Append("widget_pref", "compact", partitioned);

Double-click a cookie in the Cookies table to change its value, and since Chrome 155–156 you can delete selected rows with the keyboard or the context menu. What these flags mean for security is covered in the Web Security series, which works through the OWASP (Open Worldwide Application Security Project) Top 10 and also covers JWT (JSON Web Token) and CSRF (Cross-Site Request Forgery), in the post "OWASP A07: Authentication Failures (2) — Cookie Flags, JWT Validation and CSRF"; here we focus on reading them in the tool.

The fourth cookie, tracking, is not in the table. Chrome rejected it silently, and only DevTools tells you why. In the Network panel, on the Cookies tab of the /api/session request, the rejected Set-Cookie line appears with a yellow background and a warning icon; hover over it for the reason. What we read from CDP's Network.responseReceivedExtraInfo event:

   blocked Set-Cookie:
     tracking=t-77; path=/; samesite=none -> SameSiteNoneInsecure

A cookie that says SameSite=None must also be Secure; otherwise it is rejected. The "show filtered out request cookies" option on the same tab also shows the cookies that were not sent with the request and why (SameSite, path or domain mismatch). It is the first place to look for the "I log in but the next request has no session" complaint.

We also fell into a trap during the test. In the production build, when the request went through the Service Worker, the page's Network record did not carry the blocked cookie information; once we bypassed the Service Worker (the CDP counterpart of Application > Service workers > Bypass for network), the line above appeared. Fetches made by the Service Worker itself show up as separate rows in the Network panel with a gear icon; look for the cookie there, or turn on the bypass.

Service Worker and Cache Storage: Angular's ngsw

ng add @angular/pwa adds ngsw-config.json, the manifest and this provider to the project:

provideServiceWorker('ngsw-worker.js', {
  enabled: !isDevMode(),
  registrationStrategy: 'registerWhenStable:30000',
}),

Because of enabled: !isDevMode(), there is no Service Worker under ng serve; every Service Worker output in this post comes from the production build. The Application > Service workers section shows the registered worker, its status and its script; under Cache Storage you see the caches Angular opened:

--- SW versions: id=0 status=activated running=running script=/ngsw-worker.js
--- Cache Storage:
    ngsw:/:db:control
    ngsw:/:cdfdb3838596c99e0523bcd9e2c09cc01eab4adf:assets:app:cache
    ngsw:/:db:cdfdb3838596c99e0523bcd9e2c09cc01eab4adf:assets:app:meta
    ngsw:/:cdfdb3838596c99e0523bcd9e2c09cc01eab4adf:assets:assets:cache
    ngsw:/:db:cdfdb3838596c99e0523bcd9e2c09cc01eab4adf:assets:assets:meta
   entries in ngsw:/:cdfdb...:assets:app:cache : /chunk-A4SHIOZ7.js, /chunk-E6ZZI456.js, /favicon.ico, /index.html, /main-66HIL7BB.js, /manifest.webmanifest, /styles-5INURTSO.css

The long hash in the cache names is the hash of the ngsw.json manifest, in other words the app's version. Because of "installMode": "prefetch" in ngsw-config.json, the app group has downloaded everything up front, from index.html to main-*.js. The Angular Service Worker also has its own debug page; just open /ngsw/state on the same origin:

NGSW Debug Info:

Driver version: 22.2.5
Driver state: NORMAL ((nominal))
Latest manifest hash: cdfdb3838596c99e0523bcd9e2c09cc01eab4adf
Last update check: 2s397u

=== Version cdfdb3838596c99e0523bcd9e2c09cc01eab4adf ===

This page works in the user's browser too. Telling support to "type yoursite/ngsw/state into the address bar and send a screenshot" gives a definite answer to "which version are you on". A Driver state other than NORMAL (for example EXISTING_CLIENTS_ONLY when the cache is broken) is a diagnosis in itself.

The Old Bundle After a Deploy: Step-by-Step Diagnosis

We actually tried the first question from Predict First: we copied the new build that renames "Books" to "Catalog" to the server and reloaded the page at intervals (we shortened the /ngsw/state lines and hashes):

--- reload before deploy: nav text="Books" main=main-66HIL7BB.js fromServiceWorker=true
--- 1st reload after deploy: nav text="Books" main=main-66HIL7BB.js fromServiceWorker=true
--- /ngsw/state after 2000 ms more: Latest manifest hash: cdfdb383... | Last update run: never
--- /ngsw/state after 8000 ms more: Latest manifest hash: 967d18e8... | === Version cdfdb383... === | === Version 967d18e8... ===
--- app logs so far: ["new version ready cdfdb3838596c99e0523bcd9e2c09cc01eab4adf -> 967d18e899c22aee9e1447696e0da770fa68f19c"]
--- 2nd reload after deploy: nav text="Catalog" main=main-HRXDAST5.js fromServiceWorker=true

On the first reload after the deploy the user still saw the old version, the old main-66HIL7BB.js; the response came from the Service Worker, not the server. The Angular Service Worker schedules the update check on navigation but runs it once the page goes idle: two seconds later, Last update run: never; within ten seconds, the new manifest had been downloaded and two versions sat side by side. The app learned about it through SwUpdate:

if (this.swUpdate.isEnabled) {
  this.swUpdate.versionUpdates
    .pipe(filter((e): e is VersionReadyEvent => e.type === 'VERSION_READY'))
    .subscribe(e => console.log('new version ready', e.currentVersion.hash, '->', e.latestVersion.hash));
}

And only on the second reload did "Catalog" appear. In real life this arrives as "I deployed but users see the old screen"; an old bundle expecting a new API field also throws errors. The fix is the path the Angular documentation recommends: when VERSION_READY arrives, show a "new version available, reload" notice, or call document.location.reload() at a suitable moment.

While developing, this behavior slows you down. Application > Service workers has three checkboxes. Update on reload forces the worker to update on every reload; when we put the next build on the server and reloaded with only this setting on, the new version ("All Books") arrived on the first reload. Bypass for network sends requests straight to the network, skipping the Service Worker; it also brought the new bundle on the first reload, this time with fromServiceWorker=false. Offline is for testing the Service Worker's offline behavior. Unregister removes the registration. Users do not have these checkboxes; on their machine, diagnosis means /ngsw/state and the hash in Cache Storage, and the lasting fix is the SwUpdate flow. If you urgently need to pull back a very bad release, there is safety-worker.js in the Angular build output: put in place of ngsw-worker.js, it unregisters the old worker and deletes the ngsw: caches.

Manifest, Web Install and bfcache

Application > Manifest parses manifest.webmanifest and shows its fields, icons and errors. Bookstore's is clean:

--- manifest url /manifest.webmanifest errors [] name bookstore-web display standalone

With Chrome 155–156, the Issues panel reports manifest errors for the Web Install flow used with navigator.install and <install>: if the manifest cannot be fetched, has a syntax error, has an invalid start_url, or is missing a name or id, an issue appears with a link to the manifest file. When you are trying to make the app installable, look at Issues first.

The back/forward cache (bfcache) restores the in-memory page when the user presses Back instead of reloading it. In Application > Back/forward cache, the "Test back/forward cache" button navigates away and back, and lists the reasons if the page did not come from the cache. We ran the same test by hand on Bookstore: we put a marker on the page, navigated to another address and came back.

after back: marker 42 persisted true navType navigate notUsed 0

The pageshow event arriving with persisted: true and the JavaScript state (marker 42) still in place show that the page came from the bfcache; CDP's "not used" event never fired. An unload listener, an open WebSocket or Cache-Control: no-store can spoil this result; the reason the button lists is usually a one-line fix.

Production Notes

Deploying with a Service Worker is not "copy the files and you are done". Some users talk to the new API with the old bundle for at least one reload. Make API changes backward compatible and announce the update to the user with SwUpdate. The Angular Service Worker already requests ngsw.json bypassing the cache with an ngsw-cache-bust query; but if you cache index.html and ngsw-worker.js on a CDN (Content Delivery Network) for a long time, the update may never arrive.

The cart in localStorage and the records in IndexedDB also change between versions: the new version must be able to read data in the old format. Clear site data is a developer's tool; asking users to do it is not a fix.

Do not rely on the localhost exception for cookies: on a real domain, Secure cookies are only stored over HTTPS, and SameSite=None always requires Secure. Set up staging with HTTPS so you do not learn the difference in production.

Common Pitfalls

  • Looking for the Service Worker under ng serve: because of enabled: !isDevMode() it is only registered in the production build.
  • Telling users to "clear the cache" after a deploy: the Service Worker's Cache Storage is separate from the browser's HTTP cache.
  • Leaving Update on reload or Bypass for network on and wondering "updates arrive instantly for me, why not for users".
  • Assuming the IndexedDB table is live and declaring "the record was not written" without pressing refresh.
  • Looking for a rejected cookie under Application > Cookies: it is not there; the reason is on the Cookies tab of the request in Network.
  • Pressing Clear site data and deleting the evidence of the problem.

A Small Experiment

★☆☆ Open the production build, add two books to the cart, corrupt the bookstore.cart value in Local storage by hand (delete the last character) and reload. Watch how the app reacts, then clean up with Clear site data.

★★☆ Call /api/session and read the reason the tracking cookie was rejected on the Cookies tab of the request in Network. Add Secure = true to the cookie in the API and compare how the table changes.

★★★ Add a component that shows a "New version ready" banner when VERSION_READY arrives. Build and deploy, wait without reloading for two versions to appear in /ngsw/state, and confirm the banner shows. Then turn on Update on reload, repeat the flow and write down the difference.

So far we have seen what the app stores, where, and with which version. The next question is "how fast": in the next post we move to the Performance panel. With live metrics, trace recording, Insights and Angular's own custom track, we will measure and fix the INP (Interaction to Next Paint) of the book list that stutters while typing in the filter.