When someone asks "what is the latency of your model?", the answer is almost always incomplete. They usually mean time-to-first-token or inter-token latency, both of which describe the model's forward pass. But the user does not experience the forward pass. The user experiences everything from the moment they hit send to the moment the last character appears on screen. And that end-to-end number is always larger, sometimes significantly, than the model-only number.
I spent a day instrumenting every stage of a typical LLM serving stack, from client to load balancer to inference server and back, and the breakdown was eye-opening. Here is what I found.
The full request lifecycle
A typical production LLM request traverses these stages:
- Client-side serialization: JSON encoding the request body.
- Network hop 1: Client to load balancer (could cross regions).
- Load balancer processing: Route selection, health checking, connection setup.
- Network hop 2: Load balancer to inference server.
- HTTP parsing: The inference server's HTTP framework (uvicorn, FastAPI) parses the request.
- Tokenization: Converting the input text to token IDs.
- Queue wait: Time spent waiting in the scheduler's queue until a batch slot opens.
- Prefill: The model's forward pass over the full input.
- Decode step 1: Generate the first output token.
- Detokenization: Converting the token ID back to text.
- SSE framing: Wrapping the token in a server-sent event frame.
- Network return: SSE chunk travels back through the load balancer to the client.
- Decode steps 2..N: Repeat decode, detokenize, stream for each subsequent token.
That is at least 12 distinct stages for the first token alone. Let me quantify each one.
The numbers, stage by stage
Here is what I measured on a setup with a 7B model on a single H100, serving through FastAPI + uvicorn behind an nginx load balancer, with the client on the same network (sub-millisecond RTT):
Stage Typical (ms)
---------------------------------------------
Client JSON serialization 0.1 - 0.5
Network hop (same region) 0.5 - 2.0
Load balancer (nginx) 0.2 - 1.0
HTTP parsing (uvicorn) 0.3 - 1.0
Tokenization (512 tokens) 1.0 - 3.0
Queue wait (varies wildly) 0.0 - 500+
Prefill (512 tokens, 7B) 15.0 - 25.0
First decode step 8.0 - 12.0
Detokenization 0.1 - 0.3
SSE framing 0.1 - 0.2
Network return 0.5 - 2.0
---------------------------------------------
Total TTFT (no queue) 26 - 47 ms
Total TTFT (with queue) 26 - 547+ ms
The model forward pass (prefill + first decode) is typically 23 to 37 ms for this setup. The non-model overhead adds 3 to 10 ms. That is a 10 to 30 percent overhead when there is no queueing. Acceptable, but not negligible.
The real story, though, is the queue wait. Under load, queue wait can dwarf everything else. When the server is at capacity and all batch slots are full, a new request has to wait for a slot to open. This is where TTFT goes from 30 ms to 500 ms or more, and it has nothing to do with the model.
Tokenization: the hidden tax
Tokenization surprised me. For short prompts (under 100 tokens), it is sub-millisecond and irrelevant. But for long prompts, it adds up. Tokenizing 8K tokens with the Llama tokenizer takes 5 to 8 ms on a single CPU core. That is 20 to 30 percent of the prefill time for a 7B model.
This matters because tokenization happens on the CPU, in the Python event loop, and it is synchronous in most serving frameworks. While the CPU is tokenizing one request, the event loop is blocked and cannot process incoming SSE responses for other requests. In high-throughput deployments, I have seen tokenization of long prompts cause visible stalls in the SSE stream of concurrent requests.
# Tokenization benchmark
from transformers import AutoTokenizer
import time
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf")
text = "word " * 8000 # ~8K tokens
start = time.perf_counter()
tokens = tokenizer.encode(text)
elapsed = (time.perf_counter() - start) * 1000
print(f"{len(tokens)} tokens in {elapsed:.1f} ms")
# Typical output: 8001 tokens in 6.2 ms
The fix is to offload tokenization to a separate thread or process. vLLM does this with its tokenizer pool (--tokenizer-pool-size), which runs tokenization in background processes. SGLang handles it similarly. If you are building your own serving layer, do not tokenize on the main event loop.
Detokenization has a similar issue but smaller magnitude, because it processes one token at a time. However, incremental detokenization with SentencePiece-based tokenizers requires maintaining state across tokens to handle multi-byte characters correctly. A naive implementation that decodes each token independently will produce garbled output for non-ASCII text.
Queue wait: the silent killer
Queue wait is the most variable component and the hardest to optimize. It depends on three things: the arrival rate of requests, the batch size the server can handle, and the average generation length (which determines how long each batch slot is occupied).
With continuous batching, a slot opens as soon as one request in the batch finishes generating, rather than waiting for the entire batch to complete. This dramatically reduces queue wait compared to static batching, where a request might wait for the slowest request in the batch to finish before the next batch starts.
But even with continuous batching, if the arrival rate exceeds the throughput, the queue grows without bound. I monitor two metrics to catch this:
- Queue depth: the number of requests waiting for a batch slot. If this trends upward, you are under-provisioned.
- P99 queue wait: the 99th percentile wait time. This should be under 100 ms for interactive workloads. If it is consistently above that, either scale up (more GPUs) or scale out (more replicas).
Network and SSE: death by a thousand frames
For streaming responses, each token generates a separate SSE event. At 50 tokens per second, that is 50 HTTP frames per second per request. With 100 concurrent requests, the server is pushing 5000 frames per second. The per-frame overhead of SSE is small (a few bytes of framing), but the kernel has to context-switch for each send call, and TCP Nagle's algorithm can add up to 40 ms of buffering if not disabled.
Practical tips I have learned:
- Set
TCP_NODELAYon all sockets in the serving stack. Nagle's algorithm batches small writes, which adds latency to SSE streams. - Disable response buffering in nginx:
proxy_buffering off;andX-Accel-Buffering: no. - If using a CDN or reverse proxy, ensure it supports streaming and does not buffer the entire response before forwarding.
Cross-region: when the network dominates
Everything changes when the client is in a different region from the inference server. A cross-region network hop adds 50 to 200 ms of round-trip time. For TTFT, this adds one RTT (the request must reach the server). For inter-token latency perceived by the client, it adds one RTT per SSE chunk.
In practice, SSE chunks are pipelined on a persistent connection, so the inter-token overhead is not a full RTT per token. But the first-byte latency absolutely includes the full RTT. If your inference server is in us-east and your user is in ap-south, you are adding 200 ms to TTFT before the model even starts running. This is why geo-aware routing matters for latency-sensitive applications.
If you are only measuring model latency, you are measuring the wrong thing. Instrument the full path, from client send to client receive, and break it down by stage. The optimization that gives you the biggest latency win might not be in the model at all.
For how to build the streaming client, see SSE streaming client. For queue management with batch formation, see priority request queue. For load balancing strategies, see round-robin and least-connections load balancers.