Mert Özen Further With Every Line
Developer Tools Debugging and Problem Solving — #13

Real-World Bug Hunts 2: Frontend and Integration — Cache, CORS, Dates and Memory Leaks

Mert Özen Nov 8, 2026 15 min 4 views
Real-World Bug Hunts 2: Frontend and Integration — Cache, CORS, Dates and Memory Leaks

Most frontend bugs are born not in the code but in the gap between browser and server. Five real cases: a white screen after deploy from a cached index.html and missing chunks, a CORS preflight failing only in production because of a trailing slash, a birth date shifting by a day across time zones, floating point money totals and memory growing from an observable never unsubscribed; with the decisive tool for each and the series' debugging checklist.

Version note: The code in this post was built and run with .NET 10, ASP.NET Core 10, Angular 22.2, RxJS 7.8 and Vitest 5.1; browser output was captured with Chrome 156.

Guess First

Bookstore's Angular app has been deployed with a new version. A user still has the app open in their browser; after the deploy finishes they click the "Books" link. What happens: does the new page open, the old one, or nothing at all? And think about this: the server sends "1990-05-14" in JSON (JavaScript Object Notation) for a customer's birth date. Which day does a customer in New York see on screen? We will see both answers with real browser and test output.

In the last post of the series, each case follows the same order as the previous post: symptom, first wrong hypothesis, narrowing down, root cause, and a fix with the test that proves it. This time the stage is the border between browser and server. I captured the browser output from a headless Chrome launched with a separate profile, using CDP (Chrome DevTools Protocol); the DevTools panel steps are described from the documentation.

Case 1: A White Screen After Deploy

Symptom: after a deploy, complaints come in saying "the page is completely white" and "nothing happens when I click Books", and nobody on the team can reproduce them. First wrong hypothesis: "The new version has a JavaScript error." But the app works for everyone who opens the new version; the problem is not in the code, it is in the gap between the version the user has and the one on the server.

Angular CLI (Command Line Interface) adds a content hash to file names in a production build. Between the two builds we changed only the text of one component:

v1: index.html, main-UNNPJ3LA.js, chunk-HACNQHZ2.js, chunk-C2JN2BIZ.js (book-list)
v2: index.html, main-7RZUROQY.js, chunk-HACNQHZ2.js, chunk-DTIQWNSM.js (book-list)

ASP.NET Core serves the static files, with the "cache everything for a day" rule everybody writes at some point:

StaticFileOptions staticFiles = new()
{
    FileProvider = new PhysicalFileProvider(root),
    OnPrepareResponse = ctx =>
    {
        string cacheControl = fixedCaching
            ? (ctx.File.Name == "index.html" ? "no-cache" : "public, max-age=31536000, immutable")
            : "public, max-age=86400"; // BUG: one rule for everything, index.html included
        ctx.Context.Response.Headers[HeaderNames.CacheControl] = cacheControl;
    },
};

app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = staticFiles.FileProvider });
app.UseStaticFiles(staticFiles);
app.UseRouting();
app.MapFallbackToFile("index.html", staticFiles);

There is a reason I call UseRouting() explicitly after the static files. If you do not, WebApplication puts routing at the start of the pipeline; the / request matches the fallback endpoint first, and the static file middleware skips a request that already has an endpoint. In my first attempt, that is exactly why index.html never went through OnPrepareResponse and got no Cache-Control header at all.

To narrow it down I replayed the scenario step by step in headless Chrome: deploy v1, open the page, deploy v2, click the link, simulate the next visit. Real output with the faulty cache rule:

1) open http://localhost:5303/ (v1 deployed) -> h1="Bookstore v1"
  net 200 / text/html
  net 200 /styles-5INURTSO.css text/css
  net 200 /main-UNNPJ3LA.js text/javascript
  net 200 /chunk-HACNQHZ2.js text/javascript
  net 200 /favicon.ico image/x-icon
