DEV Community

Mustafa Hassan
Mustafa Hassan

Posted on

Migrating an Educational Platform from Laravel to ASP.NET Core: A 90% Performance Recovery

I inherited Judeer, an educational platform, in a state that most backend engineers dread: a production system with severe, user-facing performance problems **and no clear path forward from the existing codebase.
The most critical issue was response time. Under normal load, API responses were taking up to 20 seconds — an unacceptable experience for any platform, let alone one serving students and instructors who needed to interact with content in real time. On top of that, the system suffered from intermittent data retrieval failures, an admin dashboard that couldn't handle basic rich content (instructors literally couldn't write mathematical notation for course material), and a video delivery pipeline that wasn't built for adaptive streaming.
I made the call to do a full technical rebuild rather than patch the existing Laravel system: migrate to ASP.NET Core with PostgreSQL as the backend foundation.
Diagnosing the Root Cause
Before touching a single line of new code, I profiled the existing system to understand why it was slow — not just that it was slow. The biggest offenders were:
Missing or poorly designed database indexes, causing full table scans on frequently queried data
Inefficient query patterns with no ORM-level optimization
No structured approach to caching or connection handling
This mattered because a rewrite without addressing the root cause would have just reproduced the same problems in a new language.
The Rebuild
Backend framework: ASP.NET Core, chosen for its performance characteristics, strong typing (which catches a whole category of runtime errors that PHP's dynamic typing lets slip into production), and first-class async support for I/O-bound operations like database and external API calls.
Database layer: PostgreSQL with Entity Framework Core. I redesigned the indexing strategy from scratch based on actual query patterns rather than guesswork, and optimized the EF Core queries to avoid the N+1 problems that had been silently killing performance under the old system.
Result: response times dropped from ~20 seconds to under 2 seconds — a performance improvement of over 90%.
A System Design Problem Nobody Had Caught
While migrating, I found something more subtle than a performance bug: the previous implementation used sequential integer values as primary keys across the system.
This is a design decision that looks harmless until you need to restore from a backup or reconcile data from an older database snapshot — at which point you risk ID collisions between newly created records and records being restored from an older state. It's the kind of problem that doesn't show up in testing and only bites you during an incident, which is exactly when you don't want new problems.
The textbook fix is to move to GUIDs as primary keys. But Jodeer already had a live mobile application built against the existing integer ID scheme. A full migration to GUIDs would have meant reworking the mobile client's data layer too — real engineering time, and a real cost to the company for a problem that had a cheaper solution.
Instead, I implemented Sqids (an ID-obfuscation/encoding scheme) to generate unique, non-sequential identifiers at the API boundary while preserving the underlying integer scheme the mobile app already depended on. This closed the collision risk without touching the mobile codebase or requiring additional development resources — a solution that balanced technical correctness against business cost, not just "what's technically ideal in a vacuum."
Other Improvements Along the Way
Video delivery: implemented transcoding to HLS (HTTP Live Streaming) format, enabling adaptive bitrate streaming instead of serving flat video files
Admin permissions: extended the role/permission system to give administrators finer-grained control instead of the all-or-nothing access that existed before
Content authoring: added rich content editing support for instructors, including mathematical notation — a feature the platform needed but never had
What This Taught Me
The easy version of this story is "I rewrote a slow app in a faster framework." The real lesson was about judgment: not every correct fix is the right fix. The GUID vs. Sqids decision is the one I keep coming back to — the "best practice" answer and the "best answer for this system, this team, and this budget" weren't the same thing, and figuring out which one to ship is a bigger part of the job than writing the code itself.
I'm a backend engineer working primarily in C# / ASP.NET Core, PostgreSQL, and EF Core, currently leading technical direction across several product builds. Always happy to talk architecture — feel free to reach out.**

Top comments (1)

Collapse
 
hubertgarcia profile image
Hubert García Gordon

The part I'd underline for anyone reading this before their own migration: the three root causes you profiled — missing indexes, N+1 queries, no caching strategy — are all framework-independent. Every one of them was fixable in Laravel. So the 20s → 2s isn't really a Laravel-vs-ASP.NET number. It's a "we finally fixed the queries" number, and the platform change is confounded with it.

I say that as someone in the middle of a much uglier migration: a 20-year-old WebForms system behind national hospital statistics, 442 stored procedures deep. I have the same problem you do. I can't tell stakeholders how much of the improvement is .NET 9 and how much is two decades of accumulated bad SQL finally getting rewritten. The only honest approach I've found is to apply the cheap fixes to the old stack first and measure there, so the rewrite only gets credit for what's left. It's slower and it makes the headline number smaller, but it's the number that survives someone asking "did we actually need to rewrite this?"

One genuine question on Sqids. Since it encodes the integer at the API boundary and the underlying primary keys stay sequential, doesn't the restore-collision risk you describe still exist at the database level? From here it reads like it solves ID enumeration rather than collision. Curious whether there's a piece I'm missing.