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

First Byte Is Not First Content In the healthy case the first byte and the shell arrive together at forty milliseconds, and later flushes fill in content. In the misleading case the first byte arrives at forty milliseconds but carries only headers and an opening tag, and the shell does not arrive until nine hundred milliseconds because a data call sits above it. Both report the same time to first byte. Same first-byte figure, very different pages healthy shell at 40 ms — the browser can paint content flushes follow; nothing above the shell awaits data misleading headers at 40 ms — nothing renderable shell at 900 ms — a data call sits above it in the template Report the gap between first byte and first renderable markup, not either number alone.

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

Three Places the Bytes Can Wait Between the application and the visitor there are three buffers: the compressor, the reverse proxy and the CDN. Each can accumulate a small flush until it has more data. The diagnostic is to measure at each boundary in turn, starting with a direct request to the origin and adding one layer at a time. Measure at each boundary, adding one layer at a time application flush log says 40 ms compressor may wait for a block reverse proxy buffering often on by default CDN tees, or accumulates Procedure: request the origin directly, then through the proxy, then through the CDN The layer where the arrival time jumps is the layer that is buffering. Nothing else needs to be inspected.

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.

Three Problems, Three Measurements A slow first byte is a server problem, a fast first byte with late content is a template problem, and fast delivery with slow interactivity is an activation problem. Each has its own instrument. Match the symptom to the instrument slow first byte server: capacity, query time, cold start — streaming cannot help fast first byte, late content template: something above the shell is awaiting data fast delivery, slow interactivity · activation: measure hydration, not delivery

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.

← Back to Marko Streaming and the Tags API