2) v2 deployed while tab is open, click Books -> list="(none)" url=/
  net 404 /chunk-C2JN2BIZ.js text/plain
  log: Failed to load resource: the server responded with a status of 404 (Not Found)
  console.error: ERROR TypeError: Failed to fetch dynamically imported module: http://localhost:5303/chunk-C2JN2BIZ.js
3) next visit (navigate, not reload) -> h1="Bookstore v1"
  net 200 / text/html (disk cache)
  net 200 /styles-5INURTSO.css text/css
  net 200 /chunk-HACNQHZ2.js text/javascript
  net 200 /main-UNNPJ3LA.js text/javascript

Step two is the "nothing happens when I click Books" complaint itself: v1 in the open tab lazy loads v1's chunk-C2JN2BIZ.js, which is no longer on the server. Step three is sneakier: index.html comes from the disk cache, the user stays on v1 for a whole day and hits the same 404 on every Books click. The white screen appears when the old index.html reaches a browser that does not have the old main file cached, or arrives through a CDN (Content Delivery Network) in between. A visit simulating the old index.html being served while the server holds the v2 files:

  net 200 / text/html
  net 200 /styles-5INURTSO.css text/css
  net 200 /chunk-HACNQHZ2.js text/javascript
  net 404 /main-UNNPJ3LA.js text/plain
  log: Failed to load resource: the server responded with a status of 404 (Not Found)
  net 200 /favicon.ico image/x-icon
  app-root: <app-root></app-root>

An empty app-root: the white screen. The root cause is not in the code but in the cache policy: index.html is a version's "front door" and must never be cached for long; files with a hash in their names can be cached forever, because when the content changes, so does the name. The fixed server's headers:

GET /
Cache-Control: no-cache
GET /main-7RZUROQY.js
Cache-Control: public, max-age=31536000, immutable
GET /main-UNNPJ3LA.js
HTTP/1.1 404 Not Found

no-cache does not mean "do not cache", it means "ask the server before every use"; thanks to the ETag, an unchanged file comes back cheaply as a 304. The old chunk returning 404 matters too: by default ASP.NET Core's MapFallbackToFile returns index.html only for paths without an extension ({*path:nonfile}). A rewrite rule that sends every path to index.html, however, returns index.html with a 200 for a missing .js as well; then the Console shows a wrong MIME (Multipurpose Internet Mail Extensions) type error instead of a 404, and the trail goes cold.

A cache header cannot fix the open tab; that tab is already running v1. I added a handler to the router that reloads the page once when a chunk is missing:

provideRouter(
  routes,
  withNavigationErrorHandler(error => {
    // the chunk of the old version is gone after a deploy: load the new version once
    const chunkMissing = String(error.error).includes('Failed to fetch dynamically imported module');
    if (chunkMissing && sessionStorage.getItem('chunk-reload') !== error.url) {
      sessionStorage.setItem('chunk-reload', error.url);
      location.assign(error.url);
    }
  }),
),

In the fixed version of the same scenario, step two now ends like this:

2) v2 deployed while tab is open, click Books -> list="BooksDune, Emma, Ulysses, Middlemarch" url=/books
  net 404 /chunk-C2JN2BIZ.js text/plain
  log: Failed to load resource: the server responded with a status of 404 (Not Found)
  console.error: ERROR TypeError: Failed to fetch dynamically imported module: http://localhost:5306/chunk-C2JN2BIZ.js
  net 200 /books text/html
  net 200 /styles-5INURTSO.css text/css
  net 200 /chunk-HACNQHZ2.js text/javascript
  net 200 /main-BBB3MOOU.js text/javascript
  net 200 /chunk-DTIQWNSM.js text/javascript

The 404 still happens, but the user lands on v2's Books page. The sessionStorage check prevents an endless reload loop. The message text varies by browser (this one is Chromium's); test the texts for Firefox and Safari yourself.

If There Is a Service Worker

