Solving the Fitment Problem with On-Device AI: A Practical Demo
If you’ve ever built or maintained a parts catalog — brake pads, spark plugs, filters, wiper blades, anything model-specific — you already know the hardest part isn’t storing the data. It’s matching a customer’s vehicle to the right SKU.
This is the fitment problem, and it’s harder than it looks.
Why fitment matching is deceptively hard
A parts catalog usually stores compatibility as structured data: make, model, generation, year range, engine code. That part is fine — it’s exactly the kind of data a vehicle registration lookup returns cleanly.
The hard part is the other side of the equation: what the customer actually types. In the real world, that’s rarely a clean match to your structured fields. People type:
- “Skoda Fabia” — no year, no engine, no generation
- “2016 Skoda Fabia 1.2 TSI” — precise, but phrased differently than your catalog’s fitment string
- “Opel Corsa” — when your catalog, sourced from a UK supplier, only lists “Vauxhall Corsa” (same car, different badge, depending on market)
- “VW Polo” — when the real question is which generation of Polo, because the answer changes the SKU
Traditional approaches — exact string matching, dropdown selectors, SQL LIKE queries — handle the first case fine and fall over on the rest. You either build an enormous manual mapping table (badge names, generation aliases, regional naming quirks) or you accept a worse search experience and push the ambiguity onto the customer.
A different approach: let a language model do the matching
The insight here isn’t “use AI to know about cars.” It’s narrower and more reliable than that: keep your fitment data as the source of truth, and use a language model purely as a fuzzy matcher between what the customer typed and what your catalog already says.
That distinction matters a lot in practice. Asking an LLM to recall precise technical specs (exact torque values, exact part numbers) is a recipe for hallucination. Asking it to reason about whether “Opel Corsa” and “Vauxhall Corsa D” refer to the same car — a fact any experienced parts counter person would know instantly — is a much safer, much more tractable task. The model isn’t inventing anything; it’s doing the kind of common-sense normalization a human would do at the counter.
Why on-device (Chrome’s built-in Gemini Nano) specifically
Chrome now ships a local language model — Gemini Nano — accessible directly in the browser via the Prompt API, with no server round-trip and no API key. For a fitment-matching use case, that has some genuinely useful properties:
- Zero marginal cost per search. No inference bill per catalog lookup, which matters a lot at scale for a high-traffic parts store.
- No data leaves the browser. The customer’s search and your catalog snippet stay local — relevant if you’re matching against commercially sensitive supplier data.
- Works offline / on flaky connections, which is a nice property for a trade counter or a warehouse app running on unreliable mobile data.
- Fast enough for interactive search — this isn’t a background batch job, it’s a live filter-as-you-type experience.
The trade-off is that Gemini Nano is a small model. It won’t out-reason a frontier model on genuinely obscure technical trivia. But for the kind of common-sense normalization fitment matching actually needs — generation aliases, badge equivalence, rough year-range reasoning — that’s well within a small model’s capability, and it’s worth proving that out before assuming you need a heavier (and costlier) server-side model.
The demo
I built a small working demo of this: marvelous-liger-5c793b.netlify.app
It mocks the whole flow end to end:
- Step 1 — registration lookup (simulated). Type or click a fake number plate, get back a vehicle record (make, model, year, engine) — standing in for a real reg-lookup API call.
- Step 2 — fitment filter (real inference). That vehicle description is matched, live, against a small catalog of brake pad SKUs — each carrying structured fitment data (platform, generation, year range, engine) — using Gemini Nano running entirely in your browser.
A few deliberately awkward test cases are built in, specifically to check whether the model is reasoning or just pattern-matching:
- Typing “Skoda Fabia” with no year correctly surfaces parts across all three Fabia generations in the catalog.
- Typing “2016 Skoda Fabia 1.2 TSI” correctly narrows that down to the one generation it actually fits.
- Typing “Opel Corsa” correctly matches a catalog entry that only ever says “Vauxhall Corsa” — nowhere on the page or in the underlying data does the word “Opel” appear. The model has to know, from general knowledge, that Opel and Vauxhall are the same car under different regional badges (Vauxhall in the UK/Ireland market naming, Opel everywhere else in Europe). Typing “Opel Astra” — a real Opel model with no equivalent in this catalog — correctly returns nothing, ruling out a lucky blanket association.
That last case is really the crux of the demo. A keyword search could never make that connection unless someone manually added an alias table entry for every badge-engineering pair across every market. The model does it for free, because it’s reasoning about the underlying vehicle rather than matching text.
Where this is genuinely useful — and where to be careful
Good fit:
- Free-text “what’s my car” search boxes on a parts catalog, replacing rigid make/model/year dropdowns
- Normalizing incoming customer descriptions before hitting a structured fitment database
- Client-side pre-filtering of a large catalog before a final, precise server-side lookup
Worth being careful about:
- Don’t let the model be the only check on a safety-critical fitment decision (brakes, tyres, anything where an actual wrong-fit part is dangerous) — treat its output as a shortlist, then validate against exact structured fields (year within range, engine code match) before confirming a sale.
- Small on-device models can occasionally drift off the expected output format — build in parsing that degrades gracefully rather than assuming a perfectly clean response every time.
- Availability depends on the user’s Chrome version, flags, and device capability (the model needs to download once on first use) — this is a progressive enhancement, not something to rely on as your only search path yet.
Try it yourself
The demo is live at marvelous-liger-5c793b.netlify.app — open it in a recent Chrome build (chrome://flags/#prompt-api-for-gemini-nano enabled), type a vehicle into the reg-lookup box or the fitment search directly, and watch it reason through generation and badge differences in real time, entirely on your own machine.
Technical appendix: how the JavaScript works
Everything runs client-side, in a single HTML file, with no backend. The interesting part is a small, generic aiFilter() function — it knows nothing about cars or brake pads specifically, and could just as easily filter a product catalog, a document set, or a list of job candidates against any natural-language criteria.
1. Feature detection and session setup
The Prompt API is exposed as a global LanguageModel object in current Chrome builds. The first step is just checking it exists, then checking on-device model availability before creating a session:
function getPromptAPI() { if ('LanguageModel' in self) return self.LanguageModel; if (self.ai && self.ai.languageModel) return self.ai.languageModel; // older API surface return null;}async function createFilterSession(onStatus) { const api = getPromptAPI(); const availability = await api.availability(); // 'available' | 'downloadable' | 'unavailable' if (availability === 'unavailable') throw new Error('Gemini Nano not available on this device.'); return api.create({ expectedOutputLanguages: ['en'], monitor(m) { // fires progress events the first time the model needs downloading m.addEventListener('downloadprogress', (e) => onStatus(`Downloading: ${Math.round(e.loaded * 100)}%`)); }, initialPrompts: [{ role: 'system', content: '...instructions below...' }], });}
A system prompt is set once, at session creation, rather than repeated on every call. It tells the model exactly what shape of task this is and exactly what shape of answer to give back:
“You are a precise fitment-matching engine… match generously but sensibly: account for generation nicknames, platform-sharing and rebadging between related manufacturers… Reply with ONLY a comma-separated list of the item numbers that are compatible. No explanations.”
That last constraint matters more than it looks. Small on-device models are far more reliable at producing a short, tightly-constrained output (a list of numbers) than at producing well-formed free text or JSON — so the whole design leans on getting the model to do the minimum possible amount of “talking.”
2. One batched prompt, not one call per item
Rather than asking the model “does item 1 fit? does item 2 fit?” — N separate inferences — the whole catalog is serialized into one numbered list and sent in a single prompt:
const list = items.map((item, i) => `${i + 1}. ${describe(item, i)}`).join('\n');const prompt = `Customer vehicle: "${criteria}"\n\n` + `Catalog items:\n${list}\n\n` + `Which item numbers are compatible with the vehicle "${criteria}"? ` + `Respond with only the comma-separated numbers (e.g. "1,3,5") or "none".`;const raw = await session.prompt(prompt);
The describe() function is the only thing that’s catalog-specific — it’s a small callback that turns one item into a line of text the model reads. For the brake pad demo it’s just part name + fitment string; for a different catalog it’d read different fields entirely. This is what makes aiFilter() reusable rather than one-off.
3. Parsing the response back into real objects
The model’s reply is a plain string like "1,3,7" or "none". Parsing deliberately doesn’t assume a perfectly clean format — it just pulls out any digits present, which degrades gracefully if the model adds stray words despite instructions not to:
const matchedIndexes = new Set( (raw.match(/\d+/g) || []).map(n => parseInt(n, 10) - 1));const kept = items.filter((_, i) => matchedIndexes.has(i));
Those indexes map straight back to the original array, so the function returns real catalog objects — not text the model generated — which is what keeps the model firmly in a matching role rather than a data generation role. It never gets the chance to invent a SKU or a fitment string; it only ever gets to point at ones that already exist.
4. Loading the model once, not per search
The first version of this demo created and destroyed a session on every click, which meant a delay on the very first search while the model loaded. The current version preloads a single session as soon as the page opens — the “Find compatible parts” button stays disabled with a spinner until createFilterSession() resolves — and that same session is reused for every subsequent search, so only the very first page load pays any loading cost.
5. The registration lookup is the one part that’s fake
Worth being explicit: Step 1 (plate → vehicle) in the demo is a hard-coded lookup table with an artificial setTimeout delay, standing in for what would be a real API call in production — e.g. against a vehicle registration dataset like the ones we work with at carregistrationapi.ie. Step 2 (the actual fitment filtering) is the only part doing real inference, and it’s a self-contained function that would drop into any catalog UI unchanged.
RegCheck MCP Server — Connector Documentation
RegCheck is a vehicle registration lookup API covering 50+ countries, operated by Infinite Loop Development Ltd since 2004. This connector exposes RegCheck’s lookup capability as an MCP tool, allowing Claude to look up vehicle details — make, model, colour, fuel type, engine size, and more — directly within a conversation.
Getting Started
1. Create a RegCheck account
If you don’t already have a RegCheck account, sign up at regcheck.org.uk. Your RegCheck username and password are used to authenticate with the MCP server.
2. Connect to Claude
In Claude, go to Settings → Connectors → Add custom connector and enter:
https://regcheckmcp-526628810409.europe-west2.run.app/mcp
Click Connect. Claude will redirect you to the RegCheck MCP sign-in page. Enter your RegCheck username and password and click Sign in. You will be redirected back to Claude automatically.
The connector uses OAuth 2.1 with PKCE. Your password is never stored by the MCP server — it is used only to verify your RegCheck account at sign-in time.
3. Enable the connector in your conversation
Once connected, enable the RegCheck connector via the + button at the start of a conversation. You can then ask Claude to look up any vehicle registration plate.
Example Prompts
- “What can you tell me about the car with plate AB12 CDE?”
- “Look up German plate M-AB 1234 for me.”
- “What fuel type is French registration AA-123-BB?”
- “Check the MOT status of UK plate LD21 ABC.”
- “What model is Australian plate ABC123 in Victoria?”
Claude will invoke the lookup tool automatically when a registration plate is mentioned in context.
Supported Countries
The connector supports vehicle registration lookups in 50+ countries, including:
| Region | Countries |
|---|---|
| Europe | United Kingdom, Ireland, Germany, France, Spain, Italy, Netherlands, Belgium, Poland, Portugal, Sweden, Norway, Denmark, Finland, Austria, Switzerland, Czech Republic, Romania, Hungary, and more |
| Americas | United States (all states), Canada, Brazil, Argentina, Mexico, Chile, Colombia |
| Asia-Pacific | Australia (all states), New Zealand, Malaysia, Indonesia, Taiwan |
| Middle East & Africa | South Africa, UAE |
For the full list of supported countries and country codes, see regcheck.org.uk.
Countries requiring a state or region parameter
Some countries require a state or province code in addition to the plate number. When Claude asks for a state, provide the standard abbreviation — for example:
- Australia: NSW, VIC, QLD, WA, SA, TAS, ACT, NT
- USA: CA, TX, NY, FL, etc.
- Canada: ON, BC, QC, AB, etc.
- Brazil: SP, RJ, MG, etc.
Available Tools
lookup_vehicle
Looks up vehicle registration details for a given plate and country.
| Parameter | Description |
|---|---|
registration | The vehicle registration plate number |
country | Two-letter ISO country code (e.g. GB, DE, FR, AU) |
state | State or province code — required for USA, Australia, Canada, Brazil |
Returns: Vehicle details including make, model, year, colour, fuel type, engine size, and any country-specific fields such as MOT/NCT status, tax status, or emissions data.
This tool is read-only — it performs a lookup only and does not modify any data.
Authentication
The RegCheck MCP Server uses OAuth 2.1 with PKCE (Proof Key for Code Exchange).
- No passwords are stored by the MCP server
- Access tokens are valid for 1 hour and are signed with HMAC-SHA256
- The server is stateless — no session data is retained between requests
- All traffic is over HTTPS
OAuth endpoints
| Endpoint | URL |
|---|---|
| Authorization | https://regcheckmcp-526628810409.europe-west2.run.app/authorize |
| Token | https://regcheckmcp-526628810409.europe-west2.run.app/token |
| Discovery | https://regcheckmcp-526628810409.europe-west2.run.app/.well-known/oauth-authorization-server |
Pricing & Usage
Usage is billed against your RegCheck account. Each vehicle lookup consumes one credit. Credits can be purchased at regcheck.org.uk. New accounts receive a small number of free credits to get started.
Support
For questions about the connector or your RegCheck account:
- Website: regcheck.org.uk
- Email: info@infiniteloop.ie
- Technical blog: blog.dotnetframework.org
For issues specific to the MCP connector, please include the country code and a sanitised (last 3 characters removed) version of the plate number in your support request.
Privacy Policy
The RegCheck MCP Server is operated by Infinite Loop Development Ltd.
Data collected by this connector:
| Data | Purpose | Retained |
|---|---|---|
| RegCheck username | Authenticate API requests | Not stored — used at sign-in only |
| Vehicle registration plate | Perform the lookup | Not stored — passed to RegCheck API and discarded |
| Country / state code | Route the lookup to the correct endpoint | Not stored |
What we do not collect:
- Conversation content
- Claude prompt text
- IP addresses beyond standard GCP Cloud Run access logs (retained 30 days)
- Any data beyond what is necessary to perform the lookup
Third parties: Lookup requests are forwarded to the RegCheck API at regcheck.org.uk, operated by Infinite Loop Development Ltd. No data is shared with any other third party.
Full privacy policy: infiniteloop.ie/privacypolicy.html
Data controller: Infinite Loop Development Ltd, Ballyliffin, Co. Donegal, Ireland. Contact: info@infiniteloop.ie
Building an MCP Server for a Vehicle Registration API in C# .NET 8
If you’ve spent any time with Claude recently, you may have noticed it can do things that feel surprisingly connected to the real world — looking up domain expiry dates, checking live weather, querying your calendar. Most of that happens through MCP: the Model Context Protocol, an open standard that lets AI assistants talk directly to external APIs and services.
I’ve been running RegCheck since 2004 — a vehicle registration lookup API covering 50+ countries, built on a .NET/IIS stack. This is the story of how I built an MCP server for it over the course of a few hours, deployed it to Google Cloud Run, and made it available for anyone to connect to Claude.
The full source is on GitHub: github.com/infiniteloopltd/RegCheckMCP
What is MCP, and why does it matter for API developers?
MCP is essentially a standardised way for AI assistants to discover and call your API tools. Instead of a user having to copy and paste a plate number into a lookup form, then copy the result back into their conversation, Claude can just do it — invisibly, mid-conversation, whenever it recognises that a vehicle lookup is relevant.
The interesting thing for API developers is that MCP is model-agnostic. The same server works with Claude, and potentially other AI clients that implement the standard. You build it once.
The architecture
The RegCheck API is a classic ASMX web service — it returns XML, with the vehicle data serialised as JSON inside a vehicleJson node. Each country has its own web method: Check for UK, CheckIreland for Ireland, CheckGermany for Germany, and so on across 50+ endpoints.
The MCP server is a thin ASP.NET Core 8 layer that:
- Accepts MCP tool calls over Streamable HTTP
- Reads the caller’s RegCheck username from an
X-Api-Keyheader - Maps the request to the correct ASMX endpoint for the given country
- Parses the XML response and returns the
vehicleJsoncontent back to Claude
The .NET MCP SDK
Microsoft maintains an official C# SDK for MCP — ModelContextProtocol.AspNetCore on NuGet. It integrates cleanly with ASP.NET Core’s dependency injection and minimal API hosting. Setting up a basic server is straightforward:
csharp
var builder = WebApplication.CreateBuilder(args);builder.Services.AddHttpContextAccessor();builder.Services.AddHttpClient("regcheck");builder.Services.AddMcpServer() .WithHttpTransport(options => options.Stateless = true) .WithTools<VehicleLookupTools>();var app = builder.Build();app.MapMcp("/mcp");var port = Environment.GetEnvironmentVariable("PORT") ?? "8080";app.Run($"http://0.0.0.0:{port}");
The Stateless = true option is important for Cloud Run — it removes the requirement for a session ID header, which is the right choice for a public API where each request is independently authenticated.
Defining tools
Tools are just C# methods decorated with attributes. The [Description] attributes are doing real work here — they’re what Claude reads to decide when to invoke the tool. Vague descriptions mean missed invocations; specific ones mean Claude reliably picks up the tool when a user mentions a plate number.
csharp
[McpServerTool(Name = "lookup_vehicle_uk")][Description("Look up a UK vehicle registration plate. Returns make, model, colour, fuel type, " + "MOT expiry, tax status, and engine size. Use when the user provides a UK vehicle " + "registration number such as 'AB12 CDE' or 'AB12CDE'.")]public async Task<string> LookupVehicleUk( [Description("UK vehicle registration number, e.g. 'AB12CDE'. Spaces are ignored.")] string registration){ return await CallEndpoint("Check", registration, null);}
For the multi-country case, rather than writing 50+ separate methods, a single generic tool with a country code parameter keeps things maintainable. The country-to-endpoint mapping lives in a dictionary:
csharp
private static readonly Dictionary<string, string> CountryEndpoints = new(){ { "IE", "CheckIreland" }, { "DE", "CheckGermany" }, { "FR", "CheckFrance" }, // ... 50+ entries};
A handful of countries (Australia, USA, Canada, Pakistan) require a state parameter as well, handled by a HashSet<string> check with a helpful error message if the caller omits it.
Authentication
The RegCheck API authenticates by username, passed as a query parameter. Rather than building OAuth (which the Anthropic Connectors Directory requires for directory listing, but not for custom connectors), I went with a simple X-Api-Key header — users pass their RegCheck username as the key value.
Because Streamable HTTP sends headers with every request, reading it is trivial via IHttpContextAccessor:
csharp
var apiKey = _httpContextAccessor.HttpContext?.Request.Headers["X-Api-Key"].FirstOrDefault();
No session management, no token storage — just read it fresh on every tool call.
Parsing the ASMX response
The RegCheck ASMX endpoints return XML. The vehicle data itself is JSON, serialised inside a vehicleJson node. Extracting it with LINQ to XML is a one-liner:
csharp
var vehicleJson = XDocument.Parse(xml) .Descendants() .FirstOrDefault(e => e.Name.LocalName == "vehicleJson") ?.Value;
Using LocalName handles XML namespace variations cleanly. The result is passed straight back to Claude as a JSON string — Claude can reason over the field names without any further transformation.
A typical response looks like this:
json
{ "Description": "1997 Vauxhall Corsa Breeze, 1389CC Petrol, 5DR, Manual", "RegistrationYear": "1997", "CarMake": { "CurrentTextValue": "Vauxhall" }, "CarModel": { "CurrentTextValue": "Corsa" }, "FuelType": { "CurrentTextValue": "Petrol" }, "EngineSize": { "CurrentTextValue": "1389CC" }}
Testing with MCP Inspector
Before deploying anywhere, I tested locally using the official MCP Inspector — a browser-based tool that acts like Postman for MCP servers:
npx @modelcontextprotocol/inspector
Point it at http://localhost:8080/mcp, add the X-Api-Key header in the connection config, connect, and you get a UI listing all registered tools. You can invoke them manually, see the raw JSON input and output, and iterate on descriptions without touching Claude at all. It significantly tightens the development loop.
Deploying to Cloud Run
The server deploys to Google Cloud Run with a single command:
gcloud run deploy regcheckmcp \ --source . \ --region europe-west2 \ --allow-unauthenticated
Cloud Run injects a PORT environment variable (always 8080), which the server reads at startup. Binding to 0.0.0.0 rather than localhost is essential — Cloud Run won’t route external traffic to a loopback-only listener.
The live endpoint is:
https://regcheckmcp-526628810409.europe-west2.run.app/mcp
Connecting to Claude
In Claude (claude.ai), go to Settings → Connectors → Add custom connector, enter the server URL, and add X-Api-Key as a custom header with your RegCheck username as the value. That’s it — Claude will now invoke the vehicle lookup tools automatically whenever you mention a registration plate in conversation.
You can try something like:
“What year is the car with plate AB12CDE?”
Claude will call lookup_vehicle_uk, get the response, and answer directly — no copying, no form filling.
What’s next
The immediate next step is submitting to the Anthropic Connectors Directory, which requires adding OAuth 2.1 with PKCE — so that users can authenticate via a proper consent flow rather than pasting an API key manually. The MCP server logic doesn’t change; it’s purely an auth layer addition.
Longer term, a VIN lookup tool is a natural addition — the RegCheck API supports it via a VinCheck endpoint, and it’s a different enough use case to warrant its own tool with its own description.
Code
Everything is open source and available at github.com/infiniteloopltd/RegCheckMCP. The full implementation including the country endpoint map, state validation, and XML parsing is under 150 lines of C#.
If you have a REST or ASMX API and you’re wondering whether it’s worth building an MCP server — based on this experience, the answer is yes, and it’s less work than you might think.
Building a Chinese Vehicle Database from MIIT Public Data
If you’ve ever tried to decode a Chinese VIN or look up vehicle specifications for a car manufactured in China, you’ll know the data is hard to come by. Commercial providers charge significant fees, the official sources are in Chinese, and the coverage is often incomplete. I recently spent several weeks solving this problem from first principles — scraping, parsing, and enriching the entire MIIT vehicle type approval database — and the result is now available as a data download.
Here’s how it was built, what’s in it, and why it might be useful to you.
What is the MIIT Gonggao system?
In China, every vehicle model that can be legally manufactured and sold must first be approved by the Ministry of Industry and Information Technology (MIIT). These approvals are published in batches called 公告 (Gonggao — literally “announcements”) through the Road Motor Vehicle Manufacturers and Products system (道路机动车辆生产企业及产品).
Each Gonggao entry represents a single approved vehicle variant, with a unique model code such as SGM7102JBA1 (a Buick sedan made by SAIC-GM) or TSL7000BEVAR4 (a Tesla Model Y built in Shanghai). The MIIT database is the authoritative registry of every vehicle permitted for manufacture in China — domestic brands, international joint ventures, electric vehicles, commercial trucks, buses, motorcycles, everything.
The data is public, but it’s fragmented across hundreds of batch announcements, buried in a query interface that’s entirely in Chinese, and has no bulk download option.
How the database was built
The MIIT provides a query API at service.miit-eidc.org.cn that powers their web interface. By reverse engineering the API calls made by the search page, it’s possible to query programmatically.
The collection process ran in two phases:
Phase 1 started with an existing seed list of known Gonggao codes and fetched their full technical specifications — dimensions, weight, engine details, VIN prefix, emission standard and so on.
Phase 2 ran a systematic scan through all two-letter prefix combinations (AA through ZZ), effectively enumerating the entire active catalogue. This discovered tens of thousands of models not in the seed list. Prefixes like CA (FAW/Jiefang) returned over 8,000 records alone.
The final collection covers 227,000+ vehicle records across all vehicle categories.
English make and model enrichment
The MIIT data is entirely in Chinese. Brand names like 别克(BUICK)牌 are recognisable to a Western reader, but most domestic Chinese brands are not. Vehicle types are bureaucratic category names (纯电动运动型多用途乘用车 = pure electric SUV) rather than marketing model names.
To make the data usable for an international audience, every record was passed through the Claude AI API using the Batch API at roughly $2.50 for the full 191,000-record run. The prompt provided the Chinese brand name, vehicle type, manufacturer name, dimensions, wheelbase and VIN prefix, and asked for the English make, model name and a confidence level.
The results are stored in EnglishMake, EnglishModel and EnglishConfidence fields. For well-known brands the accuracy is very high — Tesla, Toyota, BMW, BYD, Volkswagen, Geely and hundreds of others are correctly identified with high confidence. For obscure domestic commercial vehicle manufacturers, the model name may be generic (e.g. “Heavy Duty Tipper Truck”) but the make is generally correct.
VIN prefix data
One of the most useful aspects of the dataset is the IdentificationCode field, which contains the VIN prefix or prefixes assigned to each approved model.
A Chinese VIN follows the global standard: the first three characters are the World Manufacturer Identifier (WMI), and characters 4–8 are the Vehicle Descriptor Section (VDS) which identifies the specific model variant. The MIIT records the approved VIN prefix for each Gonggao entry, typically eight characters followed by placeholder characters (e.g. LRW3E7FA×××××××××).
This means the database can function as a Chinese VIN decoder: given any VIN from a Chinese-market vehicle, match the first 8–11 characters against the prefix table to identify the make, model, fuel type, dimensions, emission standard and production details.
For example:
LRW3E7FA→ Tesla Model Y, 特斯拉(上海)有限公司, pure electric SUV, ShanghaiLSGEP83A→ Buick sedan, 上汽通用(沈阳)北盛汽车有限公司, 999cc petrol engine, National VI emissionsLFV2A2AD→ Volkswagen Lavida, 上汽大众汽车有限公司, 1.5L petrol
What’s in the download
The dataset is a single UTF-8 CSV file containing 227,000+ rows with the following fields:
Model — Gonggao model code — the official Chinese type approval identifier
Make — Manufacturer name (Chinese)
Brand — Brand/marque name (Chinese)
Type — Vehicle category (Chinese)
EnglishMake — Manufacturer name in English (AI-resolved)
EnglishModel — Model name in English (AI-resolved)
EnglishConfidence — Confidence level of English translation: high / medium / low
CompanyName — Full legal name of the manufacturing entity
EnterpriseAddress — Factory and production address
VehicleLength / VehicleWidth / VehicleHeight — Overall dimensions (mm)
Wheelbase — Wheelbase (mm)
KerbWeight — Kerb weight (kg)
TotalMass — Gross vehicle weight (kg)
MaxSpeed — Maximum speed (km/h)
PassengerCapacity — Approved seating capacity
FuelType — Fuel type (gasoline, electric, diesel, hybrid, hydrogen, etc.)
EmissionStandard — Emission standard (National III through National VI)
TireSpecs — Tyre size specifications
IdentificationCode — VIN prefix(es) for vehicle identification
Who is this for?
Automotive data businesses building VIN lookup APIs, vehicle history services, or parts fitment databases for the Chinese market. The VIN prefix data in particular is expensive to license commercially — this dataset provides a solid foundation at a fraction of the cost.
Researchers and analysts studying the Chinese automotive market — EV adoption rates, manufacturer market share, emission standard transitions, the rise of domestic brands versus joint ventures.
Parts and aftermarket businesses who need to match vehicle specifications to the correct components for Chinese-market vehicles.
Developers building applications for the Chinese automotive sector who need a structured, machine-readable vehicle reference database.
Limitations
The dataset reflects the MIIT active catalogue at the time of collection. Discontinued models that have been removed from the active catalogue are not included. The English make/model enrichment is AI-generated and should be treated as indicative for low-confidence records — the underlying Chinese fields are always authoritative. VIN prefix matching identifies the approved model family but may return multiple variants for a given prefix, as multiple trim levels can share a VIN prefix range.
Get the data
The full dataset is available for download at payhip.com/b/3S6PE.
The CSV is compatible with Excel, Google Sheets, Python (pandas), R, SQL Server, MySQL and any standard data tool.
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.
The dangers of Parallel.ForEach(… , async (item)) in IIS
A single, trivial exception — one that your code already has a catch block for — shouldn’t be able to bring down your entire IIS web server. But it can, and it will, if you combine Parallel.ForEach with an async lambda. This post explains exactly why it happens, how to spot it in the Windows Event Log, and how to fix it permanently.
The Setup
You have a method that needs to perform the same async operation against multiple items — calling a set of external APIs, processing a batch of records, sending a collection of requests. You reach for Parallel.ForEach because it sounds like the right tool: parallel work, multiple items, run them all at once. You even add a try/catch inside the lambda because you’re being responsible. It looks like this:
Parallel.ForEach(items, async (item) =>{ try { var result = await ProcessItemAsync(item); lock (results) { results.Add(result); } } catch (ItemNotFoundException) { // item not found - fine, skip it } catch (Exception ex) { lock (errors) { errors.Add(ex); } }});
This looks safe. It has error handling. It uses async/await. It compiles without a single warning. And it will crash your IIS worker process (w3wp.exe) the moment any exception is thrown after an await.
Why It Crashes: The async void Trap
Parallel.ForEach was designed before async/await existed in C#. It expects a synchronous Action<T> delegate. When you pass it an async lambda, something subtle and dangerous happens: the compiler silently treats the lambda as returning void rather than Task.
This is the async void anti-pattern, and it has one devastating property: any exception thrown inside it cannot be caught by any caller. It escapes directly to the thread’s synchronisation context — and on a raw ThreadPool thread, that means it goes completely unhandled.
Here is the exact sequence of events that kills your server:
Parallel.ForEachfires the lambda for each item in the collection- Each lambda hits the first
awaitand suspends, returning control immediately Parallel.ForEachsees each lambda return (as void) and considers its job doneParallel.ForEachexits — the method returns to its caller — everything looks fine- Milliseconds later, the awaited operations complete and the continuations resume on raw ThreadPool threads
- An exception is thrown inside one of those continuations
- The
try/catchinside the lambda? It only catches exceptions thrown before the firstawait. After theawait, the lambda has already returned as far asParallel.ForEachis concerned - The exception has no owner, no observer, no catch block — it propagates to the ThreadPool itself
- In .NET 4.0 and later, an unhandled exception on a ThreadPool thread terminates the process
w3wp.execrashes. IIS restarts the application pool. All in-flight requests are lost
The particularly insidious part is that the try/catch gives you a false sense of security. You can see it right there in the code. But it doesn’t work the way you expect once an await is involved.
A Minimal Reproduction
You don’t need a complex codebase to reproduce this. The following is all it takes:
public static void CrashIIS(){ Parallel.ForEach(new[] { 1, 2, 3 }, async (item) => { await Task.Delay(100); // simulate any async I/O throw new Exception("This kills w3wp.exe"); // After the await, this runs on an orphaned ThreadPool thread // The process terminates }); // Parallel.ForEach has already returned here // The crash happens 100ms later}
Call that from any ASP.NET request handler — a controller action, an HttpHandler, anywhere — and your application pool will crash within moments. The caller gets no exception. The HTTP response may even succeed before the crash occurs. The next user to make any request gets a 503.
Even wrapping the call in a try/catch at the call site doesn’t help:
try{ Parallel.ForEach(new[] { 1, 2, 3 }, async (item) => { await Task.Delay(100); throw new Exception("Crash"); });}catch (Exception ex){ // This NEVER fires. // The exception doesn't happen until after Parallel.ForEach // has already exited this try block entirely. Log(ex);}
The catch block is long gone by the time the exception is thrown. This is what makes the pattern so dangerous — it looks exception-safe at every level, and isn’t.
How It Appears in the Windows Event Log
When this crash occurs, it leaves a very specific fingerprint in the Windows Event Log. Open Event Viewer → Windows Logs → Application and look for two entries appearing within seconds of each other.
Entry 1: .NET Runtime — Unhandled Exception
Source: .NET Runtime
Event ID: 1026
Application: w3wp.exeFramework Version: v4.0.30319Description: The process was terminated due to an unhandled exception.Exception Info: YourNamespace.YourException at YourClass.YourMethod() at SomeClass+<SomeMethod>d__3.MoveNext() at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw() at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task) at SomeClass+<>c__DisplayClass4_0+<<YourParallelMethod>b__0>d.MoveNext() at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw() at System.Threading.ExecutionContext.RunInternal(...) at System.Threading.ExecutionContext.Run(...) at System.Threading.QueueUserWorkItemCallback.ExecuteWorkItem() at System.Threading.ThreadPoolWorkQueue.Dispatch()
The key indicators are at the bottom of the stack trace:
QueueUserWorkItemCallback.ExecuteWorkItem()ThreadPoolWorkQueue.Dispatch()
These tell you the exception surfaced on a raw ThreadPool work item with no managed owner — the classic signature of an orphaned async continuation. You will also see compiler-generated state machine names like <YourParallelMethod>b__0>d.MoveNext() in the trace, confirming the exception came from inside an async lambda. The angle brackets and the b__ notation are the C# compiler’s naming convention for anonymous methods and lambdas.
Entry 2: Application Error — w3wp.exe Fault
Source: Application Error
Event ID: 1000
Faulting application name: w3wp.exeFaulting module name: KERNELBASE.dllException code: 0xe0434352
Exception code 0xe0434352 is the Windows error code for a managed (.NET) exception that has escaped to the Win32 layer. It’s the OS-level record of a .NET exception killing a process. When you see this code combined with KERNELBASE.dll as the faulting module, a .NET unhandled exception is the cause.
What to Look For — Summary
| Signal | Where | What it means |
|---|---|---|
ThreadPoolWorkQueue.Dispatch() at bottom of stack | Event ID 1026, .NET Runtime | Exception from orphaned async continuation |
Compiler-generated names like b__0>d.MoveNext() | Event ID 1026, .NET Runtime | Exception came from inside an async lambda |
Exception code 0xe0434352 | Event ID 1000, Application Error | .NET exception killed the process |
Faulting module: KERNELBASE.dll | Event ID 1000, Application Error | Managed exception, not a native crash |
| Both entries within seconds of each other | Application log | Single event caused immediate process termination |
The Effect on IIS
When w3wp.exe terminates due to an unhandled exception, IIS detects the process death and marks the application pool as faulted. Depending on your Rapid Fail Protection settings (found in IIS Manager → Application Pools → Advanced Settings), IIS will either:
- Restart the worker process automatically — users experience a brief outage and then service resumes, with the first request after restart being slow due to application warm-up
- Disable the application pool if failures occur too frequently within the Rapid Fail Protection window (default: 5 failures in 5 minutes) — this results in a persistent 503 until an administrator manually starts the pool again
This is worth understanding because thread exhaustion and this crash pattern look identical from the outside — both produce 503 errors — but they behave very differently. Thread exhaustion self-recovers when load drops. A crashed application pool requires either automatic restart (if Rapid Fail Protection hasn’t tripped) or manual intervention. If your team is regularly performing IISResets to recover from outages, a crash like this is a more likely culprit than thread exhaustion.
The Fix
The correct replacement for Parallel.ForEach with async work is Task.WhenAll, which is async-native and properly propagates exceptions back to the awaiting caller:
public static async Task<IReadOnlyList<Result>> ProcessAllAsync(IEnumerable<Item> items){ var tasks = items.Select(async item => { try { return await ProcessItemAsync(item); } catch (ItemNotFoundException) { return Result.Empty; } }); // All items processed in parallel. // Exceptions surface here, as AggregateException, to a proper awaiter. return await Task.WhenAll(tasks);}
With Task.WhenAll:
- All items are processed in parallel — no performance regression
- Every async continuation is properly tracked by the Task infrastructure
- Exceptions are collected and re-thrown as
AggregateExceptionwhen awaited — to a caller that can handle them - The process does not terminate
As an immediate safety net while refactoring, you can also add a global handler in Global.asax that prevents process termination from unobserved task exceptions:
// In Application_Start (Global.asax)TaskScheduler.UnobservedTaskException += (sender, args) =>{ Logger.Error("Unobserved task exception", args.Exception); args.SetObserved(); // prevents process termination};
This is a safety net, not a fix — the underlying orphaned tasks still exist and their results are still lost. But it prevents a single unhandled background exception from taking down your entire server while you work through a proper refactor.
The Rule
The rule to remember is simple: never pass an async lambda to Parallel.ForEach. The two are fundamentally incompatible. Parallel.ForEach has no understanding of Task, does not await the work it fires, and any exception thrown after the first await inside your lambda will be orphaned on the ThreadPool. In .NET 4.0 and later, that means process termination.
The pattern is particularly easy to introduce because it compiles cleanly, looks reasonable, and even appears to have proper error handling. The only sign something is wrong is your server going down.
When you need parallel async work, use Task.WhenAll. It was designed for exactly this purpose.
Found this useful? If you’re diagnosing IIS instability, check your application pool’s Rapid Fail Protection settings and review Event Viewer’s Application log for Event ID 1026 with ThreadPoolWorkQueue.Dispatch() at the bottom of the stack trace — that’s the fingerprint that points directly to this pattern.
Taiwan motorcycle plate lookup via #API

Our vehicle API network expands into Taiwan with full motorcycle registration lookups — make, age, engine size, and emissions test history, all in one call.
Taiwan has one of the highest motorcycle densities in the world, with over 14 million registered two-wheelers on its roads. Today, we’re pleased to announce that the /CheckTaiwan endpoint is live — bringing motorcycle registration data from Taiwan’s national vehicle database into our global API network.
What the endpoint returns
A single call to /CheckTaiwan with a Taiwanese plate number returns structured vehicle data covering identity, registration history, and emissions compliance records:
- Make — Manufacturer name (Chinese & romanised)
- Age / Registration year — Year of manufacture and license issue date
- Engine size — Displacement in cc and engine cycle type
- Inspection records — Full emissions test history with HC, CO, and CO₂ readings
Sample lookup: MWN-0076
Here’s what a real response looks like for a 2018 Kymco (光陽) motorcycle:
{ "Description": "光陽", "RegistrationYear": "2018", "CarMake": { "CurrentTextValue": "光陽" }, "EngineSize": "149", "ManufactureDate": "01/08/2018", "LicenseIssueDate": "05/09/2018", "EngineCycle": "四行程", "TestRecords": [ { "LicensePlate": "MWN-0076", "InspectionType": "定期檢驗", "HC_ppm": "102", "CO_pct": "0.1", "CO2_pct": "14.8", "Result": "合格", "TestDate": "20240822" }, { "LicensePlate": "MWN-0076", "InspectionType": "定期檢驗", "HC_ppm": "315", "CO_pct": "0.1", "CO2_pct": "14.9", "Result": "合格", "TestDate": "20240718" } ]}
The TestRecords array is particularly valuable — it provides a full chronological emissions test history, with pass/fail status (合格 = pass), hydrocarbon and carbon monoxide readings, and the serial number of each inspection. This supports fleet compliance monitoring, insurance underwriting, and second-hand vehicle verification use cases.
API endpoint
The endpoint is live now at:
https://www.chepaiapi.tw/api/reg.asmx?op=CheckTaiwan
Full documentation and interactive testing are available at chepaiapi.tw.
Expanding Chinese-language coverage
This launch also deepens our Chinese-language vehicle data coverage. Alongside Taiwan, our mainland China vehicle lookup service at chepaiapi.cn continues to serve customers requiring PRC plate data — together forming a comprehensive Chinese-language API offering across both sides of the strait.
Use cases
The Taiwan endpoint is well-suited to:
- Insurers pricing two-wheeler policies
- Logistics platforms operating scooter fleets
- Used vehicle marketplaces verifying provenance
- KYC and compliance workflows touching Taiwanese vehicle assets
The inclusion of emissions test records is a differentiator that goes beyond simple registration confirmation — providing genuine due diligence depth for any platform that needs it.
Taiwan joins our network of 55+ country vehicle lookup APIs. We’ll continue expanding coverage across Asia Pacific throughout 2026.