The Problem: 8-Second Responses Were Killing Our Product
March 2025. Our AI writing assistant had just crossed 50,000 daily active users. The product was working - users loved the quality - but our support inbox was filling with the same complaint: "It's too slow."
We instrumented everything. The median response time was 8.2 seconds. P95 was 14 seconds. For a writing assistant where users expected near-instant suggestions, this was a product-killing problem.
This post documents the exact steps we took to achieve sub-second responses while maintaining output quality. No hand-waving - I'll share the specific techniques, the benchmarks, and the trade-offs we made.
Understanding Where Time Goes
Before optimizing anything, we needed to understand our latency budget. Here's what we found:
| Component | Time (ms) | % of Total |
|---|---|---|
| API Gateway & Auth | 50 | 0.6% |
| Context Assembly (RAG) | 200 | 2.4% |
| LLM API Call | 7,200 | 87.8% |
| Post-Processing | 150 | 1.8% |
| Network Overhead | 600 | 7.3% |
| Total | 8,200 | 100% |
- 1Time to First Token (TTFT): ~2,800ms
- 2Token Generation: ~4,400ms (for ~500 tokens at ~9ms/token)
This breakdown was crucial. It revealed that even with streaming, users would wait nearly 3 seconds before seeing anything.
Optimization 1: Streaming (The Obvious One)
Impact: Perceived latency reduced from 8.2s to 2.8s
Streaming was the first thing we implemented. Instead of waiting for the complete response, we streamed tokens as they were generated.
// Before: Wait for complete response
const response = await openai.chat.completions.create({
model: "gpt-4",
messages: [...],
});
return response.choices[0].message.content;
// After: Stream tokens as they arrive
const stream = await openai.chat.completions.create({
model: "gpt-4",
messages: [...],
stream: true,
});
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content;
if (content) {
yield content; // Send to client immediately
}
}
Important caveat: Streaming doesn't reduce actual latency - the full response still takes 8+ seconds to complete. But it dramatically improves perceived latency because users see progress immediately.
However, we still had that 2.8-second wait before the first token. For a writing assistant, that felt like an eternity.
Optimization 2: Prompt Engineering for Speed
Impact: TTFT reduced from 2,800ms to 1,900ms
Most engineers don't realize that prompt length directly affects Time to First Token. The model must process every input token before generating the first output token.
Our original system prompt was 2,400 tokens. We went through it line by line:
❌ BEFORE (2,400 tokens):
"You are an expert writing assistant with decades of experience
in professional communication. You understand the nuances of
business writing, academic prose, creative fiction, and technical
documentation. When helping users, you should consider their
audience, tone, purpose, and the specific conventions of their
genre. You have expertise in grammar, style, rhetoric, and
persuasion techniques. You can help with..."
[... 2,000 more tokens of context ...]
✓ AFTER (400 tokens):
"Writing assistant. Match user's tone. Be concise unless asked
for detail. Format: brief suggestion, then explanation if needed."
We ran A/B tests on output quality. Surprisingly, the shorter prompt produced better results for most tasks - the verbose prompt was actually confusing the model with contradictory instructions.
| Metric | Long Prompt | Short Prompt |
|---|---|---|
| TTFT | 2,800ms | 1,900ms |
| User satisfaction | 4.2/5 | 4.4/5 |
| Task completion | 78% | 82% |
Optimization 3: Model Selection Strategy
Impact: Average latency reduced by 60% with model routing
Here's a truth that took us too long to accept: GPT-4 is overkill for 70% of requests.
We analyzed 10,000 requests and categorized them:
| Task Type | % of Requests | GPT-4 Needed? | Latency (GPT-4) | Latency (GPT-3.5) |
|---|---|---|---|---|
| Grammar fixes | 35% | No | 3,200ms | 800ms |
| Simple rephrasing | 25% | No | 4,100ms | 950ms |
| Tone adjustment | 15% | Sometimes | 4,500ms | 1,100ms |
| Complex rewriting | 15% | Yes | 6,200ms | Poor quality |
| Creative generation | 10% | Yes | 7,800ms | Poor quality |
interface ModelRouter {
classify(request: UserRequest): ModelTier;
route(request: UserRequest): Promise<LLMResponse>;
}
type ModelTier = 'fast' | 'balanced' | 'quality';
const modelConfig: Record<ModelTier, ModelConfig> = {
fast: {
model: 'gpt-3.5-turbo',
maxTokens: 500,
temperature: 0.3
},
balanced: {
model: 'gpt-4-turbo',
maxTokens: 1000,
temperature: 0.5
},
quality: {
model: 'gpt-4',
maxTokens: 2000,
temperature: 0.7
}
};
async function routeRequest(request: UserRequest): Promise<LLMResponse> {
// Simple heuristic-based routing
const tier = classifyRequest(request);
// Start with fast model
const response = await callModel(modelConfig[tier], request);
// Quality gate: if confidence is low, escalate
if (tier === 'fast' && response.confidence < 0.8) {
return callModel(modelConfig['balanced'], request);
}
return response;
}
function classifyRequest(request: UserRequest): ModelTier {
const text = request.text.toLowerCase();
const wordCount = text.split(/\\s+/).length;
// Grammar/spelling: fast model
if (request.task === 'grammar' || request.task === 'spelling') {
return 'fast';
}
// Short, simple rephrasing: fast model
if (request.task === 'rephrase' && wordCount < 50) {
return 'fast';
}
// Creative or complex: quality model
if (request.task === 'creative' || request.task === 'essay') {
return 'quality';
}
// Default: balanced
return 'balanced';
}
Results after model routing:
| Metric | Before | After | Improvement |
|---|---|---|---|
| Median latency | 5,200ms | 1,800ms | 65% |
| P95 latency | 9,100ms | 4,200ms | 54% |
| Cost per request | $0.024 | $0.008 | 67% |
| Quality score | 4.3/5 | 4.2/5 | -2% |
Optimization 4: Semantic Caching
Impact: 23% of requests served in <100ms
Many writing assistant requests are similar. "Make this more professional" on similar text types often produces similar outputs. We implemented semantic caching:
The key insight: we don't cache exact matches. We cache by semantic similarity.
interface CacheEntry {
requestEmbedding: number[];
taskType: string;
response: string;
quality_score: number;
created_at: Date;
}
class SemanticCache {
private vectorStore: VectorStore;
private similarityThreshold = 0.92;
async get(request: CacheableRequest): Promise<CacheEntry | null> {
const embedding = await this.embed(request);
const results = await this.vectorStore.search({
vector: embedding,
filter: { taskType: request.taskType },
topK: 1,
threshold: this.similarityThreshold
});
if (results.length === 0) return null;
const entry = results[0];
// Additional validation: check if cached response
// is still appropriate for this specific request
if (!this.isResponseApplicable(request, entry)) {
return null;
}
return entry;
}
private isResponseApplicable(
request: CacheableRequest,
entry: CacheEntry
): boolean {
// Don't use cache if request has specific constraints
// that the cached response might not satisfy
if (request.constraints?.maxLength) {
const cachedLength = entry.response.split(/\\s+/).length;
if (cachedLength > request.constraints.maxLength * 1.2) {
return false;
}
}
// Don't use stale cache for time-sensitive content
const ageHours = (Date.now() - entry.created_at.getTime()) / 3600000;
if (request.isTimeSensitive && ageHours > 24) {
return false;
}
return true;
}
}
Cache performance after 30 days:
| Metric | Value |
|---|---|
| Cache hit rate | 23.4% |
| Avg cache lookup time | 45ms |
| False positive rate | 1.2% |
| User-reported issues from caching | 0.08% |
Optimization 5: Speculative Execution
Impact: Further 400ms reduction in TTFT
This is the most sophisticated optimization we implemented. The idea: start generating a response before the user finishes typing.
We identified the most common request patterns and pre-warmed the LLM:
const commonPrefixes = [
{ pattern: /^make.*more\s*$/i, likelyCompletions: ['professional', 'concise', 'formal'] },
{ pattern: /^rewrite.*as\s*$/i, likelyCompletions: ['bullet points', 'paragraph', 'email'] },
{ pattern: /^fix.*grammar/i, likelyCompletions: [] }, // No speculation needed
];
class SpeculativeExecutor {
private pendingSpeculations: Map<string, AbortController> = new Map();
async onPartialInput(sessionId: string, partialText: string, context: string) {
// Cancel any existing speculation for this session
this.pendingSpeculations.get(sessionId)?.abort();
// Check if input matches a speculatable pattern
for (const { pattern, likelyCompletions } of commonPrefixes) {
if (pattern.test(partialText) && likelyCompletions.length > 0) {
const controller = new AbortController();
this.pendingSpeculations.set(sessionId, controller);
// Start speculative generation for most likely completion
const speculativePrompt = partialText + likelyCompletions[0];
this.speculativeGenerate(sessionId, speculativePrompt, context, controller.signal);
break;
}
}
}
private async speculativeGenerate(
sessionId: string,
prompt: string,
context: string,
signal: AbortSignal
) {
const cacheKey = 'speculation:' + sessionId;
try {
const stream = await openai.chat.completions.create({
model: 'gpt-3.5-turbo', // Use fast model for speculation
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: context },
{ role: 'user', content: prompt }
],
stream: true,
});
let buffer = '';
for await (const chunk of stream) {
if (signal.aborted) break;
buffer += chunk.choices[0]?.delta?.content || '';
await this.cache.set(cacheKey, buffer, { ttl: 10 }); // 10s TTL
}
} catch (e) {
if (e.name !== 'AbortError') throw e;
}
}
async onFinalInput(sessionId: string, finalPrompt: string): Promise<AsyncIterable<string>> {
const speculatedResult = await this.cache.get('speculation:' + sessionId);
if (speculatedResult && this.isSpeculationUsable(speculatedResult, finalPrompt)) {
// Speculation was correct! Stream the cached result
return this.streamFromCache(speculatedResult);
}
// Speculation missed, generate normally
return this.generateFresh(finalPrompt);
}
}
Speculation hit rates by pattern:
| Pattern | Hit Rate | Avg Time Saved |
|---|---|---|
| "Make more professional" | 67% | 450ms |
| "Make more concise" | 58% | 380ms |
| "Rewrite as bullet points" | 72% | 520ms |
| Overall | 34% | 410ms |
Optimization 6: Edge Deployment & Connection Pooling
Impact: Network overhead reduced from 600ms to 150ms
Our servers were in us-east-1, but we had users globally. The round-trip time to Singapore was 280ms per request.
We deployed edge functions that:
- 1Handle authentication at the edge
- 2Maintain persistent connections to OpenAI
- 3Stream responses directly to users
// Edge function (Vercel/Cloudflare)
export const config = { runtime: 'edge' };
// Connection pool - reuse connections across requests
const connectionPool = new Map<string, OpenAI>();
function getClient(region: string): OpenAI {
if (!connectionPool.has(region)) {
connectionPool.set(region, new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
maxRetries: 2,
timeout: 30000,
}));
}
return connectionPool.get(region)!;
}
export default async function handler(req: Request) {
const region = req.headers.get('x-vercel-ip-country') || 'US';
const client = getClient(region);
// Stream directly from edge
const stream = await client.chat.completions.create({
model: 'gpt-4-turbo',
messages: await req.json(),
stream: true,
});
return new Response(
new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
const text = chunk.choices[0]?.delta?.content || '';
controller.enqueue(new TextEncoder().encode(text));
}
controller.close();
},
}),
{ headers: { 'Content-Type': 'text/event-stream' } }
);
}
The Final Numbers
After implementing all optimizations:
| Metric | Before | After | Improvement |
|---|---|---|---|
| Median TTFT | 2,800ms | 650ms | 77% |
| Median Total Latency | 8,200ms | 1,800ms | 78% |
| P95 Total Latency | 14,000ms | 3,200ms | 77% |
| Perceived Latency | 8,200ms | 650ms | 92% |
| Cost per Request | $0.024 | $0.009 | 63% |
Trade-offs We Made
Let me be honest about what we sacrificed:
1. Complexity increased significantly
Our codebase went from a simple API wrapper to a system with caching, routing, speculation, and edge deployment. More moving parts means more things that can break.
2. Quality variance
Model routing means some requests get GPT-3.5 instead of GPT-4. For 98% of requests, users can't tell the difference. For 2%, they can - and some users noticed.
3. Cache invalidation headaches
Semantic caching is powerful but tricky. We've had bugs where stale advice was served. We now have extensive monitoring to catch these.
4. Speculation costs money
About 66% of speculative generations are wasted. We're essentially paying for unused compute to reduce latency for the 34% that hit.
Key Takeaways
- 1Measure before optimizing - Our bottleneck was the LLM call, not our code. We could have wasted weeks optimizing the wrong thing.
- 2Streaming is table stakes - If you're not streaming LLM responses in 2026, you're leaving perceived performance on the table.
- 3Model routing is underutilized - Most apps use GPT-4 for everything. A simple router can cut costs and latency dramatically.
- 4Semantic caching works - 23% hit rate with <0.1% quality issues. The ROI is excellent.
- 5Speculation is powerful but expensive - Only implement if you have predictable request patterns.
- 6Edge deployment matters - For global users, edge functions can shave hundreds of milliseconds.
The writing assistant that was "too slow" now feels instant. User retention improved 34% in the month after these optimizations shipped.
Latency optimization isn't glamorous work, but it's often the difference between a product users tolerate and one they love.
