Measuring Time to First Byte in Marko Streaming Apps
A streamed response has two numbers that are usually reported as one. The first byte can arrive in forty milliseconds while the first renderable markup arrives nine hundred milliseconds later, and a dashboard showing only the first number will call that page fast. This walkthrough measures both, extending the flush model in Marko streaming and the Tags API.
Prerequisites
Implementation Steps
Step 1 — Instrument the flush points on the server
// Wrap the response stream so every write is timestamped relative to request start.
function instrumentStream(res, requestStart) {
const write = res.write.bind(res);
let seq = 0;
res.write = (chunk, ...rest) => {
const t = Math.round(performance.now() - requestStart);
// Log size and offset so a small header-only first write is visible as such.
log('flush', { seq: seq++, atMs: t, bytes: Buffer.byteLength(chunk) });
return write(chunk, ...rest);
};
return res;
}
A first flush of two hundred bytes at forty milliseconds followed by nothing until nine hundred is the signature of a data call above the shell, and this instrumentation shows it without a browser being involved.
Step 2 — Measure arrival at the client
# Byte arrival with timestamps, through whatever chain production uses.
curl --no-buffer -s https://example.test/product/42 | ts '%.s' | awk 'NR<=6 {print}'
# Each line is a chunk as it arrived. The first line carrying <main> or the
# first content element is the number that matters for perceived speed.
Step 3 — Compare the two, and look in the middle when they disagree
If the server logged a flush at forty milliseconds and the client saw nothing until nine hundred, the delay is between them: a proxy buffering, a compressor waiting for more input, or a platform that materialises the whole response before forwarding it. Each of those is a configuration change rather than a code change, which is why the comparison is worth making before touching the template.
Step 4 — Track the gap continuously
Record first byte and first-contentful-paint together in field data and chart the difference. That gap is what regresses when someone adds an await above the shell, and neither number alone will show it.
Verification
Two confirmations close the investigation. First, the direct-origin measurement should match the server flush log within a few milliseconds; if it does not, the buffering is inside the application or its compressor rather than in front of it. Second, after fixing a buffering layer, the client-side gap between first byte and first paint should collapse to roughly the transfer time of the shell — typically tens of milliseconds — rather than tracking the slow data call.
Keep one synthetic check running against production. Streaming behaviour is a property of the deployment as much as the code, and it regresses silently when infrastructure changes: a new proxy version, a changed compression setting, a platform migration. A daily check that asserts the shell arrives within a threshold catches those the day they happen rather than the quarter they are noticed.
What the Numbers Are For
Distinguishing the two numbers changes what you optimise.
A slow first byte is a server problem: a cold start, a slow database round trip before any output, a queueing delay under load. Streaming does not help it, because there is nothing to stream yet. The fixes are capacity, caching and query work — the territory of edge caching and delivery for islands.
A fast first byte with late content is a template problem: something above the shell is waiting for data. The fix is to move the wait below the shell, inside a placeholder that flushes immediately, which is precisely what the out-of-order streaming machinery exists for.
A fast first byte and fast content but slow interactivity is neither — it is an activation problem, measured with the hydration instrumentation rather than with delivery timings. Keeping the three separate prevents the most common misdiagnosis in this area: a team optimising server response time for a page whose real problem is that four islands hydrate eagerly on load.
Troubleshooting
Why is a fast time to first byte not enough?
Because the first byte can be a response header or an empty shell fragment that contains nothing renderable. A page can report an excellent first-byte figure and still show nothing for a second if the flush that carries the head and the layout comes later. Measure both: when bytes start and when enough markup has arrived for the browser to paint something meaningful.
Does compression change how streaming behaves?
It can. A compressor with a large window may buffer input until it has enough to emit a block, delaying small flushes. If the shell is a few hundred bytes and the compressor waits for several kilobytes, the shell sits in the compressor rather than on the wire. Flushing the compressor at deliberate boundaries, or accepting slightly worse compression for the first chunk, resolves it.
Where should the measurement run — server, client or synthetic?
All three, and they answer different questions. Server instrumentation tells you whether the application flushed when you expected. Client measurement tells you what the visitor received. Synthetic monitoring through the real chain tells you whether a proxy or CDN changed the answer between the two. A discrepancy between the first and second is almost always something in the middle.
Related
- Streaming Out-of-Order HTML with Marko — the flush sequence these timings measure.
- Marko Streaming and the Tags API — the compiler and streaming model behind it.
- Caching Streamed HTML with Stale-While-Revalidate — keeping streaming intact through a cache layer.
← Back to Marko Streaming and the Tags API