You open your site and get a bare white page with two lines on it: Service Unavailable and HTTP Error 503. The service is unavailable. No stack trace, no IIS error page, nothing in your application logs. Restart the application pool and it works for a few minutes, then the 503 is back.

That page means your request never reached your application. IIS has stopped the application pool, and in most cases it did that on purpose, because the worker process kept crashing. This guide shows how to tell which kind of 503 you have, how to find the real crash behind it on a server you control and on shared Plesk hosting where you can't open Event Viewer, and what to fix.

This isn't theory for me. One of my own ASP.NET Core apps on a Plesk host, the Ludo game at ludo.knowledgemarkg.com, returned exactly this page for the whole of 11 November 2026. Nothing inside the app could report it, because the app wasn't running.

Short version: the plain "The service is unavailable" page comes from HTTP.sys, the kernel driver in front of IIS, and almost always means the application pool is stopped. IIS stops a pool by itself after 5 worker-process crashes within 5 minutes (Rapid-Fail Protection). Restarting it treats the symptom. Find the crash: the System event log (source WAS) on a VPS, or the ASP.NET Core stdout log on shared hosting.

First, which 503 are you looking at?

IIS can return a 503 from three different places, and each one points somewhere else. Look at the page and the response headers before you change anything.

What you see Who sent it What it usually means
Plain page: "Service Unavailable / HTTP Error 503. The service is unavailable." Header Server: Microsoft-HTTPAPI/2.0 HTTP.sys The application pool is stopped, disabled or its request queue is full. Your app never saw the request.
Your own maintenance page, with status 503 ASP.NET Core Module (ANCM) An app_offline.htm file is sitting in the site root, often left behind by a failed deploy.
IIS detailed error page "HTTP Error 503.0 - Server has been shutdown" ANCM The app was shutting down or recycling when the request arrived. Usually brief. If it persists, the app is being restarted in a loop.

If your site sits behind Cloudflare or another proxy, the Server header you see is the proxy's. To see the origin's own header, ask the origin directly. The IP below is a placeholder; use your server's:

curl -sI https://example.com/ --resolve example.com:443:203.0.113.10 | grep -iE "^(HTTP|server)" 

A file left in the folder is the easiest case, so check it first. If app_offline.htm is there, delete it and you're done. Why it gets left behind, and the deploy order that avoids it, is covered in my ASP.NET Core on Plesk 500 errors guide. The rest of this post is about the HTTP.sys page.

Why IIS stops the application pool: Rapid-Fail Protection

Every application pool has a setting called Rapid-Fail Protection. It's on by default with two limits: Maximum Failures = 5 and Failure Interval = 5 minutes. If the worker process (w3wp.exe) dies five times inside five minutes, the Windows Process Activation Service (WAS) stops starting new ones and disables the pool. From then on HTTP.sys answers every request itself with the 503 page.

That's why a restart only helps for a while. You start the pool, the next request starts a new worker process, the same crash happens, and five crashes later the pool is disabled again. On a busy site that takes seconds.

The pool can also be stopped for reasons that aren't crashes:

  • Its identity can't log on. If the pool runs as a custom account whose password has changed or expired, the pool can't start a worker process at all.
  • A CPU limit killed it. IIS can cap a pool's CPU. With the limit action set to KillW3wp, IIS kills the worker process and keeps the pool shut down for the rest of the limit interval. Shared hosting panels, Plesk included, expose this setting. A runaway loop then looks exactly like an outage.
  • Someone, or something, stopped it. A deploy script that ran Stop-WebAppPool and never got to the start line. A hosting panel action. A server reboot with the pool's Start Mode misconfigured.
  • The queue is full. The pool is running but every thread is busy, more than queueLength (default 1000) requests are waiting, and HTTP.sys turns away the rest. That's a performance problem, not a crash, and it shows up as intermittent 503s under load.

Finding the cause on a server you control

1. Ask HTTP.sys why it refused the request

HTTP.sys writes every request it rejects to its own log, separate from the IIS site logs: C:\Windows\System32\LogFiles\HTTPERR. The last column is the reason, and it tells you which of the cases above you have:

Get-ChildItem C:\Windows\System32\LogFiles\HTTPERR\httperr*.log | Sort-Object LastWriteTime | Select-Object -Last 1 | Get-Content -Tail 20 
  • AppOffline: application errors caused the pool to be taken offline. That's Rapid-Fail Protection. Go to step 2.
  • Disabled: an administrator, or a script or panel, took it offline.
  • QueueFull: the request queue was full. Look at thread starvation, not crashes.
  • AppPoolTimer: the worker process was too busy to take the request.
  • AppShutdown: the pool was shutting down, for example during a recycle.

