How We Cut ASP.NET Core Latency from 17s to Under 1s
A rogue IIS module was silently adding 9 seconds per request. Then a 3-second architectural bottleneck remained. Here's how the full investigation unfolded — and how response time went from 17s to under 1s.
* Two separate root causes discovered and eliminated over 11 days — 17s → 4s → under 1s, with no data loss and no OS reload.
ASP.NET Core API — Instant Locally, 13–17 Seconds Externally
The customer had deployed an ASP.NET Core application (<APP_DLL>.dll) on a Windows Server 2019 instance under IIS. The test endpoint /api/test — which contained nothing but return Ok() — responded instantly on localhost but took 13 to 17 seconds for every external request.
The customer had already verified the following before opening the ticket:
http://127.0.0.1— instant ✅dotnet <APP_DLL>.dllrunning correctly ✅- AspNetCoreModuleV2 installed ✅
- Application Pool set to ApplicationPoolIdentity ✅
- SQL query is simple ✅
/api/testis justreturn Ok()✅- IIS
time-takenlog: 13,000–17,000ms ❌
Crucially, the IIS logs showed the same 13–17 second time-taken on every request — 200 OK responses, 500 errors, and 404s alike — ruling out application logic as the cause.
ScriptsModule: A Hidden Global Module Blocking 9 Seconds Per Request
Engineer Hiker began a systematic elimination process. To isolate whether the delay was in the .NET application code or the IIS pipeline itself, a pure static file (test.txt) was placed on the server and accessed externally — bypassing all application logic entirely.
The static file still returned a 14-second TTFB. This definitively proved the bottleneck was in the IIS pipeline, not the application.
The next step was enabling IIS Failed Request Tracing (FREB) — IIS's built-in millisecond-level diagnostic tool. The FREB XML report revealed the culprit:
PRE_BEGIN_REQUEST, BEGIN_REQUEST, and SEND_RESPONSE. Each interception blocked for approximately 3 seconds, totalling nearly 9 seconds of artificial latency per request.The complication: Because the server ran SolidCP as its control panel, the global IIS configuration was locked. Attempting to use <clear /> to wipe all inherited modules triggered a 500.19 Lock Violation error — the panel had placed a server-level config lock that child sites could not override globally.
The surgical fix: Rather than touching the server-level config, Hiker applied a targeted removal directly inside the site's web.config:
<!-- web.config — targeted ScriptsModule removal -->
<system.webServer>
<modules>
<remove name="ScriptsModule" />
</modules>
</system.webServer>
This single line bypassed the rogue module for this specific site without touching the global server configuration or affecting other hosted sites. External TTFB dropped from 17 seconds to approximately 3–4 seconds.
Full IIS Optimization Audit — Every Known Lever Pulled
With 3–4 seconds of latency remaining, the customer wanted to continue optimizing within the existing Windows/IIS environment rather than migrating. Engineer Queta conducted a comprehensive IIS performance audit, applying every known optimization:
- Application Pool Start Mode → AlwaysRunning (eliminates cold-start delays)
- Application Pool Idle Timeout → 0 (prevents pool recycling under low traffic)
- Website Preload Enabled → True
- Removed unnecessary IIS modules:
UrlAuthorization,Profile,RoleManager - Enabled HTTP/2
- Added Windows Defender real-time scan exclusions for
w3wp.exe,dotnet.exe, and the app folder - Tested both InProcess and OutOfProcess hosting models
- Optimized SQL Server connection string from default
.to explicit127.0.0.1,<SQL_PORT>
The SQL connection fix recovered approximately 1 second. All other optimizations applied. Yet a ~3-second delay persisted. Three consecutive requests were tested — the delay was identical each time, ruling out JIT compilation or cold start as causes.
| Test Scenario | TTFB | Visual |
|---|---|---|
| Initial external access (all requests) | 13–17s | |
| After ScriptsModule removal | ~4.0s | |
| After full IIS optimization + SQL fix | ~3.2s | |
| Direct Kestrel on port 5000 (bypassing IIS) | ~0.84s | |
| Final: Kestrel Windows Service + IIS reverse proxy | 0.917s |
The IIS + ASP.NET Core Module Pipeline Adds ~3s Per Request — By Design
The direct Kestrel test was decisive: the same application, on the same server, accessed directly on port 5000 returned 0.84s TTFB. Through IIS: 3.2s. The gap was 2.4 seconds of pure architectural overhead.
Queta's technical conclusion, confirmed by IIS FREB tracing:
The IIS FREB log confirmed it precisely: the request entered the IIS pipeline at 0.476 ms; the next event — ASP.NET Core Module beginning to process the request — was logged at 3,150 ms. A gap of 3.15 seconds inside the IIS pipeline itself, before the application code even ran.
Two options were presented to the customer:
- Option 1 — Run the app as a Windows Service with Kestrel, use IIS only as a thin HTTPS reverse proxy (no ASP.NET Core Module in the request path)
- Option 2 — Migrate to a Linux VPS with Nginx + Kestrel (longer term, better scalability)
The customer chose Option 1 — keep the existing Windows Server, but restructure the hosting model.
ASP.NET Core as a Windows Service — IIS Becomes a Pure Reverse Proxy
Engineer Queta redesigned the hosting architecture. The key insight: IIS could still handle SSL termination and HTTPS, but the ASP.NET Core Module (which was causing the pipeline overhead) would be completely removed from the request path.
What was built:
- Created a Windows Service named
<SERVICE_NAME>— startsdotnet <APP_DLL>.dlldirectly, Kestrel listens onlocalhost:5000 - Renamed the original IIS site to
<SITE_NAME>.bak(preserving original config, zero data loss) - Created a new IIS site
<SITE_NAME>— HTTPS binding only, configured as a pure reverse proxy tohttp://localhost:5000 - All other IIS-hosted sites on the server: completely unaffected
- Existing SSL certificate: reused as-is on the new IIS site
# Register and start the Windows Service:
sc create <SERVICE_NAME> binPath= "dotnet C:\path\to\<APP_DLL>.dll"
sc config <SERVICE_NAME> start= auto
sc start <SERVICE_NAME>
# IIS reverse proxy web.config (no ANCM, pure proxy):
<system.webServer>
<rewrite>
<rules>
<rule name="ReverseProxy" stopProcessing="true">
<match url="(.*)" />
<action type="Rewrite"
url="http://localhost:5000/{R:1}" />
</rule>
</rules>
</rewrite>
</system.webServer>
# Performance verification:
# POST https://<SITE_NAME>/api/auth/login
# TCP Connection: 0.028s
# TLS Handshake: 0.104s
# Time to First Byte: 0.917s ← was 17s
# Total: 0.917s
The final performance test confirmed the result: 0.917 seconds total — including TCP connection, TLS handshake, and full API response. Login requests that previously took 13–17 seconds now completed in under one second.
Future deployments require only: publish new files → replace existing published files → restart <SERVICE_NAME>. IIS configuration never needs to change.
RESOLVED NO DATA LOSS NO OS RELOAD 17s → 0.9s
If IIS logs show large time-taken values on every request type (200, 404, 500) including static files, a global module is almost certainly the culprit — not your application code.
- Enable Failed Request Tracing (FREB) in IIS Manager → site → Failed Request Tracing Rules
- Set trace rule: Status codes
200-999, Time taken >1000ms - Reproduce a slow request once
- Open the generated XML at
C:\inetpub\logs\FailedReqLogFiles\W3SVC*\fr000001.xml - Look for any module appearing in
PRE_BEGIN_REQUESTorBEGIN_REQUESTwith large elapsed time values
💡 If the server uses a control panel (SolidCP, Plesk, cPanel for Windows), watch for injected modules like ScriptsModule, RewriteModule overrides, or panel-specific security hooks.
Control panels often lock global IIS configuration. Using <clear /> in web.config will trigger a 500.19 Lock Violation error. The safe approach:
<!-- web.config: target only the problematic module -->
<system.webServer>
<modules>
<remove name="ScriptsModule" />
<!-- Add other problem modules here -->
</modules>
</system.webServer>
This removes only the named module for your site, leaving global config untouched. Other sites are not affected.
If your ASP.NET Core app still has unexplained latency after all IIS optimizations, run this test directly on the Windows Server:
# Run the app directly (bypassing IIS entirely):
dotnet <APP_DLL>.dll --urls "http://localhost:5000"
# From another machine or using curl on the server:
curl -w "\nTTFB:%{time_starttransfer}s\n" http://<SERVER_IP>:5000/api/test
# Compare to IIS:
curl -w "\nTTFB:%{time_starttransfer}s\n" https://<SITE_NAME>/api/test
If the direct Kestrel call is >2s faster than the IIS path, the IIS ASP.NET Core Module pipeline is your bottleneck — not the application. The fix is to bypass ANCM entirely, as shown in this case study.
📊 In this case: Kestrel direct = 0.84s vs. IIS = 3.2s. A 2.4-second pure pipeline overhead with no configuration fix possible.
When you need HTTPS on Windows but want to bypass the ANCM pipeline overhead, use IIS purely as an SSL-terminating reverse proxy:
- Publish your app and install as a Windows Service (
sc createor use the built-inUseWindowsService()in .NET 6+) - Configure Kestrel to listen on
localhost:5000only (not externally exposed) - Create an IIS site with your HTTPS binding — but configure it to proxy to
http://localhost:5000using ARR/URL Rewrite, not as an ASP.NET Core app - The existing SSL certificate stays on IIS — no changes needed
- Other IIS sites on the same server are completely unaffected
🔧 Future deployments: copy new published files + sc stop/start <SERVICE_NAME>. IIS config never changes.
"From 17 seconds to under one —
two root causes, eleven days, zero data loss.
When the logs say 13 seconds and the code says 0.8,
the answer is always in the pipeline."