If the app uses @angular/service-worker, the old version lives not in the HTTP (Hypertext Transfer Protocol) cache but in the Service Worker's own cache; fixing the server headers alone is not enough. According to the Angular docs, SwUpdate.versionUpdates emits a VERSION_READY event when a new version is ready; the app should ask the user and call document.location.reload(). activateUpdate() switches versions without a reload, and the docs explicitly warn that this can break the app because lazy chunk names change; SwUpdate.unrecoverable signals the state that only a full reload can rescue. This section is based on the docs; I did not run it. For diagnosis, the Application panel is decisive: it shows the active worker and the Cache Storage contents, and "Clear site data" lets you repeat the experiment on a clean page.

Case 2: A CORS Preflight That Fails Only in Production

Symptom: the order button does not work in production; it works in development, in staging, in Postman and with curl. First wrong hypothesis: "The token is invalid in production." But the request never reaches the API (Application Programming Interface); the API log has no POST from that user.

It is no accident that you do not see this bug in development. The Angular dev server runs with a proxy configuration:

{
  "/api": {
    "target": "http://localhost:5311",
    "secure": false
  }
}

In the browser's eyes the request goes to the page's own origin, so CORS (Cross-Origin Resource Sharing) never kicks in. A POST sent through the dev server returns 201 even when the API's allowed origin list is empty. In production, the page is served from https://shop.bookstore.test and the API from another origin. A POST carrying Content-Type: application/json and an Authorization header is not a "simple request"; the browser sends an OPTIONS preflight first. The API's CORS setup:

string[] allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [];
builder.Services.AddCors(options => options.AddPolicy("Shop", policy => policy
    .WithOrigins(allowedOrigins)
    .WithHeaders("authorization", "content-type")
    .WithMethods("GET", "POST")));

WebApplication app = builder.Build();

app.UseRouting();
app.UseCors("Shop");

And appsettings.Production.json:

{
  "Cors": {
    "AllowedOrigins": [ "https://shop.bookstore.test/" ]
  }
}

The fastest way to narrow it down is to replay the browser's preflight exactly with curl:

curl -v -X OPTIONS http://localhost:5311/api/orders \
  -H "Origin: https://shop.bookstore.test" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: authorization,content-type"

< HTTP/1.1 204 No Content
< Date: Sun, 08 Nov 2026 09:02:15 GMT
< Server: Kestrel

It returns 204, but without any Access-Control-Allow-* header. The browser reads that as a rejection and never sends the actual POST; in the Console you see the "No 'Access-Control-Allow-Origin' header is present" error. The decisive tool here is not the Network panel but the API's own log. With the Microsoft.AspNetCore.Cors category raised to Information:

info: Microsoft.AspNetCore.Cors.Infrastructure.CorsService[5]
      CORS policy execution failed.
info: Microsoft.AspNetCore.Cors.Infrastructure.CorsService[6]
      Request origin https://shop.bookstore.test does not have permission to access the resource.

The root cause is the last character of the configuration: Microsoft Learn's CORS page says it plainly, the URL (Uniform Resource Locator) given to WithOrigins must not end with /; if it does, the comparison fails. The Origin header a browser sends never has a trailing slash. Remove the slash and the same preflight returns:

< HTTP/1.1 204 No Content
< Date: Sun, 08 Nov 2026 09:02:24 GMT
< Server: Kestrel
< Access-Control-Allow-Headers: authorization,content-type
< Access-Control-Allow-Methods: GET,POST
< Access-Control-Allow-Origin: https://shop.bookstore.test

The lesson of this case: working in curl and Postman proves nothing about CORS, because the browser is what enforces CORS. Putting this curl command into your post-deploy smoke test catches the next surprise within minutes. The other common causes are found the same way: UseCors in the wrong place (the docs say after UseRouting, before UseAuthorization), a custom header missing from the allow list, or a redirect that sends the preflight to another address.

Case 3: A Birth Date That Shifts by a Day

Symptom: a few customers in the United States say their birth date shows up one day early in their profile. The team in Türkiye cannot reproduce it. First wrong hypothesis: "It was saved wrong in the database." The database holds 1990-05-14.

