Vercel Hobby-tier functions time out at 10 seconds and Pro at 60 or 300 seconds — AI generation, file processing, and report exports regularly approach these limits. When they hit, users see a generic error or a permanent spinner with no indication of whether the operation is still running, failed, or timed out. CWE-755 (Improper Handling of Exceptional Conditions) applies: failing to distinguish a timeout from other errors means users can't make an informed decision about whether to wait or retry. ISO 25010 reliability.fault-tolerance requires that timeout conditions be surfaced to users with a recovery path, not collapsed into opaque generic errors.
Medium because timeout errors without specific messaging produce permanent spinners or generic failures, making it impossible for users to distinguish retryable timeouts from permanent errors.
Use AbortController with a timeout signal for all long-running fetch requests, and catch AbortError separately from other failures.
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 30000)
try {
const response = await fetch('/api/generate', { signal: controller.signal })
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
setError('This request timed out. Please try again — complex requests may take longer.')
} else {
setError('Something went wrong. Please try again.')
}
} finally {
clearTimeout(timeoutId)
}
For operations that routinely exceed 10 seconds on Vercel Hobby, move them to background jobs (Inngest or Trigger.dev) and poll for completion status rather than blocking on a single long-lived request.
ID: saas-error-handling.user-errors.timeout-messaging
Severity: medium
What to look for: Look for long-running operations in the codebase: AI API calls, file uploads, report generation, email sending, large data exports. Check whether these operations: (1) have a timeout configured (AbortController with timeout, fetch with signal, server function with timeout headers); (2) display a loading indicator while waiting; (3) show a specific "This is taking longer than expected" or "Request timed out" message when the timeout elapses rather than a generic error or permanent spinner. Also check Vercel/serverless function timeout limits — if the project deploys to Vercel's Hobby tier (10s timeout) or Pro tier (60s/300s), are there operations that might exceed these limits?
Pass criteria: Count all long-running operations (over 3 seconds expected duration) in the project. Pass if long-running operations show a loading state AND handle timeout conditions with a specific, actionable message that distinguishes a timeout from other errors. At least 1 timeout-specific message must exist. A message like "This is taking longer than expected. You can wait or try again." is acceptable. Report the count: "X long-running operations found, Y handle timeout conditions."
Fail criteria: Fail if long-running operations have no timeout configured and could spin indefinitely. Fail if timeout errors produce the same generic error message as other failures. Should not pass when operations that hit serverless function limits (Vercel 10s/60s) are not acknowledged with a timeout-specific error.
Skip (N/A) when: The project has no operations expected to take more than 2 seconds (trivial CRUD apps with no AI, file processing, or data exports). Evaluate this by examining API routes and async functions for calls to external APIs, file I/O, or data aggregation that could be slow.
Detail on fail: "AI generation endpoint at api/generate/route.ts has no AbortController or timeout; shows generic error on Vercel 10s limit". Max 500 chars.
Remediation: A permanent spinner is one of the most frustrating user experiences. Timeout-specific messaging lets users know the system is responsive — it just needs more time, or they should try again later.
For fetch requests, use AbortController:
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 30000) // 30 second timeout
try {
const response = await fetch('/api/generate', { signal: controller.signal })
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
setError('This request timed out. Please try again.')
} else {
setError('Something went wrong. Please try again.')
}
} finally {
clearTimeout(timeoutId)
}
For long-running tasks, consider moving them to background jobs (see the background-job-retry check) and polling for completion instead of blocking.