The Art of Writing Useful Error Messages
In 2023, we shipped an internal tool at Harbor Software that had a particularly unhelpful error message: “Operation failed.” No error code. No context. No suggestion of what to do next. Within the first week, our support channel had 47 messages that were all variations of “I clicked the button and it said ‘Operation failed.’ What do I do?” Every single one of those messages required a developer to check logs, identify the actual problem, and relay the fix back to the user. That’s 47 interruptions that could have been prevented by a better error message.
That experience became the catalyst for a company-wide effort to rethink how we write error messages — in our own products, in client applications, in APIs, and in CLI tools. This article documents what we learned: the principles, the anti-patterns, the specific techniques that turned our error messages from a source of support tickets into a source of user trust.
Why Error Messages Matter More Than You Think
Error messages are the only part of your application that users interact with when they’re already frustrated. Everything else — the onboarding flow, the clean UI, the fast performance — happens when things are going well. Error messages happen when things are going badly. The quality of your error messages determines whether users recover and continue, or give up and leave (or worse, file a support ticket).
There’s also a less obvious cost. Bad error messages create a feedback loop that degrades your entire support infrastructure:
- User encounters vague error
- User contacts support
- Support asks engineering to check logs
- Engineering finds the actual error, relays fix
- Support relays to user
- Total time: 2-4 hours. Total people involved: 3.
With a good error message, the loop is:
- User encounters specific error with actionable guidance
- User fixes the issue themselves
- Total time: 2 minutes. Total people involved: 1.
At scale, this difference is enormous. A SaaS product with 10,000 users might generate 200 error-related support tickets per month with vague messages, or 20 with good ones. That’s the difference between needing a support team and not needing one.
The Three Audiences for Error Messages
Not all error messages serve the same audience, and conflating them is one of the most common mistakes. There are three distinct audiences, and each needs different information:
1. End Users
End users don’t know (and shouldn’t need to know) what a 502 Bad Gateway is, what a null pointer exception means, or why a foreign key constraint was violated. They need to know: what happened, why it happened in terms they understand, and what they can do about it.
// Bad: Developer-facing error shown to end user
"Error: SQLITE_CONSTRAINT: UNIQUE constraint failed: users.email"
// Good: User-facing error with context and action
"This email address is already associated with an account.
You can sign in instead, or use a different email to create a new account."
2. API Consumers (Developers)
Developers integrating with your API need machine-readable error codes, field-level validation details, and documentation links. They don’t need friendly prose — they need precision.
// Bad: Friendly but useless for programmatic handling
{
"message": "Something went wrong with your request. Please try again."
}
// Good: Structured, machine-readable, developer-friendly
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Request body validation failed.",
"details": [
{
"field": "shipping_address.postal_code",
"code": "INVALID_FORMAT",
"message": "Postal code must be 5 digits or 5+4 format (e.g., 12345 or 12345-6789).",
"received": "ABC"
}
],
"request_id": "req_7f8a9b2c",
"docs": "https://api.example.com/errors/VALIDATION_FAILED"
}
}
3. Internal Developers (Log Consumers)
Your own engineering team needs full stack traces, request context, timing information, and correlation IDs. This information should be in logs, not in user-facing messages.
// What goes in the log (structured JSON for searching)
{
"level": "error",
"timestamp": "2026-03-30T14:23:17.442Z",
"request_id": "req_7f8a9b2c",
"user_id": "usr_123",
"error": "UNIQUE_CONSTRAINT_VIOLATION",
"table": "users",
"column": "email",
"value": "[REDACTED]",
"stack": "at UserRepository.create (user-repo.ts:47)n at UserService.register (user-service.ts:23)n ...",
"duration_ms": 12,
"query": "INSERT INTO users (email, name) VALUES ($1, $2)"
}
The critical insight: these three audiences require three different representations of the same underlying error. Your error handling system should produce all three from a single error definition.
The Anatomy of a Good Error Message
Every good error message, regardless of audience, answers three questions:
- What happened? A clear, specific description of the failure.
- Why did it happen? Enough context to understand the cause.
- What can I do about it? A concrete action the reader can take to resolve it.
Let’s apply this to real examples from projects we’ve built:
// ❌ Answers none of the three questions
"Error occurred."
// ❌ Answers "what" but not "why" or "what to do"
"Failed to upload image."
// ❌ Answers "what" and "why" but not "what to do"
"Failed to upload image: file exceeds maximum size of 5MB."
// ✅ Answers all three
"Your image couldn't be uploaded because it's 12MB,
which exceeds the 5MB limit. Try compressing the image
or using a smaller file. Supported formats: JPG, PNG, WebP."
Notice how the good version includes the specific size of the file the user tried to upload (12MB) and the exact limit (5MB). This might seem like a small detail, but it’s the difference between the user wondering “is my file too big by a lot or a little?” and immediately knowing they need to reduce it by more than half.
Error Message Patterns That Work
Pattern 1: The Specific Number
Whenever an error involves a limit, threshold, or constraint, include both the actual value and the allowed value.
// Bad
"Too many tags."
// Good
"You've added 25 tags, but the maximum is 20. Remove at least 5 tags to continue."
// Bad
"Request rate exceeded."
// Good
"You've made 152 requests in the last minute, exceeding the limit of 100.
Please wait 47 seconds before retrying. See our rate limiting docs: [link]"
Pattern 2: The Breadcrumb Trail
For complex operations (multi-step forms, API workflows, build processes), tell the user exactly where in the process the failure occurred.
// Bad
"Checkout failed."
// Good
"Your order couldn't be completed at the payment step.
Your card ending in 4242 was declined by the issuing bank.
Your cart and shipping details have been saved —
you can try a different payment method without re-entering them."
Notice how the good version also tells the user what was preserved. When an error occurs in a multi-step process, users are terrified they’ve lost their progress. Explicitly telling them what’s saved reduces anxiety and prevents abandonment.
Pattern 3: The Differential Diagnosis
When an error could have multiple causes, list the most likely ones in order of probability.
// Bad
"Unable to connect to database."
// Good (for a CLI tool)
"Unable to connect to PostgreSQL at localhost:5432.
Common causes:
1. PostgreSQL isn't running. Start it with: pg_ctl start
2. Wrong port. Check your DATABASE_URL environment variable.
3. Authentication failed. Verify your username and password in .env
4. Database 'myapp_dev' doesn't exist. Create it with: createdb myapp_dev
Connection string used: postgresql://user@localhost:5432/myapp_dev
Full error: connection refused (os error 111)"
This pattern is especially effective in CLI tools and developer-facing applications where the user is technical enough to work through a diagnostic list.
Pattern 4: The Safe Default
When the system takes a corrective action automatically, tell the user what happened and what was done about it.
// Bad: Silent correction, user doesn't know what happened
(nothing — the system silently truncated their input)
// Bad: Error with no correction
"Your bio exceeds 500 characters."
// Good: Transparent correction with user control
"Your bio was 847 characters, which exceeds the 500-character limit.
We've saved the first 500 characters. Click 'Edit' to adjust what was kept."
Building an Error Handling System
Good error messages don’t happen by accident. They require a structured system that makes it easy to write good errors and hard to write bad ones. Here’s the system we use across Harbor Software projects:
Step 1: Define Error Types Centrally
Every error in the system is defined in a central error catalog. Each error has a machine-readable code, templates for each audience, and metadata.
// errors/catalog.ts
export const ErrorCatalog = {
VALIDATION_EMAIL_TAKEN: {
code: 'VALIDATION_EMAIL_TAKEN',
httpStatus: 409,
// For end users (supports template variables)
userMessage: 'This email address is already associated with an account. You can sign in instead, or use a different email.',
// For API consumers
apiMessage: 'The provided email address is already registered.',
// For developers reading logs
internalMessage: 'Unique constraint violation on users.email: {{email}}',
// Action hints
suggestedAction: 'sign_in_or_use_different_email',
docsUrl: '/errors/VALIDATION_EMAIL_TAKEN',
// Severity for alerting
severity: 'info', // Not a system error, just a user error
},
FILE_TOO_LARGE: {
code: 'FILE_TOO_LARGE',
httpStatus: 413,
userMessage: 'Your file is {{actualSize}}, which exceeds the {{maxSize}} limit. Try compressing it or using a smaller file.',
apiMessage: 'File size {{actualSize}} exceeds maximum allowed size of {{maxSize}}.',
internalMessage: 'Upload rejected: {{actualSize}} > {{maxSize}} for user {{userId}}',
suggestedAction: 'compress_or_resize',
severity: 'info',
},
DATABASE_CONNECTION_FAILED: {
code: 'DATABASE_CONNECTION_FAILED',
httpStatus: 503,
userMessage: 'We're experiencing a temporary issue. Please try again in a few minutes. If this persists, contact support.',
apiMessage: 'Service temporarily unavailable. Retry after {{retryAfterSeconds}} seconds.',
internalMessage: 'Database connection failed: {{dbHost}}:{{dbPort}} - {{nativeError}}',
suggestedAction: 'retry',
severity: 'critical', // This triggers PagerDuty
},
} as const;
Step 2: Throw Typed Errors
Application code throws errors using the catalog, providing the template variables:
// services/user-service.ts
import { AppError } from '../errors/app-error';
async function register(email: string, name: string) {
const existing = await userRepo.findByEmail(email);
if (existing) {
throw new AppError('VALIDATION_EMAIL_TAKEN', { email });
}
// ... create user
}
Step 3: Render for the Audience
The error handling middleware renders the appropriate message for the context:
// middleware/error-handler.ts
function handleError(err: AppError, req: Request, res: Response) {
const catalog = ErrorCatalog[err.code];
// 1. Log the internal version (full context for debugging)
logger.log({
level: catalog.severity === 'critical' ? 'error' : 'warn',
message: renderTemplate(catalog.internalMessage, err.context),
code: err.code,
requestId: req.id,
userId: req.user?.id,
stack: err.stack,
...err.context,
});
// 2. Alert if critical
if (catalog.severity === 'critical') {
alerting.trigger(err.code, err.context);
}
// 3. Respond with the appropriate audience message
if (req.isApiRequest) {
// API consumer gets structured error
res.status(catalog.httpStatus).json({
error: {
code: err.code,
message: renderTemplate(catalog.apiMessage, err.context),
...(err.fieldErrors && { details: err.fieldErrors }),
requestId: req.id,
docs: catalog.docsUrl,
},
});
} else {
// End user gets friendly message
res.status(catalog.httpStatus).render('error', {
message: renderTemplate(catalog.userMessage, err.context),
suggestedAction: catalog.suggestedAction,
});
}
}
This system ensures that every error, everywhere in the application, produces appropriate messages for all three audiences from a single throw statement. No developer has to think about formatting error responses — they just throw an AppError with a catalog code and the relevant context variables.
Error Messages in CLI Tools
CLI error messages follow different rules than web UI error messages because the user is a developer and the medium is a terminal. Here are the patterns we’ve refined across our CLI projects:
# Bad CLI error
Error: Something went wrong
# Good CLI error
✗ Failed to deploy: build artifact not found
Expected: ./dist/index.js
Looked in: /home/user/project/dist/
This usually means the build step was skipped or failed.
Run 'harbor build' first, then try deploying again.
If the problem persists:
harbor deploy --verbose # Show detailed build output
harbor doctor # Check project configuration
Key differences from web error messages:
- Include the exact file paths and values involved. CLI users can act on this information directly.
- Suggest the exact command to run next. Don’t say “check your configuration” — say
harbor doctor. - Offer a verbose/debug flag for users who need more detail.
- Use visual formatting (indentation, blank lines, symbols) to make the error scannable. A developer who’s seen 50 errors today needs to grok this one in 3 seconds.
Error Messages in Forms
Form validation errors are the most common error messages users encounter, and they’re often the worst. The standard anti-patterns:
- Showing all errors at the top of the form, far from the fields they relate to
- Using technical jargon (“Invalid input”, “Malformed value”)
- Not preserving the user’s input after an error (so they have to re-enter everything)
- Using red text without any other indicator (inaccessible to colorblind users)
The patterns that work:
<!-- Bad: Generic, no guidance -->
<span class="error">Invalid phone number</span>
<!-- Good: Specific format, example, preserved input -->
<span class="error" role="alert">
Phone number should include area code. Example: (555) 123-4567.
You entered: 1234567
</span>
The role="alert" attribute ensures screen readers announce the error immediately when it appears, which is a critical accessibility requirement that most form implementations miss.
The Anti-Patterns Hall of Shame
We maintain an internal “hall of shame” for error message anti-patterns we’ve encountered (and, honestly, sometimes committed ourselves). Here are the greatest hits:
The Useless Generic
"An error occurred. Please try again later."
// What error? Try WHAT again? How much later?
The Stack Trace Dump
"NullPointerException at com.harbor.service.UserService.findById
(UserService.java:147) at com.harbor.controller.UserController..."
// Nobody except a Java developer can use this. And even they
// need the full stack trace, which this truncates.
The Blame Shifter
"You entered an invalid value."
// Invalid HOW? What would be valid? The user isn't the problem.
The False Promise
"This should never happen. Please contact support."
// If it should never happen, why did you write a message for it?
// And "contact support" with zero context means the support
// team can't help either.
The Silent Treatment
// The worst error message is no error message.
// The form just... doesn't submit. No indication of why.
// The API returns 200 OK with an empty body.
// The CLI exits with code 0 after doing nothing.
The Security Leak
"Login failed: no user found with email admin@company.com"
// You just confirmed that email isn't in the system.
// An attacker can now enumerate valid emails.
// Use: "Incorrect email or password" — deliberately ambiguous.
This last one is important enough to discuss separately.
Security Considerations in Error Messages
There’s a tension between helpful error messages and security. The same specificity that helps users also helps attackers. Here are the rules we follow:
Authentication errors must be deliberately vague. “Incorrect email or password” — never “incorrect password” (confirms the email exists) or “no account with this email” (confirms the email doesn’t exist). This applies to registration too: “If this email is registered, we’ve sent a reset link” rather than “this email isn’t registered.”
Authorization errors should confirm the resource exists only if the user has partial access. If a user requests a resource they can’t access, return 403 (Forbidden) only if they’re authenticated and the resource is in a context they can see. Otherwise, return 404 (Not Found) to avoid confirming the resource’s existence.
// Authorization middleware
if (!user.isAuthenticated) {
// Don't reveal whether the resource exists
throw new AppError('NOT_FOUND', { resource: 'project' });
}
if (!user.hasAccess(project)) {
// User is authenticated and in the same org — safe to say "forbidden"
if (user.orgId === project.orgId) {
throw new AppError('FORBIDDEN', { resource: 'project', requiredRole: 'viewer' });
}
// User is in a different org — don't reveal the project exists
throw new AppError('NOT_FOUND', { resource: 'project' });
}
Internal errors should never leak implementation details to external users. Database error codes, file paths, server hostnames, library versions — none of this should appear in user-facing or API error responses. It goes in logs only.
Measuring Error Message Quality
How do you know if your error messages are actually good? We track three metrics:
- Error-to-support-ticket ratio. For each error code, what percentage of occurrences result in a support ticket within 24 hours? A high ratio means the message isn’t helping users self-serve.
- Recovery rate. After encountering an error, what percentage of users successfully complete the action on their next attempt? Low recovery rate means the message doesn’t provide actionable guidance.
- Time to resolution. How long between the error occurring and the user completing their intended action? This captures both the cases where users self-serve (short) and the cases where they need support (long).
// We track error encounters and subsequent actions
analytics.track('error_encountered', {
errorCode: 'FILE_TOO_LARGE',
userId: user.id,
context: { actualSize: '12MB', maxSize: '5MB' },
timestamp: new Date(),
});
// Then correlate with successful completions
analytics.track('upload_completed', {
userId: user.id,
fileSize: '4.2MB',
timestamp: new Date(), // 3 minutes after the error
previousError: 'FILE_TOO_LARGE', // Links to the error event
});
When we first started tracking these metrics on a client project, we found that 60% of “file too large” errors resulted in the user leaving the page entirely — they didn’t know how to compress an image. We added a link to a free image compression tool in the error message, and the recovery rate jumped from 40% to 87%.
Implementing This in Practice
If you want to improve your application’s error messages, here’s a prioritized approach:
- Audit your top 10 errors by frequency. Check your error logs for the most common errors, then look at the messages users see for each one. Fix the worst offenders first.
- Add a request ID to every error response. This single change makes every support interaction faster, even if the messages themselves are still imperfect.
- Create an error catalog. Even a simple spreadsheet mapping error codes to user messages is better than ad-hoc error string construction scattered across the codebase.
- Review error messages like you review code. Add “error message quality” to your code review checklist. It takes 30 seconds to check whether a new error message answers the three questions (what, why, what to do).
- Track error-to-support-ticket ratio. Once you can measure it, you can improve it systematically.
Error messages are the interface between your system’s failures and your users’ patience. Every vague “something went wrong” is a user who might not come back. Every specific, actionable error message is a user who trusts you more because you helped them through a problem. The investment in getting this right pays returns on every single error your application throws.
We’ve built error handling systems for applications serving millions of users. If you’re looking to improve your application’s error experience, or if you want us to audit your current error messages and prioritize improvements, let’s talk.