Chris Parmer — home

Network waterfall

HTTP request timeline from Chrome HAR

Example from the compendium of canonical charts

Network waterfall — HTTP request timeline from Chrome HAR

Python Code

"""Network waterfall — HTTP request timeline from Chrome HAR."""


import json
import numpy as np
import plotly.graph_objects as go
import requests, warnings


# Public HAR sample
HAR_URL = "https://raw.githubusercontent.com/nicowillis/HAR-viewer/master/demo-har.json"
HAR_URL2 = "https://raw.githubusercontent.com/janodvarko/harviewer/master/webapp/examples/weather.har"


PHASE_COLORS = {
    "dns":       "#52B3D0",   # teal
    "connect":   "#52B3D0",   # teal
    "ssl":       "#DA5597",   # pink
    "send":      "#55B685",   # green
    "wait":      "#845EEE",   # violet (TTFB — the main cost)
    "receive":   "#A0C878",   # light green
    "blocked":   "#d9d9e0",   # grid gray
    "queued":    "#d9d9e0",
}


def parse_har(har):
    """Extract per-request waterfall segments from HAR data."""
    try:
        entries = har["log"]["entries"]
    except KeyError:
        return []

    rows = []
    t0 = None
    for i, entry in enumerate(entries[:40]):
        try:
            started = entry.get("startedDateTime", "")
            # Parse relative start from startedDateTime + timings
            if t0 is None:
                t0_val = 0.0
                t0 = t0_val
            else:
                t0_val = t0

            url = entry.get("request", {}).get("url", f"request-{i}")
            url_label = url.split("?")[0][-50:] if len(url) > 50 else url
            timings = entry.get("timings", {})
            total_ms = entry.get("time", 0)

            # Accumulate start offset from previous rows
            start = rows[-1]["end"] if rows else 0

            phases = []
            cursor = start
            for phase in ["blocked", "dns", "connect", "ssl", "send", "wait", "receive"]:
                t = timings.get(phase, -1)
                if t is not None and t > 0:
                    phases.append({
                        "phase": phase,
                        "start": cursor,
                        "duration": t,
                        "end": cursor + t,
                    })
                    cursor += t

            rows.append({
                "url": url_label,
                "start": start,
                "end": cursor if cursor > start else start + max(total_ms, 0.1),
                "phases": phases,
            })
        except Exception:
            continue

    return rows


def synthetic_har():
    """Build realistic synthetic HAR waterfall for a typical page load."""
    rng = np.random.default_rng(33)
    entries = [
        ("example.com/",             10,   2,  5,   8,  120, 45),
        ("example.com/app.js",        0,   0,  0,   1,   80, 55),
        ("example.com/main.css",      0,   0,  0,   1,   30, 12),
        ("cdn.com/react.min.js",      5,  15, 20,   1,  200, 85),
        ("example.com/api/user",      0,   0,  0,   2,   95, 18),
        ("example.com/api/items",     0,   0,  0,   2,  145, 32),
        ("fonts.google.com/font.woff", 8,  12, 18,   1,   55, 40),
        ("example.com/logo.png",      0,   0,  0,   1,   25, 30),
        ("example.com/hero.jpg",      0,   0,  0,   1,   15, 180),
        ("analytics.com/track.js",   12,   8, 10,   1,   70, 22),
        ("example.com/api/recs",      0,   0,  0,   2,  220, 28),
        ("cdn.com/chart.min.js",      3,  10, 14,   1,  160, 60),
    ]

    rows = []
    cursor = 0
    for label, blocked, dns, connect, send, wait, receive in entries:
        phases = []
        t = cursor
        for phase, dur in [("blocked", blocked), ("dns", dns),
                            ("connect", connect), ("send", send),
                            ("wait", wait), ("receive", receive)]:
            jitter_lo = -max(1, int(dur*0.3))
            jitter_hi =  max(2, int(dur*0.3)+1)
            dur_j = max(1, dur + int(rng.integers(jitter_lo, jitter_hi)))
            if dur_j > 0:
                phases.append({"phase": phase, "start": t, "duration": dur_j, "end": t + dur_j})
                t += dur_j
        rows.append({"url": label, "start": cursor, "end": t, "phases": phases})
        # Next request starts ~90% through current (parallel pipeline)
        overlap = max(0, int((t - cursor) * 0.7))
        cursor = cursor + overlap
    return rows


def generate():
    print("fetching HAR file …")
    warnings.filterwarnings("ignore")
    try:
        requests.packages.urllib3.disable_warnings()
    except Exception:
        pass

    rows = None
    for url in [HAR_URL, HAR_URL2]:
        try:
            r = requests.get(url, verify=False, timeout=20)
            if r.status_code == 200:
                har = r.json()
                rows = parse_har(har)
                if rows:
                    source = url.split("/")[-1]
                    print(f"  parsed {len(rows)} entries from {url}")
                    break
        except Exception as e:
            print(f"  {url} failed: {e}")

    if not rows:
        print("  using synthetic HAR data")
        rows = synthetic_har()
        source = "Synthetic page load"

    # Build gantt-style figure
    fig = go.Figure()

    phase_names = list(PHASE_COLORS.keys())
    legend_added = set()
    y_labels = [r["url"] for r in rows]

    for i, row in enumerate(rows):
        y_pos = len(rows) - 1 - i  # top to bottom
        for seg in row["phases"]:
            ph = seg["phase"]
            color = PHASE_COLORS.get(ph, "#aaaaaa")
            show_legend = ph not in legend_added
            legend_added.add(ph)
            dur = max(seg["duration"], 0.1)   # avoid low >= high with zero-width bars
            fig.add_trace(go.Bar(
                x=[dur],
                y=[row["url"]],
                base=[seg["start"]],
                orientation="h",
                marker=dict(color=color),
                name=ph,
                legendgroup=ph,
                showlegend=show_legend,
                hovertemplate=f"<b>{row['url']}</b><br>{ph}: {seg['duration']:.1f}ms<extra></extra>",
                width=0.6,
            ))

    fig.update_layout(
        title=dict(text=f"Network Waterfall — {source}", x=0.5),
        barmode="overlay",
        xaxis=dict(title="Time (ms)"),
        yaxis=dict(
            title="",
            autorange="reversed",
            tickfont=dict(size=13),
            showgrid=False,
        ),
        legend=dict(orientation="h", y=1.06, x=0, traceorder="normal", font=dict(size=12)),
        margin=dict(t=90, b=40, l=220, r=40),
        height=max(350, len(rows) * 28 + 120),
    )
    return fig


if __name__ == "__main__":
    generate()

Made with Plotly