2. Read the WAS events in the System log

Event Viewer → Windows Logs → System, filtered on source WAS. Or in PowerShell:

Get-WinEvent -FilterHashtable @{ LogName = 'System'; ProviderName = 'Microsoft-Windows-WAS' } -MaxEvents 30 | Format-List TimeCreated, Id, Message 

The IDs you'll see, in the order they usually appear:

Event ID Meaning
5009 A worker process for the pool terminated unexpectedly. The message includes the process exit code.
5011 A worker process suffered a fatal communication error with WAS. It died, usually from a crash.
5002 "Application pool 'name' is being automatically disabled due to a series of failures in the process(es) serving that application pool." This is Rapid-Fail Protection firing.
5021 / 5057 / 5059 The pool's identity is invalid, or WAS couldn't start a worker process at all. The pool is disabled.

5009 and 5011 tell you that it crashed. To find why, switch to the Application log and look at the same timestamps. A .NET crash leaves an .NET Runtime error (event 1026) with the exception and stack trace, and an Application Error (1000) naming the faulting module. That stack trace is the bug you actually need to fix.

3. Get the pool running again while you work

Import-Module WebAdministration Get-WebAppPoolState -Name 'MyAppPool' Start-WebAppPool -Name 'MyAppPool' # list every stopped pool on the box & "$env:windir\system32\inetsrv\appcmd.exe" list apppool /state:Stopped 

You can switch Rapid-Fail Protection off (Set-ItemProperty IIS:\AppPools\MyAppPool -Name failure.rapidFailProtection -Value $false) so the pool keeps restarting while you collect crashes. Treat that as a diagnostic step only. With it off, a crashing app keeps burning CPU on restarts and every visitor whose request is in flight when it dies gets an error anyway.

Finding the cause on shared Plesk hosting

On shared Windows hosting you don't get Event Viewer, and you don't get the HTTPERR folder. What you do have is the application's folder, its web.config, and the Plesk panel. That's enough.

Check the pool in Plesk first