Narrowing down happens on two layers: what does the server send, and how does the browser interpret it? On the server side, three possible JSON forms of the same date:

DateTime fromDb = new(1990, 5, 14);
DateTime asUtc = TimeZoneInfo.ConvertTimeToUtc(fromDb, istanbul);

Console.WriteLine(JsonSerializer.Serialize(new CustomerV1(fromDb), web));
Console.WriteLine(JsonSerializer.Serialize(new CustomerV1(asUtc), web));
Console.WriteLine(JsonSerializer.Serialize(new CustomerV2(DateOnly.FromDateTime(fromDb)), web));
{"birthDate":"1990-05-14T00:00:00"}
{"birthDate":"1990-05-13T21:00:00Z"}
{"birthDate":"1990-05-14"}

I tried the browser side with Node.js, switching the time zone with the TZ environment variable. Each input is parsed with new Date(s):

Europe/Istanbul  1990-05-14             -> getDate()=14  toLocaleDateString('en-US')=5/14/1990
Europe/Istanbul  1990-05-14T00:00:00    -> getDate()=14  toLocaleDateString('en-US')=5/14/1990
Europe/Istanbul  1990-05-13T21:00:00Z   -> getDate()=14  toLocaleDateString('en-US')=5/14/1990
America/New_York 1990-05-14             -> getDate()=13  toLocaleDateString('en-US')=5/13/1990
America/New_York 1990-05-14T00:00:00    -> getDate()=14  toLocaleDateString('en-US')=5/14/1990
America/New_York 1990-05-13T21:00:00Z   -> getDate()=13  toLocaleDateString('en-US')=5/13/1990

This table shows two root causes at once. The first is on the server: a birth date is not an "instant" but a calendar day; converting it to UTC (Coordinated Universal Time) using Istanbul time turns it into an instant like May 13, 21:00, which lands on a different day in each time zone. The second is in the browser and less known: ECMAScript interprets a date-only ISO (International Organization for Standardization) 8601 string as UTC, but a string with a time and no offset as local time. So switching the server to DateOnly and sending "1990-05-14" is not enough by itself; if the frontend hands it to new Date, New York still shows May 13.

The fix has two sides: DateOnly on the server, and on the frontend keeping the value as a string and leaving it to Angular's date formatting. Output of the Vitest test run with TZ=America/New_York:

America/New_York formatDate('1990-05-14') = May 14, 1990
America/New_York formatDate('1990-05-13T21:00:00Z') = May 13, 1990

Angular's formatDate treats a date-only string as a local calendar day. This test stays as the regression test, run in CI (Continuous Integration) with the time zone pinned. The decisive tool was not the debugger but trying the same input with a different time zone. We discussed backend time zone traps in the previous post with the DateTime.Now case.

Case 4: Floating Point in Money Calculations

Symptom: the cart page shows a total of 94.05 TL, but a "cart total does not match payment total" warning appears. First wrong hypothesis: "The backend computes the total wrong." The backend uses decimal and is correct.

const items = [
  { title: 'Dune', price: 29.9, quantity: 3 },
  { title: 'Emma', price: 4.35, quantity: 1 },
];

const total = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
console.log('total            :', total);
console.log('total === 94.05  :', total === 94.05);
console.log('Math.trunc(4.35 * 100):', Math.trunc(4.35 * 100));
console.log('(1.005).toFixed(2):', (1.005).toFixed(2));

// fix: integer minor units (kurus), converted once with Math.round
const totalMinor = items.reduce((sum, i) => sum + Math.round(i.price * 100) * i.quantity, 0);
console.log('totalMinor       :', totalMinor);
console.log('display          :', new Intl.NumberFormat('tr-TR', { style: 'currency', currency: 'TRY' }).format(totalMinor / 100));
total            : 94.04999999999998
total === 94.05  : false
Math.trunc(4.35 * 100): 434
(1.005).toFixed(2): 1.00
totalMinor       : 9405
display          : ₺94,05

