Topic Guide
Classic ASP Modernization
The app underneath these posts is real: it runs real payment callbacks, real multi-tenant customer data, and admin screens that carry fifteen years of assumptions nobody wrote down. It's also classic ASP, a stack most teams would call a rewrite candidate on sight. This hub is the case against that instinct, told through the actual bugs and decisions of keeping it alive.
Modernizing a codebase like this doesn't mean replacing the stack. It means finding the load-bearing walls, the undocumented global that a hundred call sites quietly trusted, the query that used to run fast until the data grew, the 47,000-line JavaScript file that is somehow still the fastest way to ship a feature, and changing them without bringing the building down. Some of the posts below are correctness bugs that hid for years. Some are performance incidents that looked like attacks. Some are the release discipline that makes six production hotfixes survivable in one day instead of terrifying.
If there's one lesson that repeats across all of them, it's that a 20-year-old app doesn't fail because the language is old. It fails because a decision made once, correctly, stopped being reviewed. The fix is rarely a rewrite. It's usually finding the one place the decision lives and making it explicit again.
57 posts in this guide, by DX
Start here
A fluent DOM builder for legacy ASP: modernize without a rewrite
How I replaced string-soup Classic ASP pages with a fluent DOM builder on Page.prototype: no framework, no big-bang rewrite, one page at a time.
Modernizing without a rewrite
-
Cache-Busting With No Build Pipeline: Date-Stamped Asset Filenames
A legacy ASP app had no bundler or asset manifest, so it versioned compiled JavaScript and CSS with date-stamped filenames. The convention changes the asset URL with a minimal, auditable release step.
-
Our servers are too old to fix, and we still ship classic ASP on purpose
The host warned they can't promise replacement parts for our dedicated boxes. The fix everyone expects is a rewrite. The fix that pays the bills is decoupling: DNS to the edge first, hardware second, stack unchanged.
Payments and checkout on the legacy gateway
-
The Pricing Rewrite Only Got Real When I Wrote the Gates
A tournament-pricing prototype looked simple until it met hybrid checkout, existing feature gates, proration, account-state rules, and legacy billing. The hard part was making the product decisions explicit across those systems.
-
The bug was wearing a trench coat
Two hotfix branches merged the same day. Three tickets looked like one customer complaint and turned into three different bugs: bad payment data, wrong scope resolution, and a deploy that would have re-broken the fix by Tuesday.
-
The reuse story died, so I shipped a new cart instead
I went in expecting to wire tournament registration onto the existing cart. One grep later, the premise was dead and I was designing from scratch. The new cart shipped the same day.
-
A `?prototype=1` Flag and 23 Commits: Prototyping a Redesign in Production Code
A query-string prototype flag can separate an internal presentation path from the default experience, but it does not isolate shared writes, totals, or payment contracts. Those seams need deliberate controls and verification.
-
Server-Side JavaScript on Classic ASP in 2026: Prototype Pages and the DB Helper Pattern
A production payment flow in server-side JScript on classic ASP, and the OR-fallback bug that read the gateway's transaction ID instead of the invoice ID I sent.
-
Shipping a Throwaway Relay Page to Debug a Payment Integration in Prod
When a gateway won't POST to localhost, ship a throwaway relay page to prod and read the real payload. Three silent bugs surfaced that a faked callback never would.
-
A Trusted Gateway POST Is Still a Public Endpoint
A payment gateway callback fed a raw POST field straight into a SQL WHERE clause. How trusted-caller framing kills input validation, plus the five-line boundary fix.
Data-model and correctness bugs that hid for years
-
The feature was fine. First-time state was the real bug.
The tournament feature worked after week two. The other three weeks went to everything around it. Every signup path left different first-run state behind.
-
The CSS Bug That Was Actually a Delete Bug
A mobile-button complaint looked like a style ticket, but the same investigation found a boolean error in the layout-save path that could remove modules without a visible explanation.
-
The Bug Was Not in the Mobile UI
A display-order field that appeared to save and then silently dropped its value. The investigation found that ADD and UPDATE had been writing different data shapes for years inside the same module save path.
-
The bug wasn't the upload, it was the URL contract
Inside a day of debugging an old ASP-classic form, where a querystring namespace collision hid behind an upload bug and exposed the real shape of AI-paired work in legacy code.
-
I had to stop letting the package tier pretend it was the product
A ticket started as a Packages UI redesign. By the time I closed it, I had touched the tournament org taxonomy, the credit constants, and the account type routing logic in three separate files. The UI was the last thing I changed.
-
Classic ASP Doesn't Care That It's a Comment: The Script-Delimiter Collision
A Classic ASP include comment contained a literal closing delimiter that the template scanner could interpret before the JScript block comment existed. The prototype was rewritten to describe the syntax without reproducing the delimiter.
-
The Duplicated-Derivation Bug: One Fragile Expression in Four Queries
Four queries each derived the same entity key inline and quietly disagreed. Why duplicated derivation is a live correctness bug, and the one-resolver fix.
-
Response.Redirect Blew Up the Error Page. Server.Transfer Didn't.
A bad bracket ID reached a Classic ASP error screen because a late redirect ran inside the failure path. The fix replaced brittle URL assembly with a safe internal handoff and documented the remaining fallback issue.
-
The Ambient-Global Bug: When "Current Account" Is a Mutable Global Your Whole App Reads
A dropdown that let org admins drill into child events broke saves across a 12,000-line handler. Two globals that had always pointed at the same object suddenly didn't. Here's what the fix looked like and why the real hazard is the mutation, not the call sites.
-
The new door was already in the building
A request called for a low-discoverability tournament signup path. An existing admin linking flow already supplied the needed context and downstream handling, so extending it avoided a parallel implementation.
-
The Real Fix Was Starting Over With a Smaller Blast Radius
An AI-assisted pricing prototype became reviewable only after its scope was separated from shared purchase logic, with explicit dependency checks and a smaller surface for subsequent changes.
JavaScript with no framework, no bundler
-
Why Request.Form Is Empty: FormData vs URLSearchParams in Classic ASP AJAX
Classic ASP's Request.Form only parses application/x-www-form-urlencoded. Post FormData from a modern fetch call and the collection comes back silently empty with no error. Here is why, and the one-line fix.
-
Hand-rolled hydration: passing server state to the browser with no framework
Hand-rolled hydration broke silently when a second account context appeared. The fix was one named global and a one-line comment at the injection site.
-
The legacy server-variable bug that printed `undefined` instead of a value
A diagnostic page returned the literal word `undefined` instead of a client value. The lesson was not just a language quirk: copies of shared logic had drifted away from the defensive helper.
-
A 47,490-line JavaScript file and what it tells you about a profitable legacy app
Two 'current account' globals were equal for fifteen years until a context switch split them and silently broke hundreds of call sites in one 47,490-line file.
-
Carry Your State in the URL, Then Actually Carry It: The Dropped Query Param
A query param carried selection state until two links forgot to re-emit it. Why URL-as-state needs one central URL builder, not hand-appending at call sites.
Performance and incidents under legacy load
-
The Page Was Performing a Ceremony, Not a Job
An internal overview page accumulated more than 80 blocking queries per load. The important fix was to reconsider its page contract and defer nonessential sections, not merely tune one query.
-
The call was coming from inside the house
A login abuse filter blacklisted a power user for clicking too fast; the IIS logs proved the fast clicks were our own redirect chain, not an attack.
-
The Fastest Legacy Hotfix Is Often a Diagnostic
A stacked set of customer tickets became clearer once small, access-controlled diagnostics separated data-state problems from code regressions before remediation began.
-
A 25-Second Admin Screen Meant We Trusted the Wrong Query
An internal admin screen that looked like a layout ticket required a trace of its real database work. The incident showed why query plans, scope, and user value matter more than the surface description of a slow page.
-
The fix that ran on every request
I closed a redirect-loop bug in two hours. Then the fix itself saturated the IIS worker pool on every authenticated admin page load. Two tickets, one afternoon.
-
The exit survey that overwrote a live mailer
A new exit-feedback page shipped cleanly. The provisioning script had already silently clobbered a scheduled digest mailer row that had been live for months.
-
314 broken stats pages and one bad cursor
Stats pages were broken across 314 sites. The cause was a shared recordset helper opening keyset ADO cursors over aggregate queries. The data caught up.
-
The bot swarm that turned out to be one bad query plan
The uptime monitor fired on a 9 PM CPU spike. Everything pointed to hostile traffic. It was one cached plan in a request-log table doing clustered scans on every request, and one OPTION (RECOMPILE) settled it.
-
When a Workflow Gap Needs an Operator Tool: Preview, Policy, and an Audit Trail
When a normal workflow cannot make a needed, authorized change, resist the one-off production update. A narrow operator tool should make intent, authorization, validation, review, execution, and recovery explicit.
-
'Newest First' Is a Re-Priority Problem, Not an ORDER BY
A new folder needed to appear first without undoing the order people had arranged by hand. The fix moved to the create path, and it left a wrinkle behind.
-
Shipping a Whole Analytics Suite Into a Legacy ASP Admin
Five report pages inside a classic-ASP admin, one page pattern, and the habit that paid: check in your review SQL as the canonical business definition.
-
The attacker was our own redirect logic
The abuse filter caught an attacker. It was us. Our own redirect chain was tripping the speed flag on real admin accounts.
-
The fastest fix was blocking the bot by name
AwarioBot slipped past an existing IP blacklist because the per-request filter had auto-cleared its range. Thirty seconds in the session handler fixed it.
-
The method outlasts the patch
A ticket closed on April 23rd with five firewall rules. The durable win was the two hours I spent the next morning writing down how to investigate the next swarm so I would not have to rediscover the frame from scratch.
-
The Prod Box Was DDoSing Itself: An iCal Calendar Feature Looping Through the Public Edge
Two hours of IIS logs showed the prod box hitting its own schedule endpoint 19,178 times through Cloudflare: an iCal self-loop, plus the case for 410 Gone.
Release discipline on a fleet of clones
-
Env drift was the real bug
A workflow cleanup became a twelve-clone migration when long-lived environments had drifted at the same shared files. The code change was only part of the work; the migration record made it repeatable.
-
The Real Release Started After the Tag
A feature deployed cleanly, then two data-shape defects surfaced in the first post-release window. The lesson is to treat production monitoring as part of completion, not an afterthought.
-
187 Commits to Fix Four Bugs
A normal customer-fix day exposed the propagation cost of carrying one application across eleven long-lived clone and release paths.
-
Don't Trust the Green Deploy: Grep the Live File for Your Ticket Marker
A green deployment signal can confirm that a pipeline completed without proving a specific fix reached the serving artifact. Use a traceable artifact marker or a behavior-level check to verify the change that matters.
-
A var EDIT_DATABASE = false Sat in a Branch for 2.5 Years. Then We Shipped It.
A single debug flag set to false in October 2023 rode a parked branch for two and a half years, merged in April 2026, and silently dry-ran every admin write in production. The postmortem is about how scaffolding debt outlives memory and what we changed to prevent it.
-
Route a Change by Its Blast Radius, Not Its Size or Your Schedule
A four-line redirect fix shipped as a hotfix while a 760-line refactor rode the preview branch. Route changes by blast radius, not diff size or the release calendar.
-
Six Production Releases in One Day: Anatomy of a Hotfix Train
A minor release and five hotfixes shipped across one day on a long-running Classic ASP app. Keeping each hotfix small made rollback scope easier to reason about under pressure.
-
I Had to Turn My Internal Docs into a Product
A hotfix day produced a pre-edit branch guard that blocked protected-branch writes in this setup, alongside a docs rebuild that gave AI-assisted work a more usable reference surface.
-
A three-pass AI code review that kept finding real bugs
I sent a cross-cutting feature design to a read-only AI reviewer three times. Each pass found new, real bugs: eleven findings total, none hallucinated. Here's what made the difference.
Data recovery and multi-tenant safety
-
1900-01-01 Is Not a Date: The Zero-Datetime That Corrupts Every Migration
SQL Server's datetime epoch (1900-01-01) looks like a real date, serializes cleanly, and inserts without error. That's exactly why it corrupts migrations. Here's how to catch it and why you need to omit the column, not blank it.
-
A code-first schema audit: keeping a 1,477-line ASP file in sync with a 14,536-column DB
When a hand-maintained recovery script has to stay in sync with a 14,536-column database, discipline alone won't cut it. Here's how a 418-line Python differ and a source-of-truth decision solved the problem.
-
I Wrote a 1,477-Line Site-Recovery Engine in Classic ASP: Copy First, Remap IDs Second
When a customer's entire sports site gets deleted in a shared-database multi-tenant system, RESTORE DATABASE is not an option. Here's the copy-first, remap-IDs-second pattern that makes application-level recovery work without cross-tenant corruption.
-
Don't leave the recovery gun loaded: scrubbing prod constants at end of day
After a successful data recovery, the script was still armed: real customer username hardcoded, phase set to run. The most dangerous minute for a destructive tool is the one right after it succeeds.
-
N Drifting Copies Share One Bug: The Legacy Consolidation Thesis
Three per-sport recovery scripts shared one un-scoped UPDATE bug that could corrupt other tenants. Consolidating them gave the bug one place to live.
-
The One-Shot Data-Repair Script as a First-Class Artifact
The console UPDATE is the most dangerous tool a solo dev points at production. A four-part repair-script discipline: dry-run, assert, idempotent, JSON record.
-
I Almost Built an Entitlement System for a Feature That Was Already a Page
When a group of pages needed tier-aware navigation, an existing per-page tier field avoided a parallel UI catalog. One dynamically added page exposed the ordering bug that declarative setup can hide.
Hitting one of these walls in your own codebase or your own machine? Talk it through with us, or read the rest of the Build Log.