Archive
The Static HttpClient That Wouldn’t Rotate: A Tale of Pooled Connections
The symptom
A production .NET service had been running fine for months. It made outbound HTTP calls through a rotating proxy provider — the kind that promises a new exit IP for each request. Then one day, requests started timing out. Not failing cleanly with a 4xx or 5xx. Timing out. Each one sat there for thirty seconds or more before giving up.
The fix that worked, every time, was restarting IIS.
That’s an interesting fix. It tells you the problem isn’t with the code’s logic — the code is the same after the restart as before. It tells you the problem is with state that the application is accumulating over its lifetime. And it tells you that whatever that state is, it lives somewhere inside the application’s process, not on disk or in a database.
The setup
Here’s the shape of the code:
csharp
private static readonly HttpClient _httpClient = new HttpClient(new HttpClientHandler{ Proxy = _rotatingProxy, UseProxy = true});
A static HttpClient. A rotating proxy. By every piece of Microsoft guidance written in the last decade, this is correct. “Don’t create a new HttpClient per request — it’ll exhaust your sockets.” So a static instance it is.
The intent: every outbound request goes through the proxy, the proxy assigns a different exit IP each time, and the upstream service sees a varied stream of source addresses rather than a single hammering client.
The reality, as we’ll see, was very different.
Anti-pattern #1: assuming HttpClient opens a new connection per request
This is the foundational misunderstanding. HttpClient does not open a new TCP connection for each request. It maintains a connection pool, keyed by destination host, and reuses pooled connections wherever possible. This is HTTP keep-alive doing its job — and it’s a good thing for the general case, because TCP and TLS handshakes are expensive.
But here’s the consequence for a rotating proxy: rotation happens per TCP connection, not per HTTP request. If your HttpClient opens one TCP connection on the first request and then reuses that connection for every subsequent request, you get one exit IP, forever. Your “rotating” proxy is effectively a sticky proxy, because nothing is ever telling it you want a new connection.
The static HttpClient makes this worse, because the connection pool lives for the lifetime of the process. There is no natural moment at which the pooled connection gets replaced. It just sits there, being reused, until something forces it to close.
Anti-pattern #2: no timeout configured
HttpClient.Timeout defaults to 100 seconds. Most developers never set it. For most APIs this is fine — if the call is going to succeed, it’ll succeed in well under 100 seconds.
But “100 seconds” becomes a serious problem when something on the network silently fails. A pooled TCP connection that’s been killed by an intermediate firewall, a load balancer that’s stopped routing your traffic, a remote server that’s decided to tarpit you — none of these produce a clean error. They produce a hang. And your code will sit in that hang for the full timeout duration.
The combination of “no timeout” + “pooled connection that’s gone bad” is the textbook recipe for the mysterious thirty-second-plus pause that I described at the top. The connection looks fine to your code. The pool hands it out. The request goes out. Nothing comes back. You wait.
Anti-pattern #3: silent bans look identical to network glitches
Modern anti-abuse systems rarely respond to suspected scraping or hammering with a clean 403 Forbidden. A 403 is a gift to the attacker — it tells them immediately that they need to rotate. Far more effective is to do nothing: accept the TCP connection, accept the request, and then never send a response. The attacker’s client hangs. Their thread is consumed. Their queue backs up. Their logs fill with timeouts that look indistinguishable from random network failures.
If you’re not expecting this behaviour, you’ll spend a lot of time chasing phantom network problems, blaming your cloud provider, blaming your proxy, blaming DNS. The truth — that you’ve been quietly added to a deny list — is invisible from the symptoms alone.
When you combine this with the pooled-connection problem, you get a particularly nasty failure mode: you get one exit IP from your proxy, you hammer the upstream API from that single IP, the upstream API decides you’re abusive and starts tarpitting that IP, and now every single request hangs because they’re all going through the same pooled connection to the same banned IP. The only way out is to break the connection — which an IIS restart conveniently does as a side effect.
What’s actually going on
Putting these three pieces together, the mechanism is:
- The application starts and opens its first outbound connection through the proxy. The proxy assigns exit IP
X. - .NET pools that connection and reuses it for every subsequent request.
- Every request to the upstream service comes from IP
X. - Upstream eventually notices the volume from IP
Xand starts silently dropping requests. - Now every request hangs for the default 100-second timeout, or however long the underlying TCP layer takes to give up.
- Restarting the application destroys the connection pool, forces a new TCP connection on the next request, and the proxy assigns a new exit IP. Service is restored — until the cycle repeats.
The root cause is not the proxy. The proxy is doing what it was told. The root cause is that the application is never asking it for a new connection.
The pattern: disable keep-alive when you actually want rotation
The fix is one line:
csharp
client.DefaultRequestHeaders.ConnectionClose = true;
This sets the Connection: close header on every outgoing request. It tells .NET’s connection pool not to reuse the underlying TCP socket after the response. The next request opens a fresh connection. The proxy assigns a fresh exit IP. Rotation now works the way you expected it to work all along.
The complete pattern looks like this:
csharp
private static readonly HttpClient _httpClient = BuildHttpClient();private static HttpClient BuildHttpClient(){ var handler = new HttpClientHandler { Proxy = _rotatingProxy, UseProxy = true }; var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(15) }; client.DefaultRequestHeaders.ConnectionClose = true; return client;}
Three things to notice:
The HttpClient is still static. We haven’t abandoned the standard guidance. The object itself is long-lived; it’s just configured not to pool connections internally.
The timeout is now sane. Fifteen seconds is appropriate for most API calls. If something goes wrong — a tarpit, a network glitch, anything — we fail fast and try again, rather than blocking a thread for a minute and a half.
There’s no manual connection management. No tracking of connection age, no recycling logic, no locks around pool rebuilds. The framework handles it because we’ve told it to handle it the right way.
The cost, and when not to do this
Disabling keep-alive isn’t free. Every request now pays the full cost of a TCP handshake and a TLS negotiation. Through a proxy, that’s typically 400–800ms of overhead per request, on top of whatever the upstream service itself takes.
If you’re making thousands of requests per second to the same endpoint and you want a stable identity from the upstream service’s perspective, do not do this. Keep-alive exists for excellent reasons. The pattern in this post is specifically for the case where you have a rotating proxy and you actively want to defeat connection reuse so that rotation happens.
For high-volume scenarios where you want both rotation and performance, the right answer is usually session tokens — most rotating proxy providers let you encode a session identifier in the proxy username, and you can vary that token per request to force rotation without giving up keep-alive on individual sessions. That’s a more involved pattern and a topic for another post.
The broader lesson
The interesting thing about this bug is how each of the three anti-patterns is, in isolation, considered best practice or at least defensible. Static HttpClient is recommended. Default timeouts are what you get if you don’t think about them. And nobody designs their code around the possibility of being silently banned by an upstream service.
It’s the interaction between these three things, plus the presence of a rotating proxy, that produces the failure. And the failure mode — a hang, not an error — is the worst possible signal, because it tells you nothing useful and points you in completely the wrong direction.
The takeaway isn’t “always disable keep-alive” or “always set short timeouts.” The takeaway is that when your code interacts with infrastructure that has its own behaviours — proxies, load balancers, anti-abuse layers — the default settings of your HTTP client may not match the assumptions you’re making about how that infrastructure works. Take a moment to ask: does my code’s connection lifecycle match what I actually want to happen at the network level? If you have a rotating proxy and you’ve never thought about connection pooling, the answer is almost certainly no.
Indonesia Vehicle Registration Lookups #API