The screen showed the rounded total, while the comparison used the raw value. JavaScript's number is an IEEE (Institute of Electrical and Electronics Engineers) 754 double, and 29.9 and 4.35 cannot be represented exactly in binary. The same calculation in C# gives 94.04999999999998 with double and exactly 94.05 with decimal. The Math.trunc(4.35 * 100) line is a separate bug: truncating while converting to minor units loses one kuruş.

The lasting fix is to take money math away from the frontend: the backend computes totals and discounts with decimal, and the frontend only displays them. If the frontend really must calculate, keep amounts as integer minor units, convert from decimals in one place with Math.round, and leave display to Intl.NumberFormat. We followed the series' main case, the "order total is sometimes wrong" ticket, on the backend in posts 01–03 and 11; this case is the frontend member of the same family.

Case 5: Memory Growing from an Observable Never Unsubscribed

Symptom: users say the app slows down when they keep the tab open all day; a refresh fixes it. First wrong hypothesis: "The order list is too big, we need pagination." The list has 20 rows.

To narrow it down, you first need to find which component leaks. The method in the DevTools Memory panel: take a heap snapshot, go in and out of the suspect page (ten times), take another snapshot, open the second one in the Comparison view against the first, and sort by the "# Delta" column. You are looking for a component class whose count grows with the number of visits. In our case the candidate was the badge component:

export class CartBadgeLeaky implements OnInit {
  private readonly events = inject(CartEvents);
  readonly count = signal(0);

  ngOnInit(): void {
    // BUG: never unsubscribed
    this.events.changes$.subscribe(c => this.count.set(c));
  }
}

export class CartBadge implements OnInit {
  private readonly events = inject(CartEvents);
  private readonly destroyRef = inject(DestroyRef);
  readonly count = signal(0);

  ngOnInit(): void {
    this.events.changes$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(c => this.count.set(c));
  }
}

CartEvents is a service living at the root, and it counts active subscribers with tap's subscribe/finalize callbacks. Output of the test that creates and destroys each component 1000 times:

leaky : active subscribers after 1000 visits = 1000
fixed : active subscribers after 1000 visits = 0

The cost of a leak is not the subscription itself but what it holds. The callback captures this; the Subject in the root service keeps the callback alive, and the callback keeps the destroyed component and all its state alive. To measure that, I ran a small Node.js script that opens and closes a view carrying about 200 KB of state 500 times (with node --expose-gc, running the garbage collector before each measurement):

leaky: heap 5.6 MB -> 101.4 MB after 500 visits, still observed: true
fixed: heap 5.8 MB -> 5.9 MB after 500 visits, still observed: false

Even after the same garbage collection, 95 MB does not come back. The root cause: subscribing to a long-lived source from a short-lived object and never ending the subscription. The fix is takeUntilDestroyed, or better, binding the Observable in the template with AsyncPipe or toSignal; both leave cleanup to Angular. HTTP calls emit a single value and complete, so they do not produce this leak; the dangerous ones are never-ending sources such as a Subject, interval, WebSockets and stores. This test stays as the leak's regression test.

Which Tool Was Decisive in Which Case?

CaseMisleading first clueDecisive toolLasting proof
White screen after deploy"There is a JavaScript error"Network panel: 404 and (disk cache), response headersBrowser test replaying the deploy, Cache-Control check
CORS only in production"The token is invalid"API log and a preflight replayed with curlOPTIONS request in the post-deploy smoke test
Birth date shifting by a day"The data was saved wrong"Trying the same input with a different TZUnit test run with TZ=America/New_York
Floating point in money"The backend adds wrong"Printing the raw value without roundingTest checking the raw total (total === 94.05)
Growing memory"The list is too big"Memory panel: heap snapshot ComparisonUnit test counting active subscribers

The last column sums up the series: every case ends with a test that keeps it from happening again.

In Production

In frontend cases the error happens in the user's browser, and all you have left is what they tell you. So set these up in advance: an error report sent from the global ErrorHandler to the server (with version number, route, browser, time zone and the request's trace ID); a /version endpoint or a meta tag in index.html that shows the deployed version, so "which version is the user on?" is answered at a glance.