In Plesk, open the domain and find Dedicated IIS Application Pool (on some plans the pool is shared and this page isn't there). It shows whether the pool is running, has Stop, Start and Recycle buttons, and holds the CPU-usage limit. If a limit is set with an action that kills the process, that alone can explain repeated outages under load. Starting the pool here is the shared-hosting equivalent of Start-WebAppPool, with the same caveat: if the crash is still there, it'll stop again.

Turn on the ASP.NET Core stdout log

The ASP.NET Core Module can capture everything your app writes to the console, including the unhandled exception that kills it. In the web.config that dotnet publish produced, set two attributes on the aspNetCore element:

<aspNetCore processPath="dotnet" arguments=".\MyApp.dll" stdoutLogEnabled="true" stdoutLogFile=".\logs\stdout" hostingModel="inprocess" /> 

Then create an empty logs folder next to the DLL. ANCM won't create it for you, and on shared hosting the pool identity often can't either. Recycle the pool, load the site, and read the newest stdout_*.log file through the Plesk File Manager. Switch it off again afterwards: the files are never rotated, and they fill the disk.

Catch the crash yourself

The stdout log only helps if the process gets far enough to write something. For crashes on background threads, or ones that happen after startup, add a last-chance handler at the very top of Program.cs. It writes to a file in the app folder, which you can read on any host:

AppDomain.CurrentDomain.UnhandledException += (_, e) => { try { var dir = Path.Combine(AppContext.BaseDirectory, "App_Data"); Directory.CreateDirectory(dir); File.AppendAllText(Path.Combine(dir, "crash.log"), $"{DateTime.UtcNow:O} {e.ExceptionObject}{Environment.NewLine}"); } catch { /* nothing left to do */ } }; 

It won't catch everything. A StackOverflowException kills the process before any handler runs, and so does a native crash. But it catches the common cases, and a stack overflow has its own tell: event 5009 with exit code 0xc00000fd on a VPS, or a pool that dies with an empty stdout log on shared hosting.

What actually crashes an ASP.NET Core worker process

Once you have the exception, it's usually one of these:

  • Unbounded recursion, often a property getter that calls itself, or two services that resolve each other. Stack overflow, exit code 0xc00000fd.
  • An exception escaping a background thread: an async void method, a Timer callback, a Task started with Task.Run and never awaited inside a try/catch. An unhandled exception on any thread ends the process.
  • A BackgroundService that throws. Since .NET 6 the default (BackgroundServiceExceptionBehavior.StopHost) stops the whole host when one of them fails. Wrap the loop body in try/catch and log it. I covered that pattern in the BackgroundService queue post.
  • Running out of memory on a host with a per-pool memory limit. A cache with no size limit is the usual culprit.
  • Environment.FailFast or Environment.Exit called by a library on an error it considers fatal.

A failure during startup (a missing appsettings.Production.json, a database that can't be reached by Database.Migrate(), an empty JWT signing key) normally shows up as HTTP Error 500.30 - ASP.NET Core app failed to start instead of a 503. If that's what you get, the fixes are in the 500 errors guide. If the database is slow rather than missing, see the SQL Server "wait operation timed out" post.

Intermittent 503s while the pool is running

If the pool shows as Started and the HTTPERR reason is QueueFull or AppPoolTimer, nothing is crashing. Requests are arriving faster than the app finishes them. On ASP.NET Core that is almost always thread-pool starvation: synchronous code blocking on async work (.Result, .Wait(), GetAwaiter().GetResult()) or slow synchronous I/O, so threads sit idle waiting while the queue grows. Raising queueLength only lets more requests wait. Make the hot path async, and check how long each request spends waiting on the database.

Make sure you hear about it before your visitors do

The lesson I took from the Ludo outage: when the pool is stopped, nothing inside the app can tell you. Health checks, logging and error alerts all run in the process that isn't running. You need a check from outside the server: any uptime monitor that requests a real page every few minutes and alerts on a non-200 status. Point it at a page that goes through the app, not a static file. A static file can be served by IIS even when your app is down.

And if you deploy by stopping the pool, put the start step somewhere it can't be skipped. A try/finally in the deploy script costs one line.

Checklist

  1. Is there an app_offline.htm in the site root? Delete it.
  2. Is it the plain HTTP.sys page (Server: Microsoft-HTTPAPI/2.0 at the origin)? Then the pool is stopped or its queue is full.
  3. VPS: read the last HTTPERR reason, then the WAS events (5009, 5011, 5002) and the Application log at the same time.
  4. Shared hosting: check the pool and its CPU limit in Plesk, turn on the stdout log, add an UnhandledException handler.
  5. Fix the crash, then start the pool. Don't just restart it, and don't leave Rapid-Fail Protection off.
  6. Add an external uptime check so the next one takes minutes to spot, not a day.

If your ASP.NET Core site keeps going down and you'd rather someone else found out why, this is the kind of work I do. See how to hire me.

FAQ

What does HTTP Error 503. The service is unavailable mean in IIS?

That plain page is sent by HTTP.sys, the kernel driver in front of IIS, and means the request never reached your application. The application pool is stopped or disabled, or its request queue is full. Most often IIS disabled the pool itself because the worker process kept crashing.

Why does my IIS application pool keep stopping automatically?

Rapid-Fail Protection. By default, if the worker process (w3wp.exe) crashes 5 times within 5 minutes, WAS disables the pool and logs event 5002. Restarting the pool only helps until the same crash happens five more times. Find the crash in the System log (WAS events 5009 and 5011) and the Application log.

Should I disable Rapid-Fail Protection to stop the 503?

Only briefly, while collecting crash details. With it off, a crashing app keeps restarting, uses CPU on every restart and still fails requests that are in flight when it dies. Fix the crash and turn it back on.

How do I find the cause of a 503 on shared Plesk hosting without Event Viewer?

Check the Dedicated IIS Application Pool page in Plesk (state and CPU limit), set stdoutLogEnabled="true" in web.config and create an empty logs folder next to the DLL, then read the newest stdout log. Adding an AppDomain UnhandledException handler that writes to a file catches crashes on background threads too.

What is event ID 5002 in IIS?

Event 5002, source WAS, says the application pool 'is being automatically disabled due to a series of failures in the process(es) serving that application pool'. It is Rapid-Fail Protection firing. The crashes before it, events 5009 and 5011, are the real problem.

Can app_offline.htm cause a 503 error?

Yes. While app_offline.htm is in the site root, the ASP.NET Core Module answers every request with status 503 and the file's contents. A deploy that fails half-way often leaves it behind. Delete the file and the app starts on the next request.

Why do I get intermittent 503 errors when the app pool is running?

If the pool is started and the HTTPERR log shows QueueFull or AppPoolTimer, requests arrive faster than the app finishes them. In ASP.NET Core that usually means thread-pool starvation from blocking calls such as .Result or .Wait(). Make the hot path async rather than raising queueLength.

Originally published on knowledgemarkg.com.