diff --git a/backend/pkg/messages/read-message.go b/backend/pkg/messages/read-message.go
index 4b4d9681f..998d2d20c 100644
--- a/backend/pkg/messages/read-message.go
+++ b/backend/pkg/messages/read-message.go
@@ -1440,6 +1440,9 @@ func DecodeLongAnimationTask(reader BytesReader) (Message, error) {
if msg.Scripts, err = reader.ReadString(); err != nil {
return nil, err
}
+ if msg.Stalled, err = reader.ReadUint(); err != nil {
+ return nil, err
+ }
return msg, err
}
diff --git a/frontend/app/components/shared/FetchDetailsModal/components/FetchTabs/FetchTabs.tsx b/frontend/app/components/shared/FetchDetailsModal/components/FetchTabs/FetchTabs.tsx
index cbf546cc0..e653d5033 100644
--- a/frontend/app/components/shared/FetchDetailsModal/components/FetchTabs/FetchTabs.tsx
+++ b/frontend/app/components/shared/FetchDetailsModal/components/FetchTabs/FetchTabs.tsx
@@ -5,6 +5,7 @@ import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import Headers from '../Headers';
import { useTranslation } from 'react-i18next';
import { TFunction } from 'i18next';
+import FetchTimings from './FetchTimings';
const HEADERS = 'HEADERS';
const REQUEST = 'REQUEST';
@@ -120,6 +121,8 @@ function FetchTabs({ resource, isSpot, isXHR }: Props) {
);
}, [resource]);
+ const noTimings = resource.timings ? Object.values(resource.timings).every((v) => v === 0) : true;
+
const renderActiveTab = () => {
switch (activeTab) {
case REQUEST:
@@ -212,9 +215,28 @@ function FetchTabs({ resource, isSpot, isXHR }: Props) {
/>
);
case TIMINGS:
- return
- {resource.timings ? JSON.stringify(resource.timings, null, 2) : 'notihng :('}
-
;
+ return
+
+
+
+ }
+ size="small"
+ show={noTimings}
+ >
+
+
}
};
const usedTabs = isXHR ? TABS : RESOURCE_TABS;
diff --git a/frontend/app/components/shared/FetchDetailsModal/components/FetchTabs/FetchTimings.tsx b/frontend/app/components/shared/FetchDetailsModal/components/FetchTabs/FetchTimings.tsx
new file mode 100644
index 000000000..73016b3ed
--- /dev/null
+++ b/frontend/app/components/shared/FetchDetailsModal/components/FetchTabs/FetchTimings.tsx
@@ -0,0 +1,205 @@
+import React from 'react';
+import { Tooltip } from 'antd';
+import { HelpCircle } from 'lucide-react'
+
+function FetchTimings({ timings }: { timings: Record }) {
+ const formatTime = (time: number) => {
+ if (time === undefined || time === null) return '—';
+ if (time === 0) return '0ms';
+ if (time < 1) return `${Math.round(time * 1000)}μs`;
+ return `${Math.round(time)}ms`;
+ };
+
+ const total = React.useMemo(() => {
+ const sumOfComponents = Object.entries(timings)
+ .filter(([key]) => key !== 'total')
+ .reduce((sum, [_, value]) => sum + (value || 0), 0);
+
+ const largestComponent = Math.max(
+ ...Object.entries(timings)
+ .filter(([key]) => key !== 'total')
+ .map(([_, value]) => value || 0),
+ );
+
+ return Math.max(timings.total || 0, sumOfComponents, largestComponent);
+ }, [timings.total]);
+ const isAdjusted = timings.total !== undefined && total !== timings.total;
+
+ const phases = [
+ {
+ category: 'Resource Scheduling',
+ children: [
+ {
+ key: 'queueing',
+ name: 'Queueing',
+ color: 'bg-[#99a1af]',
+ description: 'Time spent in browser queue before connection start',
+ },
+ {
+ key: 'stalled',
+ name: 'Stalled',
+ color: 'bg-[#c3c3c3]',
+ description: 'Time request was stalled after connection start',
+ },
+ ],
+ },
+ {
+ category: 'Connection Start',
+ children: [
+ {
+ key: 'dnsLookup',
+ name: 'DNS Lookup',
+ color: 'bg-[#00c951]',
+ description: 'Time spent resolving the DNS',
+ },
+ {
+ key: 'initialConnection',
+ name: 'Initial Connection',
+ color: 'bg-[#efb100]',
+ description: 'Time establishing connection (TCP handshakes/retries)',
+ },
+ {
+ key: 'ssl',
+ name: 'SSL',
+ color: 'bg-[#ad46ff]',
+ description: 'Time spent completing SSL/TLS handshake',
+ },
+ ],
+ },
+ {
+ category: 'Request/Response',
+ children: [
+ {
+ key: 'ttfb',
+ name: 'Request & TTFB',
+ color: 'bg-[#2b7fff]',
+ description: 'Time waiting for first byte (server response time)',
+ },
+ {
+ key: 'contentDownload',
+ name: 'Content Download',
+ color: 'bg-[#00a63e]',
+ description: 'Time spent receiving the response data',
+ },
+ ],
+ },
+ ];
+
+ const calculateTimelines = () => {
+ let currentPosition = 0;
+ const results = [];
+
+ for (const phase of phases) {
+ const parts = [];
+ for (const child of phase.children) {
+ const duration = timings[child.key] || 0;
+ const width = (duration / total) * 100;
+
+ parts.push({
+ ...child,
+ duration,
+ position: currentPosition,
+ width,
+ });
+
+ currentPosition += width;
+ }
+ results.push({
+ category: phase.category,
+ children: parts,
+ });
+ }
+
+ return results;
+ };
+
+ const timelineData = React.useMemo(() => calculateTimelines(), [total]);
+
+ return (
+
+
+
+ {timelineData.map((cat, index) => (
+
+
{cat.category}
+
+ {cat.children.map((phase, index) => (
+
+
+
+
+
+ {phase.name}:
+
+
+
+
+ {phase.width > 0 && (
+
+ )}
+
+
+
+
+ {formatTime(phase.duration)}
+
+
+ ))}
+
+
+ ))}
+
+
+
+ Total:
+
+
+
+ {formatTime(total)}{' '}
+ {isAdjusted ? (
+
+ (adjusted from reported value: {formatTime(timings.total)})
+
+ ) : null}
+
+
+
+
+ {timelineData
+ .flatMap((phase) => phase.children)
+ .filter((phase) => phase.width > 0)
+ .map((phase, index) => (
+
+ ))}
+
+
+
+
0ms
+
{formatTime(total)}
+
+
+
+
+
+ );
+}
+
+export default FetchTimings;
diff --git a/frontend/app/player/web/types/resource.ts b/frontend/app/player/web/types/resource.ts
index b578b990e..3c499ee76 100644
--- a/frontend/app/player/web/types/resource.ts
+++ b/frontend/app/player/web/types/resource.ts
@@ -85,6 +85,7 @@ interface IResource {
decodedBodySize?: number;
responseBodySize?: number;
error?: string;
+ stalled?: number;
}
export interface IResourceTiming extends IResource {
@@ -103,6 +104,7 @@ export interface IResourceTiming extends IResource {
ssl: number;
ttfb: number;
contentDownload: number;
+ stalled: number;
total: number;
};
}
@@ -175,6 +177,7 @@ export function getResourceFromResourceTiming(
ttfb: msg.ttfb,
contentDownload: msg.contentDownload,
total: msg.total,
+ stalled: msg.stalled,
},
});
}
diff --git a/mobs/messages.rb b/mobs/messages.rb
index e9fb97d54..2dddad994 100644
--- a/mobs/messages.rb
+++ b/mobs/messages.rb
@@ -539,6 +539,7 @@ message 85, 'ResourceTiming', :replayer => :devtools do
uint 'SSL'
uint 'ContentDownload'
uint 'Total'
+ uint 'Stalled'
end
message 89, 'LongAnimationTask', :replayer => :devtools do
diff --git a/tracker/tracker/src/main/modules/img.ts b/tracker/tracker/src/main/modules/img.ts
index 2cfc3a988..7b9cd9b41 100644
--- a/tracker/tracker/src/main/modules/img.ts
+++ b/tracker/tracker/src/main/modules/img.ts
@@ -60,7 +60,7 @@ export default function (app: App): void {
const sendImgError = app.safe(function (img: HTMLImageElement): void {
const resolvedSrc = resolveURL(img.src || '') // Src type is null sometimes. - is it true?
if (isURL(resolvedSrc)) {
- app.send(ResourceTiming(app.timestamp(), 0, 0, 0, 0, 0, resolvedSrc, 'img', 0, false, 0, 0, 0, 0, 0, 0))
+ app.send(ResourceTiming(app.timestamp(), 0, 0, 0, 0, 0, resolvedSrc, 'img', 0, false, 0, 0, 0, 0, 0, 0, 0))
}
})
diff --git a/tracker/tracker/src/main/modules/timing.ts b/tracker/tracker/src/main/modules/timing.ts
index fc93938a3..42b7d5038 100644
--- a/tracker/tracker/src/main/modules/timing.ts
+++ b/tracker/tracker/src/main/modules/timing.ts
@@ -130,6 +130,14 @@ export default function (app: App, opts: Partial): void {
// will probably require custom header added to responses for tracked fetch/xhr requests:
// Timing-Allow-Origin: *
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Timing-Allow-Origin
+ let stalled = 0;
+ if (entry.connectEnd && entry.connectEnd > entry.domainLookupEnd) {
+ // Usual case stalled is time between connection establishment and request start
+ stalled = Math.max(0, entry.requestStart - entry.connectEnd);
+ } else {
+ // Connection reuse case - stalled is time between domain lookup and request start
+ stalled = Math.max(0, entry.requestStart - entry.domainLookupEnd);
+ }
const timings = {
queueing: entry.domainLookupStart - entry.startTime,
dnsLookup: entry.domainLookupEnd - entry.domainLookupStart,
@@ -138,7 +146,8 @@ export default function (app: App, opts: Partial): void {
? entry.connectEnd - entry.secureConnectionStart : 0,
ttfb: entry.responseStart - entry.requestStart,
contentDownload: entry.responseEnd - entry.responseStart,
- total: entry.responseEnd - entry.startTime
+ total: entry.duration ?? (entry.responseEnd - entry.startTime),
+ stalled,
};
if (failed) {
app.send(
@@ -159,6 +168,7 @@ export default function (app: App, opts: Partial): void {
timings.ssl,
timings.contentDownload,
timings.total,
+ timings.stalled,
),
)
}
@@ -180,6 +190,7 @@ export default function (app: App, opts: Partial): void {
timings.ssl,
timings.contentDownload,
timings.total,
+ timings.stalled
),
)
}