We’re delighted to announce that our vehicle registration lookup service now supports Indonesia. With over 150 million registered vehicles on its roads, Indonesia is one of the largest automotive markets in Southeast Asia, and adding it to our coverage has been a long-running priority. As of today, you can query Indonesian plates through the /CheckIndonesia endpoint.
What you get back
Every successful lookup returns the core vehicle attributes you’d expect: make, model, year of registration, engine displacement, body type, colour, and fuel type. Where the underlying regional registry exposes them, we also surface the VIN (chassis number), the STNK (registration certificate) expiry date, the next tax due date, and the outstanding tax amount in Indonesian rupiah.
Here’s a representative response for plate PA1554RE:
json
{ "Description": "SUZUKI ERTIGA", "RegistrationYear": "2017", "CarMake": "SUZUKI", "CarModel": "ERTIGA", "BodyStyle": null, "EngineSize": "1373", "MakeDescription": "SUZUKI", "ModelDescription": "ERTIGA", "VIN": "MHYKZE81SHJ3XXXXX", "VehicleType": "MINIBUS", "Colour": "HITAM METALIK", "FuelType": "BENSIN", "OwnershipNumber": null, "StnkExpiry": "2027-10-30", "TaxExpiry": "2026-10-30", "TotalDue": 2319000, "Region": null, "Location": "SULAWESI UTARA", "ImageUrl": "http://nopol.id/image.aspx/@U1VaVUtJIEVSVElHQQ==", "Extended": null}
A note on Indonesia’s plate system
Unlike countries with a single unified vehicle registry, Indonesia operates a federated model: each province (and sometimes individual regencies within it) runs its own SAMSAT system for collecting road tax and issuing registration documents. The first one or two letters of any Indonesian plate identify the issuing region — B for Jakarta, D for Bandung, PA for Papua, and so on — and behind each prefix sits a different government portal with its own quirks, authentication scheme, and data model.
This is why rolling out Indonesia coverage has been a region-by-region effort rather than a single integration. Every prefix we support represents a separately reverse-engineered pipeline, and the depth of data we can return varies according to what each provincial system exposes.
Current regional coverage
At launch we support nine regions, covering a substantial portion of Indonesia’s vehicle population:
| Prefix | Region | Notes |
|---|---|---|
B | DKI Jakarta | The capital region — by far the densest vehicle population in the country. Full owner-redacted tax breakdown including PKB principal, SWDKLLJ contribution, and outstanding totals. |
A | Banten | The province west of Jakarta. Full tax data with arrears and penalty breakdowns where applicable. |
D, E, F, T, Z | Jawa Barat (West Java) | Five prefixes covering Bandung, Cirebon, Bogor, Purwakarta, and Sukabumi residencies respectively. Full PKB and opsen breakdown plus next-year tax estimate. |
BG | Sumatera Selatan | South Sumatra, centred on Palembang. Full tax data including opsen and admin fees. |
BH | Jambi | Central Sumatra. Returns owner-redacted details plus payment history and expiry dates. |
DB / DL | Sulawesi Utara | North Sulawesi, centred on Manado. Full tax breakdown with opsen split. |
DN | Sulawesi Tengah | Central Sulawesi. Vehicle identification data; tax amounts not exposed by the provincial system. |
KT | Kalimantan Timur | East Kalimantan, including the new Nusantara capital region. Vehicle identification data; tax amounts are flagged for in-person verification by the provincial SAMSAT. |
PA | Papua | The easternmost province. Full tax data including arrears tracking for overdue registrations. |
Reading the response fields
A few of the returned fields warrant explanation for users unfamiliar with the Indonesian system:
FuelType— values are returned in Bahasa Indonesia.BENSINis petrol/gasoline;SOLARis diesel;LISTRIKis electric.VehicleType— categories use the official SAMSAT classifications:MINIBUS,SEDAN,JEEP S.C.HDTP,SPD. MOTOR R2(two-wheeled motorcycle), and so on.Colour— returned in Bahasa Indonesia. Common values:HITAM(black),PUTIH(white),MERAH(red),ABU-ABU(grey), often suffixed withMETALIKfor metallic finishes.StnkExpiry— the five-yearly registration certificate renewal date.TaxExpiry— the annual PKB road tax due date. This is the more frequently relevant of the two.TotalDue— the amount payable at the next renewal in rupiah, including PKB, the SWDKLLJ mandatory accident insurance contribution, opsen (provincial surcharge), and any arrears or penalties where applicable.
What’s next
We’re actively working on additional Indonesian regions, with Jawa Tengah (Central Java, prefixes G/H/K/R/AA/AD), Jawa Timur (East Java, L/M/N/P/S/W/AE/AG), Bali (DK) and Sumatera Utara (BB/BK) all on the near-term roadmap. If your use case depends on a specific Indonesian region that isn’t yet listed, get in touch — that kind of feedback is what drives our prioritisation.
The full endpoint documentation is at https://www.nopol.id/api/reg.asmx?op=CheckIndonesia.
The Misleading IndexOutOfRangeException That Means “Your List Isn’t Thread-Safe”
If you’ve ever seen a stack trace like this in a .NET application:
System.IndexOutOfRangeException: Index was outside the bounds of the array. at System.Collections.Generic.List`1.Enumerator.MoveNext() at System.Linq.Enumerable.WhereListIterator`1.MoveNext() at System.Collections.Generic.List`1..ctor(IEnumerable`1 collection) at System.Linq.Enumerable.ToList[TSource](IEnumerable`1 source) at YourCode.SomeMethod(...)
…and you stared at the offending line — something innocuous like myList.Where(x => x.IsActive).ToList() — wondering how on earth a LINQ query could be indexing outside an array, you’ve run into one of the most misleadingly-named exceptions in the framework.
The message says “index out of bounds.” The actual problem is a race condition.
What the stack trace is telling you
Read the frames from the bottom up. Your code called ToList(). ToList() calls the List<T> constructor that takes an IEnumerable<T>. That constructor enumerates the Where iterator, which in turn enumerates the underlying List<T> via its Enumerator. And it’s Enumerator.MoveNext() that throws.
So the iterator is walking your list, and at some point it tries to read element N, and N is past the end of the internal array.
How can that happen? The list got smaller — or its internal buffer got swapped — while the enumerator was mid-walk.
List<T> is implemented as a wrapper around a T[] array plus a _size field. The enumerator captures a reference to the list at construction time, then on each MoveNext() it increments an index and reads list._items[index]. If another thread calls Remove, Clear, or triggers a resize via Add between two MoveNext() calls, the array your enumerator is reading from may have been replaced with a smaller one, or the items may have been shuffled. The result: an index that was valid when you started iterating is no longer valid, and you get IndexOutOfRangeException.
Why you don’t get the “nice” exception
List<T> does have a version-check mechanism. Every mutation increments an internal _version field, and the enumerator records the version it started with. If MoveNext() notices the version has changed, it throws InvalidOperationException: Collection was modified; enumeration operation may not execute — the exception most .NET developers have seen at least once and immediately recognise as a concurrency or mid-loop-mutation bug.
The catch: that version check happens after the index increment and array access. If the racing thread mutates the list in a way that shrinks the array between those two operations, you hit the raw IndexOutOfRangeException before the version check ever runs. Same root cause, much worse error message.
This is also why the bug is so frustrating to reproduce. It depends on precise interleaving of two threads down to the instruction level. You can hammer it in a test loop and not see it for a million iterations, then it triggers twice in five minutes in production.
The minimal reproduction
var list = new List<int>();for (int i = 0; i < 1000; i++) list.Add(i);// Thread A — repeatedly enumerateTask.Run(() =>{ while (true) { var copy = list.Where(x => x > 0).ToList(); }});// Thread B — repeatedly mutateTask.Run(() =>{ var rng = new Random(); while (true) { if (list.Count > 0) list.RemoveAt(rng.Next(list.Count)); list.Add(rng.Next()); }});
Run that for a few seconds and you’ll get either InvalidOperationException or IndexOutOfRangeException — sometimes both, on different runs. The exception you get is essentially a coin flip determined by exactly when the second thread’s mutation lands relative to the first thread’s bounds check.
The diagnostic giveaway
The single most useful signal in the stack trace is this frame:
at System.Collections.Generic.List`1.Enumerator.MoveNext()
IndexOutOfRangeException originating from List<T>.Enumerator.MoveNext almost always means concurrent modification. The enumerator’s own code is straightforward — there’s no realistic way for it to compute a bad index on its own. Something outside the enumerator changed the list while it was looking away.
If your stack trace shows that frame, stop looking for off-by-one errors in your LINQ predicate. Start looking for which other thread is writing to the same list.
Fixing it
The fix depends on the access pattern. In rough order of preference:
Use the right collection type. System.Collections.Concurrent has thread-safe equivalents tuned for different shapes of access:
ConcurrentBag<T>— many writers, occasional bulk drain. No keyed lookup or removal by item.ConcurrentQueue<T>/ConcurrentStack<T>— FIFO / LIFO producer-consumer pipelines.ConcurrentDictionary<TKey, TValue>— by far the most generally useful. Supports thread-safe add, remove, lookup, and snapshot enumeration via.Keysand.Values. If you’re keying items by an ID, this is almost always what you want.
Crucially: .Values on a ConcurrentDictionary returns a snapshot, so iterating it is safe even if other threads are mutating the dictionary at the same time. No exceptions, no locks.
Lock around access. If you’re stuck with List<T> — maybe the API surface is fixed, or the contention is low enough that the overhead doesn’t matter — wrap every read and every write in a lock on a dedicated private object. Every read and every write. Missing one is enough to bring the bug back.
private static readonly object _lock = new object();private static readonly List<Thing> _items = new List<Thing>();public static List<Thing> GetActiveThings(){ lock (_lock) { return _items.Where(t => t.IsActive).ToList(); }}public static void AddThing(Thing t){ lock (_lock) { _items.Add(t); }}
The ToList() inside the lock is deliberate — it forces the enumeration to complete before the lock is released, so the returned list is a safe, isolated copy the caller can work with at leisure.
Snapshot, then iterate. A halfway measure for read-mostly workloads:
var snapshot = Volatile.Read(ref _items).ToArray();
…paired with copy-on-write semantics for the writers. This is the pattern behind ImmutableList<T> from System.Collections.Immutable, which is worth knowing about for scenarios with many readers and rare writers.
What I’d take away from this
Two things.
First: when you see IndexOutOfRangeException coming out of a LINQ chain on a List<T>, your first hypothesis should be “another thread is writing to this list,” not “my predicate has a bug.” The stack trace looks like a logic error and it almost never is.
Second: List<T> being non-thread-safe is one of those facts every .NET developer knows in the abstract and still trips over in practice, because the framework gives you no help at all until something explodes. There’s no ThrowIfShared mode, no Roslyn analyzer that flags static List<T> fields, no runtime check at write time. The only feedback you get is a confusing exception from deep inside an enumerator, possibly weeks after deployment.
The fix is almost always “use the right type from System.Collections.Concurrent.” It costs you nothing in code clarity and saves you from a class of bug that’s genuinely painful to track down once it’s loose in production.
Thread Pool Exhaustion in ASP.NET: The Async Database Trap
If you’ve ever migrated a working ASP.NET application from synchronous database calls to async, and suddenly found yourself hitting connection pool timeouts under load, you’ve likely fallen into one of the most subtle and destructive traps in the .NET ecosystem: sync-over-async deadlock.
The Symptom
Everything works fine in development. You push to production, traffic picks up, and then:
Timeout expired. The timeout period elapsed prior to obtaining a connection from the pool. This may have occurred because all pooled connections were in use and max pool size was reached.
Your database isn’t overloaded. Your queries are fast. But connections are being swallowed and never returned.
What Actually Happens
To understand the deadlock, you first need to understand two things: the ASP.NET synchronization context, and what blocking on an async method actually does.
The Synchronization Context
In classic ASP.NET (WebForms and MVC on the traditional pipeline), each request runs with a synchronization context that ensures continuations — the code that runs after an await — resume on the same thread that started the request. This is a design choice that simplifies state management, but it has a fatal implication when you block.
The Deadlock Sequence
Consider this code:
csharp
// Somewhere in a sync method:var result = GetDataAsync().Result; // ← the problem
Here’s what happens step by step:
- Thread A handles the request and calls
GetDataAsync().Result - Thread A is now blocked — it’s sleeping, waiting for the Task to complete
GetDataAsync()runs its SQL query asynchronously and completes- The async machinery looks for a thread to resume on — but the synchronization context says it must resume on Thread A
- Thread A is blocked waiting for the task. The task is waiting for Thread A. Neither can proceed.
This is a classic deadlock. The thread never releases, the SQL connection it holds is never returned to the pool, and every subsequent request that hits the same code path adds another frozen thread and another stranded connection.
Why It Only Surfaces Under Load
With light traffic, the thread pool has spare threads. The continuation sneaks onto a different free thread and completes before the pool runs dry. As concurrency increases, all available threads become blocked, no free thread exists to run any continuation, and the whole system seizes.
This is why the bug can pass development and staging entirely undetected.
The Broken Pattern
csharp
public DataTable GetUserData(string userId){ // Blocking on an async method — dangerous in ASP.NET return GetUserDataAsync(userId).Result;}public async Task<DataTable> GetUserDataAsync(string userId){ using var conn = new SqlConnection(connectionString); using var cmd = new SqlCommand("sp_GetUser @1", conn); cmd.Parameters.AddWithValue("@1", userId); await conn.OpenAsync(); using var reader = await cmd.ExecuteReaderAsync(); var dt = new DataTable(); dt.Load(reader); return dt;}
The async method itself is fine. The problem is the caller blocking on it with .Result.
The Fix: Async All the Way Down
The only correct solution is to await the entire call chain without any blocking calls. There must be no .Result, .Wait(), or .GetAwaiter().GetResult() anywhere in the path from the entry point to the database.
csharp
// ✅ Correct: full async chainpublic async Task<DataTable> GetUserDataAsync(string userId){ using var conn = new SqlConnection(connectionString); using var cmd = new SqlCommand("sp_GetUser @1", conn); cmd.Parameters.AddWithValue("@1", userId); await conn.OpenAsync(); using var reader = await cmd.ExecuteReaderAsync(); var dt = new DataTable(); dt.Load(reader); return dt;}
And the caller:
csharp
var data = await GetUserDataAsync(userId); // ✅ not .Result
The WebForms Special Case
WebForms Page_Load is synchronous by signature, which tempts developers to block. The correct bridge is RegisterAsyncTask:
csharp
protected void Page_Load(object sender, EventArgs e){ RegisterAsyncTask(new PageAsyncTask(DoWorkAsync));}private async Task DoWorkAsync(){ var data = await GetUserDataAsync(userId); // ... use data}
RegisterAsyncTask is ASP.NET’s own sanctioned mechanism for running async work from a sync page lifecycle event. It does not block, does not hold threads, and allows the page pipeline to handle async completion correctly.
Coexisting Sync and Async
A pragmatic migration strategy — rather than converting everything at once — is to maintain both sync and async versions of database methods, and use each only from the appropriate call path:
csharp
// Sync version — for legacy sync call pathspublic static DataTable BoundPopulateDataTable(string command, string[] parameters){ using var conn = new SqlConnection(ConnectionString); using var cmd = new SqlCommand(command, conn); cmd.Parameters.AddRange(ConvertSqlParameters(parameters).ToArray()); conn.Open(); using var reader = cmd.ExecuteReader(); var dt = new DataTable(); dt.Load(reader); return dt;}// Async version — only called from async pathspublic static async Task<DataTable> BoundPopulateDataTableAsync(string command, string[] parameters){ using var conn = new SqlConnection(ConnectionString); using var cmd = new SqlCommand(command, conn); cmd.Parameters.AddRange(ConvertSqlParameters(parameters).ToArray()); await conn.OpenAsync(); using var reader = await cmd.ExecuteReaderAsync(); var dt = new DataTable(); dt.Load(reader); return dt;}
The discipline required is simple: never call the async version from a sync context, and never block on it.
Quick Diagnostic Checklist
If you’re seeing connection pool timeouts after an async migration, scan your codebase for these patterns:
| Pattern | Risk |
|---|---|
someTask.Result | ❌ Deadlock |
someTask.Wait() | ❌ Deadlock |
someTask.GetAwaiter().GetResult() | ❌ Deadlock |
await someTask | ✅ Safe |
RegisterAsyncTask(...) | ✅ Safe WebForms bridge |
Summary
The async deadlock in ASP.NET is invisible under low load, catastrophic under real traffic, and trivially easy to introduce during a migration. The root cause is always the same: blocking a thread on an async operation inside a synchronization context that needs that thread to resume.
The rule is simple and absolute: if you make a method async, every caller must also be async, all the way to the top of the call stack. There are no shortcuts. .Result is not a shortcut — it’s a time bomb.
Done correctly, async database access is genuinely more scalable. Done incorrectly, it’s worse than sync in every way.