Asking the user for a HAR (HTTP Archive) file is very valuable for network problems, but a HAR contains cookies, tokens and form data. Ask for it to be sanitized before sharing, and do not leave it attached to a ticket; the rule from post 02 of this series applies here too. The same goes for logs: in the birth date case, log the customer's ID and the raw value sent, not the customer's name.

Behind a CDN, the CDN's own cache can hold index.html too. When deploying, upload the hashed files first and index.html last, and do not delete old chunks for a few versions; that is the cheapest fix for open tabs.

Common Pitfalls

Saying "it works on my machine" and assuming your browser's cache, time zone and origin are the same as the user's. Four of the five cases in this post came from exactly that difference. When reproducing, imitate the user's environment: clean profile, same time zone, same origin.

"Turning off" a CORS error in the browser (an extension, AllowAnyOrigin) and moving on without finding the cause. For requests with credentials, AllowAnyOrigin cannot be combined with AllowCredentials anyway; the real issue is the exact origin match.

Parsing calendar days with new Date("yyyy-MM-dd"), adding up money as number and comparing it, subscribing to a Subject from a component and forgetting cleanup. All three are invisible in a test environment, because a test's time zone, data and duration do not look like the user's.

Series Wrap-Up: A One-Page Debugging Checklist

I boiled the method of thirteen posts down to one list. Copy it into your team's wiki or a ticket template:

  • Write down the symptom: what was expected, what happened, for whom, when, on which version (01, 02).
  • Reproduce: same data, culture, time zone, configuration, browser and cache state; if you cannot reproduce it, do not attempt a fix (02).
  • Failing test first: write the smallest test that catches the bug and let the fix turn it green (02).
  • Split in the middle: move by halves along the UI (User Interface) → state → HTTP → API → database chain and through commit history (git bisect run) (03, 07).
  • Read the error message and stack trace from top to bottom; find the first exception in the log and the traceId (04, 08).
  • Change one thing at a time and write it in your log: hypothesis, experiment, result (01).
  • Use the debugger smartly: conditional breakpoints, tracepoints, exception settings; for timing bugs switch to logs and dumps (05, 06).
  • In production, build a timeline from logs: deploy, config change, traffic and the first error on the same axis (08).
  • Collect evidence for crashes and hangs: dumps, dotnet-stack, a short dotnet-trace; remember dumps contain secrets (09, 10).
  • Get to the root cause: separate symptom, proximate cause and root cause; look for several factors with 5 Whys and a fishbone (11).
  • Finish the fix on three levels: hotfix, regression test, a change that makes this class of bug impossible (11, 12, 13).
  • Verify it is fixed: does the same repro pass now, did the production metric return to normal? If you did not fix it, it is not fixed (01).
  • Write a blameless postmortem and share what you learned (11).

A Small Experiment

★☆☆ Build your own Angular app in production mode and change only one lazy component between two builds. Which file names changed, which stayed the same? Check with curl the Cache-Control headers the server returns for index.html and for the hashed files.

★★☆ Deliberately put a wrong origin into your API's CORS policy, replay the preflight with curl, and find the failure line in the Microsoft.AspNetCore.Cors log. Then add the same check to your deploy pipeline as a smoke test.

★★★ List every subscribe call in your app and classify each as "emits once and completes" or "never-ending source". For those bound to never-ending sources, write a test that creates and destroys the component 1000 times and measures the number of active subscribers.

In this series we talked about approaching a bug with a method, narrowing it down, collecting evidence with tools, and getting to the root cause so it does not happen again. The series ends here, but the road goes on: we will cover CPU (Central Processing Unit) and allocation analysis in the upcoming .NET Profiling and Diagnostics series, log, trace and metric infrastructure in the Observability series on the DevOps side, and the panels in detail in the Browser DevTools series. With your next bug, let your first move be writing the first line of the list above.