Error Handling
Errors will happen in production. The difference between a professional app and an amateur one is what the user sees — and what the team can find out afterwards.
On this page · 7 min · Advanced
On this page
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 7 min
- Difficulty
- Advanced
- Updated
- Oct 29, 2026
Learning objectives#
- Turn on friendly error pages with customErrors
- Use HandleErrorAttribute globally and for specific exceptions
- Show a proper 404 page for unknown URLs
- Log every unhandled exception in Application_Error
Explanation#
<customErrors mode="On"> shows friendly pages to everyone; RemoteOnly shows details only on the server itself (handy in development); Off shows the yellow error screen with stack traces to anyone — never use Off in production.
HandleErrorAttribute (registered globally in FilterConfig) catches exceptions thrown by actions and views and renders Views/Shared/Error.cshtml with a HandleErrorInfo model, but only when custom errors are on. [HandleError(ExceptionType = typeof(TimeoutException), View = "Busy")] picks a different view for one exception type.
HandleErrorAttribute ignores 404s and errors outside MVC (unknown controllers, static files). Application_Error in Global.asax catches everything that escapes, so log there and render a friendly NotFound page for 404s.
Set Response.TrySkipIisCustomErrors = true when you write your own error response, otherwise IIS may replace it with its own page; httpErrors existingResponse="PassThrough" does the same in config.
Error pages must not show exception messages or stack traces — they reveal table names, file paths and versions to attackers. Log the details; show the user a reference and a way back.
Syntax#
<system.web>
<customErrors mode="On" /> <!-- On | RemoteOnly | Off -->
</system.web>
<system.webServer>
<httpErrors existingResponse="PassThrough" />
</system.webServer>
Code example: Friendly 500, 503-style and 404 pages#
<!-- Web.config -->
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<appSettings>
<add key="webpages:Version" value="3.0.0.0" />
<add key="webpages:Enabled" value="false" />
</appSettings>
<system.web>
<compilation debug="false" targetFramework="4.8" />
<httpRuntime targetFramework="4.8" />
<customErrors mode="On" />
</system.web>
<system.webServer>
<httpErrors existingResponse="PassThrough" />
</system.webServer>
</configuration>
// Global.asax.cs
using System;
using System.Diagnostics;
using System.Web;
using System.Web.Mvc;
using System.Web.Routing;
using ShopSite.Controllers;
namespace ShopSite
{
public class MvcApplication : HttpApplication
{
protected void Application_Start()
{
FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters); // adds HandleErrorAttribute
RouteConfig.RegisterRoutes(RouteTable.Routes);
}
// Last line of defence: everything MVC did not handle ends up here
protected void Application_Error()
{
var exception = Server.GetLastError();
var status = (exception as HttpException)?.GetHttpCode() ?? 500;
Trace.TraceError("Unhandled {0}: {1}", status, exception); // use your logger here
if (status != 404) return; // customErrors handles the rest
Server.ClearError();
Response.Clear();
Response.TrySkipIisCustomErrors = true;
var route = new RouteData();
route.Values["controller"] = "Error";
route.Values["action"] = "NotFound";
IController controller = new ErrorController();
controller.Execute(new RequestContext(new HttpContextWrapper(Context), route));
}
}
}
// Controllers/ErrorController.cs
using System.Web.Mvc;
namespace ShopSite.Controllers
{
public class ErrorController : Controller
{
public ActionResult NotFound()
{
Response.StatusCode = 404;
Response.TrySkipIisCustomErrors = true;
return View();
}
}
}
// Controllers/ShopController.cs
using System;
using System.Web.Mvc;
namespace ShopSite.Controllers
{
public class ShopController : Controller
{
public ActionResult Crash()
{
throw new InvalidOperationException("Connection to SQL-PROD-01 failed for table dbo.Payments");
}
[HandleError(ExceptionType = typeof(TimeoutException), View = "Busy")]
public ActionResult Slow()
{
throw new TimeoutException("Payment gateway timed out");
}
}
}
@* Views/Shared/Error.cshtml *@
@model HandleErrorInfo
@{ ViewBag.Title = "Error"; }
<h1>Sorry, something went wrong.</h1>
<p>We have been notified. (Failed in @Model.ControllerName.@Model.ActionName)</p>
@* Views/Shared/Busy.cshtml *@
@{ ViewBag.Title = "Busy"; }
<h1>We are very busy right now</h1>
<p>Please try again in a minute.</p>
@* Views/Error/NotFound.cshtml *@
@{ ViewBag.Title = "Not found"; }
<h1>Page not found</h1>
<p><a href="/">Back to the shop</a></p>
GET /Shop/Crash 500 Internal Server Error <h1>Sorry, something went wrong.</h1> <p>We have been notified. (Failed in Shop.Crash)</p> GET /Shop/Slow 500 Internal Server Error <h1>We are very busy right now</h1> <p>Please try again in a minute.</p> GET /Shop/Missing 404 Not Found <h1>Page not found</h1> <p><a href="/">Back to the shop</a></p> GET /nothing/here 404 Not Found <h1>Page not found</h1> <p><a href="/">Back to the shop</a></p>
This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.
Step-by-step explanation#
- Crash throws; the global HandleErrorAttribute catches it and, because customErrors is On, renders Error.cshtml with status 500. The message with the server and table names never reaches the browser.
- Slow throws a TimeoutException, so the more specific [HandleError] on the action renders the Busy view instead.
- Shop/Missing is an unknown action: MVC raises a 404 HttpException, HandleErrorAttribute ignores 404s, and Application_Error renders the NotFound page with a real 404 status.
- nothing/here has no controller at all, and takes the same Application_Error path.
- Every error is traced in Application_Error (or by HandleError for handled ones if you add logging there), so the team can investigate.
Real-world use case#
A travel site showed a yellow error page containing the database server name after a deployment. Switching customErrors to On, adding friendly pages and logging in Application_Error turned a security finding into a non-event — and the logs pinpointed the bad connection string in minutes.
Common mistakes#
Stack traces, file paths and SQL text become visible to anyone. Use On (or RemoteOnly while debugging on the server).
It only sees exceptions inside MVC actions and views, and ignores anything that maps to a non-500 status — HttpException 404s, and even UnauthorizedAccessException, which ASP.NET maps to 401. Add Application_Error for the rest.
Redirecting to /Error/NotFound with customErrors redirect returns 302 then 200, so search engines index the error page. Render it in place with status 404.
Best practices#
- customErrors On in production, RemoteOnly on test servers
- Log every unhandled exception with context
- Return correct status codes on error pages
- Keep error pages simple — no database calls that could fail again
Remember this#
customErrors On + HandleError = friendly 500.
Application_Error = the safety net (and 404s).
Users see an apology; logs see the details.
Practice exercise#
Your task: Create a PaymentDeclinedException, throw it from a Pay action and add a [HandleError] rule that shows a PaymentFailed view for it.
Need a hint?
Show solution
<!-- Web.config -->
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<appSettings>
<add key="webpages:Version" value="3.0.0.0" />
<add key="webpages:Enabled" value="false" />
</appSettings>
<system.web>
<compilation debug="false" targetFramework="4.8" />
<httpRuntime targetFramework="4.8" />
<customErrors mode="On" />
</system.web>
</configuration>
// Controllers/PaymentsController.cs
using System;
using System.Web.Mvc;
namespace ShopSite.Controllers
{
public class PaymentDeclinedException : Exception
{
public PaymentDeclinedException(string message) : base(message) { }
}
public class PaymentsController : Controller
{
[HandleError(ExceptionType = typeof(PaymentDeclinedException), View = "PaymentFailed")]
public ActionResult Pay(int orderId) => throw new PaymentDeclinedException("Card ending 4242 declined: insufficient funds");
}
}
@* Views/Shared/PaymentFailed.cshtml *@
<h1>Your payment did not go through</h1>
<p>No money was taken. Please try another card.</p>
GET /Payments/Pay?orderId=5001 500 Internal Server Error <h1>Your payment did not go through</h1> <p>No money was taken. Please try another card.</p>
Mini quiz#
Which customErrors mode shows friendly pages to everyone?
Want more? Practise all ASP.NET MVC MCQs · ASP.NET MVC interview questions
My notes#
Saved in this browser.