How We Reduced LLM Latency from 8s to 800ms Without Losing Quality
Back to all articles
AI Engineering
22 min read12 min read

How We Reduced LLM Latency from 8s to 800ms Without Losing Quality

A deep dive into the systematic approach we used to cut response times by 90% in production, with specific techniques, benchmarks, and the trade-offs we navigated.

Debasish Maji
Debasish Maji
AI Engineering Lead
March 15, 2026
PerformanceLLM OptimizationProductionLatencyStreaming

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:

Diagram
flowchart LR A[User Input] --> B[API Gateway] B --> C[Context Assembly] C --> D[LLM API Call] D --> E[Post-Processing] E --> F[Response] B -.->|50ms| C C -.->|200ms| D D -.->|7200ms| E E -.->|150ms| F
ComponentTime (ms)% of Total
API Gateway & Auth500.6%
Context Assembly (RAG)2002.4%
LLM API Call7,20087.8%
Post-Processing1501.8%
Network Overhead6007.3%
Total8,200100%
The LLM call dominated everything. But within that 7.2 seconds, there were actually two distinct phases:

  1. 1Time to First Token (TTFT): ~2,800ms
  2. 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.

Typescript
// 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:

Markdown
❌ 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.

MetricLong PromptShort Prompt
TTFT2,800ms1,900ms
User satisfaction4.2/54.4/5
Task completion78%82%
Key insight: Prompt length should be proportional to task complexity. For simple tasks, shorter prompts often work better.

•••

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 RequestsGPT-4 Needed?Latency (GPT-4)Latency (GPT-3.5)
Grammar fixes35%No3,200ms800ms
Simple rephrasing25%No4,100ms950ms
Tone adjustment15%Sometimes4,500ms1,100ms
Complex rewriting15%Yes6,200msPoor quality
Creative generation10%Yes7,800msPoor quality
We built a classifier that routes requests to the appropriate model:

Typescript
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:

MetricBeforeAfterImprovement
Median latency5,200ms1,800ms65%
P95 latency9,100ms4,200ms54%
Cost per request$0.024$0.00867%
Quality score4.3/54.2/5-2%
The 2% quality drop was acceptable given the massive latency and cost improvements.

•••

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:

Diagram
flowchart TB A[Incoming Request] --> B{Cache Lookup} B -->|Hit| C[Return Cached Response] B -->|Miss| D[Generate Response] D --> E[Store in Cache] E --> F[Return Response] C --> G[Log Cache Hit] F --> H[Log Cache Miss] style C fill:#22c55e,color:#fff style D fill:#f59e0b,color:#fff

The key insight: we don't cache exact matches. We cache by semantic similarity.

Typescript
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:

MetricValue
Cache hit rate23.4%
Avg cache lookup time45ms
False positive rate1.2%
User-reported issues from caching0.08%
The 0.08% issue rate was acceptable - we added a "Regenerate" button that users could click if they wanted a fresh response.

•••

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.

Diagram
sequenceDiagram participant U as User participant C as Client participant S as Server participant L as LLM U->>C: Types "Make this more" C->>S: Prefetch candidates S->>L: Start generating (speculative) U->>C: Finishes "Make this more professional" C->>S: Confirm request S-->>C: Stream response (already in progress) Note over S,L: 400ms head start

We identified the most common request patterns and pre-warmed the LLM:

Typescript
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:

PatternHit RateAvg Time Saved
"Make more professional"67%450ms
"Make more concise"58%380ms
"Rewrite as bullet points"72%520ms
Overall34%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:

  1. 1Handle authentication at the edge
  2. 2Maintain persistent connections to OpenAI
  3. 3Stream responses directly to users
Typescript
// 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:

MetricBeforeAfterImprovement
Median TTFT2,800ms650ms77%
Median Total Latency8,200ms1,800ms78%
P95 Total Latency14,000ms3,200ms77%
Perceived Latency8,200ms650ms92%
Cost per Request$0.024$0.00963%
Diagram
flowchart LR subgraph Before["Before: 8,200ms"] B1[Gateway<br/>50ms] --> B2[Context<br/>200ms] B2 --> B3[LLM<br/>7,200ms] B3 --> B4[Post<br/>150ms] B4 --> B5[Network<br/>600ms] end subgraph After["After: 800ms (perceived)"] A1[Edge<br/>20ms] --> A2[Cache Check<br/>45ms] A2 --> A3[Model Route<br/>10ms] A3 --> A4[LLM Stream<br/>650ms TTFT] A4 --> A5[Direct Stream<br/>75ms] end style B3 fill:#ef4444,color:#fff style A4 fill:#22c55e,color:#fff
•••

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

  1. 1Measure before optimizing - Our bottleneck was the LLM call, not our code. We could have wasted weeks optimizing the wrong thing.
  1. 2Streaming is table stakes - If you're not streaming LLM responses in 2026, you're leaving perceived performance on the table.
  1. 3Model routing is underutilized - Most apps use GPT-4 for everything. A simple router can cut costs and latency dramatically.
  1. 4Semantic caching works - 23% hit rate with <0.1% quality issues. The ROI is excellent.
  1. 5Speculation is powerful but expensive - Only implement if you have predictable request patterns.
  1. 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.

Found this helpful?

Share it with others who might benefit

TweetShare

Related articles