Blog · Debugging

Debugging Production With Two HAR Files: Working vs Broken

By Supril Singh 8 min read

The hardest production bugs are the ones that reproduce for exactly one person. Your account works. Staging works. The logs show nothing unusual. A customer sends a screenshot of a spinner that never stops.

A HAR diff resolves this class of bug faster than almost anything else, because it compares two complete records of what actually crossed the network — not what you believe crossed it.

A HAR diff is the comparison of two HTTP Archive captures of the same user flow, one where it succeeded and one where it failed, to isolate the first request whose behaviour differs. That first divergence is nearly always the cause or one step from it.

Why this beats reading logs

Server logs tell you what your backend saw. They are silent about everything that happened before the request arrived and after the response left. A HAR captures the whole exchange from the client’s side, which is where these bugs usually live:

  • A request that was never sent at all
  • A request sent with a different payload than you expect
  • A response that returned 200 with a body the client could not handle
  • A CORS preflight that failed, so the real request never happened
  • A cached response the client used instead of asking
  • A third-party script that failed and took a handler down with it

None of these appear as an error in your application logs. All of them are unmistakable in a HAR.

Step 1: Get a clean capture from both sides

The quality of the diff depends entirely on the quality of the capture. Give the customer precise instructions:

  1. Open DevTools, Network tab.
  2. Tick Preserve log.
  3. Tick Disable cache — you want real requests, not cache hits.
  4. Reload, then perform only the failing action.
  5. Click the export arrow, save the .har, and send it.

Then capture your own, doing the identical steps on a working account. Both captures must cover the same flow. A HAR of a full browsing session diffed against one of a single click will drown you in noise.

Before you accept a customer’s HAR, know what you are receiving. It contains their session cookies, auth tokens, and every request body they submitted — potentially including personal data. That is a live credential set for their account.

Treat it accordingly: prefer a support channel over email, delete it when the investigation closes, and never commit it to a repository or paste it into a tool you have not vetted. If your process cannot make those guarantees, ask the customer to scrub it first — Chrome’s export includes an option to strip sensitive headers, and standalone sanitisers exist.

Step 2: Line the two traces up

The naive approach — open both in DevTools and scroll — falls apart quickly, because request ordering varies between sessions.

Normalise first. Sort both by start time, then compare on the tuple of method + path with dynamic segments collapsed. /api/v2/orders/4471 and /api/v2/orders/9902 are the same endpoint for diffing purposes.

A minimal script gets you most of the way:

import json, re
from pathlib import Path

def normalize(url):
  path = re.sub(r"https?://[^/]+", "", url).split("?")[0]
  path = re.sub(r"/\d+", "/{id}", path)                       # numeric ids
  path = re.sub(r"/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f-]+", "/{uuid}", path)
  return path

def load(p):
  entries = json.loads(Path(p).read_text())["log"]["entries"]
  return [
      {
          "key": f'{e["request"]["method"]} {normalize(e["request"]["url"])}',
          "status": e["response"]["status"],
          "size": e["response"]["content"].get("size", 0),
          "time": round(e["time"]),
      }
      for e in entries
  ]

good, bad = load("working.har"), load("broken.har")
good_keys = [e["key"] for e in good]
bad_keys = [e["key"] for e in bad]

print("Missing in broken:", [k for k in good_keys if k not in bad_keys])
print("Only in broken:  ", [k for k in bad_keys if k not in good_keys])

# Same endpoint, different outcome — usually the money shot.
bad_by_key = {e["key"]: e for e in bad}
for e in good:
  other = bad_by_key.get(e["key"])
  if other and other["status"] != e["status"]:
      print(f'{e["key"]}: {e["status"]} -> {other["status"]}')

Step 3: Read the divergence

Work in this order. It is roughly the order of decreasing frequency.

Requests missing from the broken trace. The most informative signal, and the most commonly overlooked. If the working trace has a call the broken one never made, the bug is client-side and earlier than you think — a guard clause, a failed condition, a JavaScript exception that killed the handler before it fired. Stop looking at the backend.

Requests present only in the broken trace. Retries, a redirect to an error page, a token refresh that only fires when something is wrong.

Same endpoint, different status. The obvious one. A 200 becoming a 403 points at permissions or a scope difference between the accounts. A 200 becoming a 404 points at data that does not exist for that user.

Same endpoint, same status, different size. The sneakiest failure mode. A 200 that returns {"items": []} where the working trace returned fifty items is a data problem, not a code problem, and it will never show up as an error anywhere.

Failed preflights. An OPTIONS request that returns non-2xx means the browser blocked the real request. The subsequent call will simply be absent — which is why “missing request” and “CORS problem” are often the same bug.

Timing. Compare the time field. A request that takes 30 s in one trace and 200 ms in the other is hitting a slow path — a missing index against a larger dataset, a cache miss, an N+1 that only triggers past some threshold.

A worked example

A customer reported that their export button did nothing. No error, no download.

The working trace showed:

GET  /api/v2/exports/eligibility   200   142 B
POST /api/v2/exports               202   88 B
GET  /api/v2/exports/8813/status   200   64 B   (x4, polling)
GET  /api/v2/exports/8813/download 200   2.1 MB

The broken trace showed:

GET  /api/v2/exports/eligibility   200   139 B

And nothing else. The POST was never sent.

Both eligibility calls returned 200, which is why the server logs looked clean and why the on-call engineer initially concluded the frontend was fine. But the response bodies differed: the working one had "eligible": true, the broken one "eligible": false with a reason field the UI did not render. The customer’s plan had lapsed. The backend was behaving exactly as designed; the frontend silently swallowed a documented refusal.

Total time from receiving the second HAR to root cause: under ten minutes. Time spent before that, reading logs: two days.

The general lesson: a 200 is not a success. Diffing status codes alone would have found nothing here. The bodies carried the answer.

Making this routine

The teams that get the most from this treat it as process rather than heroics.

Put HAR capture instructions in your support macros, so the first reply to any “it does not work for me” asks for one. Keep a known-good HAR for each critical flow in your repo as a reference to diff against — it doubles as documentation of what the flow is supposed to do. And check the HAR before escalating; a large share of bugs marked “backend” are resolved by noticing a request that was never sent.

Frequently asked questions

Does a HAR capture WebSocket traffic? Chrome records that a WebSocket connection was opened, but message frames are not included in the HAR export. For WebSocket-heavy apps you will need the DevTools frames view or dedicated logging.

What if the bug is intermittent? Ask for Preserve log on and have the customer keep the tab open through several attempts, then send one HAR covering all of them. You can then diff the failing attempt against the succeeding attempt within a single file — which controls for account, network, and browser differences all at once. That is the cleanest comparison available.

Can I diff a HAR against a staging capture? Yes, and it is useful for environment-drift bugs — a missing header added by a proxy in one environment, a different CDN behaviour. Normalise the hostnames before comparing.

How large can these get? A media-heavy session can produce a HAR of tens of megabytes. Filtering to XHR/fetch before export keeps it manageable, at the cost of losing document and asset requests that occasionally matter.


TraceMiner takes both files and isolates the divergence for you, then lets you ask about it in plain language instead of scrolling JSON. Try it with two HARs.

Published blog cards

Browse other published topics from the blog library.