Dev Playbook
Conventions

Performance

Response time budgets, caching, and optimization patterns.

Response Time Budgets

Endpoint TypeTarget (p95)Max (p99)
Simple read (GET by ID)\< 100ms\< 200ms
List with pagination\< 200ms\< 500ms
Search with filters\< 300ms\< 800ms
Write (POST/PUT/PATCH)\< 200ms\< 500ms
File upload\< 2s\< 5s
Report generation\< 5s\< 10s (or async)

If an operation exceeds 10 seconds, make it asynchronous — return 202 Accepted and process in the background.

Database Performance

N+1 Query Prevention

The most common performance bug. Loading a list then querying each item individually.

// BAD — N+1: 1 query for courses + N queries for instructors
var courses = await _context.Courses.ToListAsync();
foreach (var course in courses)
{
    course.Instructor = await _context.Users.FindAsync(course.InstructorId);
}

// GOOD — eager loading: 1 query with JOIN
var courses = await _context.Courses
    .Include(c => c.Instructor)
    .ToListAsync();
// BAD — N+1
const courses = await prisma.course.findMany();
for (const course of courses) {
  course.instructor = await prisma.user.findUnique({ where: { id: course.instructorId } });
}

// GOOD — include relation
const courses = await prisma.course.findMany({
  include: { instructor: true },
});
# BAD — N+1
courses = session.query(Course).all()
for course in courses:
    print(course.instructor.name)  # lazy load triggers N queries

# GOOD — eager load
courses = session.query(Course).options(joinedload(Course.instructor)).all()

Query Rules

  • Always paginate list endpoints — never return unbounded results
  • Select only needed columns — don't SELECT * when you need 3 fields
  • Index WHERE, JOIN, ORDER BY columns — see database.md
  • Use EXPLAIN ANALYZE on slow queries to understand the execution plan
  • Set query timeouts — a runaway query shouldn't take down the database
// Select only what you need
var courseSummaries = await _context.Courses
    .Where(c => c.TenantId == tenantId && !c.IsDeleted)
    .Select(c => new CourseSummaryDto
    {
        Id = c.Id,
        Title = c.Title,
        StudentCount = c.Enrollments.Count,
    })
    .ToListAsync();

Caching Strategy

Cache Layers

LayerWhat to CacheTTLInvalidation
BrowserStatic assets (JS, CSS, images)Long (1 year with hash)New deployment
CDNPublic pages, images, API responsesMinutes to hoursPurge on update
ApplicationDB query results, computed valuesSeconds to minutesWrite-through or event-based
DatabaseQuery plans, prepared statementsAutomaticDatabase manages this

When to Cache

  • Data that's read far more than written (course catalog, user profiles)
  • Expensive computations (analytics, aggregations, reports)
  • External API responses (with appropriate TTL)

When NOT to Cache

  • User-specific sensitive data (unless per-user cache with auth)
  • Rapidly changing data (real-time feeds, live counters)
  • Data that must be consistent (financial transactions, enrollment counts during registration)

Implementation

// .NET — IDistributedCache with Redis
public async Task<CourseDto?> GetCourseAsync(Guid courseId)
{
    var cacheKey = $"course:{courseId}";
    var cached = await _cache.GetStringAsync(cacheKey);

    if (cached != null)
        return JsonSerializer.Deserialize<CourseDto>(cached);

    var course = await _repository.GetByIdAsync(courseId);
    if (course == null) return null;

    var dto = course.ToDto();
    await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(dto),
        new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) });

    return dto;
}

// Invalidate on write
public async Task UpdateCourseAsync(Guid courseId, UpdateCourseRequest request)
{
    await _repository.UpdateAsync(courseId, request);
    await _cache.RemoveAsync($"course:{courseId}");
}
// TypeScript — Redis cache helper
async function cached<T>(key: string, ttlSeconds: number, fn: () => Promise<T>): Promise<T> {
  const hit = await redis.get(key);
  if (hit) return JSON.parse(hit);

  const result = await fn();
  await redis.setex(key, ttlSeconds, JSON.stringify(result));
  return result;
}

// Usage
const course = await cached(`course:${id}`, 300, () => db.course.findUnique({ where: { id } }));

Cache Key Convention

<entity>:<id>                    → course:abc-123
<entity>:list:<tenant>:<hash>    → course:list:tenant-1:page1-size20
<entity>:count:<tenant>          → course:count:tenant-1

Frontend Performance

Core Web Vitals Targets

MetricTargetWhat It Measures
LCP (Largest Contentful Paint)\< 2.5sLoading performance
INP (Interaction to Next Paint)\< 200msInteractivity
CLS (Cumulative Layout Shift)\< 0.1Visual stability

Rules

  • Lazy load below-the-fold content and routes
  • Optimize images — use WebP/AVIF, serve responsive sizes, always set width/height
  • Minimize JavaScript — code split by route, tree-shake unused imports
  • Prefetch likely navigation targets
  • Use SSR/SSG for content pages, CSR for interactive dashboards
// Next.js — Dynamic import for heavy components
import dynamic from 'next/dynamic';

const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
  loading: () => <ChartSkeleton />,
  ssr: false,
});

// Image optimization
import Image from 'next/image';

<Image
  src="/hero.jpg"
  alt="Course hero image"
  width={1200}
  height={630}
  priority  // LCP image — load immediately
/>

Background Jobs

Move heavy operations out of the request path:

OperationApproach
Sending emailsQueue → Background worker
Generating reportsQueue → Return job ID → Poll/webhook for result
Processing uploadsQueue → Worker → Notify on completion
Analytics aggregationScheduled job (cron)
Cache warmingScheduled job or event-driven
// .NET — Return 202 and process in background
[HttpPost("reports/generate")]
public async Task<IActionResult> GenerateReport([FromBody] ReportRequest request)
{
    var jobId = await _backgroundJobs.EnqueueAsync<ReportGenerator>(
        x => x.GenerateAsync(request));

    return Accepted(new { jobId, statusUrl = $"/api/reports/jobs/{jobId}" });
}

Anti-Patterns

  • No pagination — Returning 10,000 rows because "it works in dev"
  • **SELECT *** — Fetching 20 columns when you need 3
  • Cache everything — Caching data that changes every second or is accessed once
  • Premature optimization — Profile first, optimize the bottleneck, not what you guess
  • Synchronous heavy operations — Generating PDFs or sending emails in the request handler
  • No query monitoring — You can't fix what you can't see. Log slow queries (\> 100ms)

On this page