Feature
Chasing Down Milliseconds: My Journey Optimizing Load Times on Portal

Abdul Rahman

As my internship at Poket comes to a close, I keep coming back to the same project when I think about what actually taught me something. It wasn't the flashiest thing I worked on, and it definitely wasn't the thing I expected to enjoy most going in. But somewhere between staring at DynamoDB query plans and watching a Sentry waterfall chart for the hundredth time, I found the project that made me feel like an actual engineer for the first time. That project was reducing load times across Portal, our internal dashboard used by every team member and client to manage surveys, review submissions, and track redemptions.
The Problem
It started simply enough. The survey builder page was taking around 3.1 seconds to load, warm. Then someone mentioned the Submissions page, the very first thing anyone sees after logging in, was taking upwards of 11 seconds. Eleven seconds doesn't sound catastrophic until you're the one sitting there watching a spinner, wondering if something broke.
My first instinct, honestly, was to assume the database was the problem. It usually is, right? So that's where I started.
Digging Into the Backend
The survey builder issue turned out to be a good entry point. We were fetching surveys by task ID and then filtering by state in Python, after the data had already been pulled back from DynamoDB. That meant we were doing work in application code that the database could do far more efficiently on its own. The fix involved adding a group_name attribute to our Surveys table, backfilling it across roughly 500 existing records, and building a new GSI so we could query directly by group and let DynamoDB handle the filtering.
It sounds simple written out like that, but it wasn't. I learned the hard way that infrastructure changes, database layer changes, and API changes all have to be sequenced correctly. I pushed the API change before the new database layer version was published and immediately broke dev. Lesson learned. I also missed that our Lambda execution role needed explicit permission to query the new index, since DynamoDB treats index permissions separately from table permissions. Small thing, but it took down the survey builder page for a bit before we caught it.
Once that was sorted, I moved on to the Submissions page. Here's where I made the mistake I'm most grateful for making. I looked at the code, found a query that ran the full filtered query twice, once to count total results and once to fetch the actual page of data, and assumed I'd found my villain. I wrote a fix using a window function so the count and the data could be pulled in a single query instead of two. I was proud of it.
Then I actually pulled the Sentry trace.
The Moment Everything Changed
Watching that waterfall chart was humbling in the best way. The database call I'd been so focused on was real, sure, about two seconds of the eleven. But it wasn't even close to the biggest offender. Our main JavaScript bundle alone was taking almost four seconds to load. Google Tag Manager and its associated scripts added up to roughly the same. Fonts, stylesheets, small icon files, they were all quietly stacking up, each one a second here, a second there, until suddenly you're at eleven seconds and nobody noticed how it happened.
That was the moment I stopped thinking about performance as a backend problem or a frontend problem and started thinking about it as a page problem. The user doesn't care which team owns the slow part. They just experience the wait.
I brought this to my manager expecting maybe a little pushback, since I was essentially saying the backend fix I'd just finished wasn't going to move the needle much. Instead she said exactly what I needed to hear: let's go where the evidence points. That gave me the confidence to pivot into frontend work I hadn't originally signed up for.
Into the Frontend
The first fix was almost embarrassingly simple once I saw it. Our Google Tag Manager script was sitting at the very top of the page's head tag, meaning it was the first thing the browser tried to fetch, ahead of our own app's styles and scripts. I moved it below our stylesheets and added preconnect hints for the external domains we depend on, so the browser could start opening those connections in parallel instead of waiting to discover them one by one.
A code review bot actually caught something I'd gotten wrong here, and I want to be honest about that because it taught me something too. I'd added a crossorigin attribute to every preconnect hint without thinking too hard about it. Turns out that attribute has to match how the actual resource gets requested, or the browser opens a redundant connection and you gain nothing. Getting called out by an automated review on something that specific stuck with me. It's a good reminder that performance work rewards precision, not just good intentions.
The bigger discovery came when I looked into why our main bundle was so large in the first place. Every single route in the app, all twenty four of them, survey builder, redemptions, permissions, the admin panel, everything, was being eagerly bundled into one giant file and shipped to every user on every page load, whether they ever visited those pages or not. Our build budget had actually been raised to ten megabytes at some point, which in hindsight was less a decision and more a surrender to the bloat that had already accumulated.
So I started converting routes to load lazily, one at a time, starting with login since it felt like the safest place to prove the pattern worked before touching anything higher traffic. That process taught me more about how Angular actually works under the hood than anything else I did this summer. I hit a wall almost immediately when I tried to move the login page into its own module, because a shared button component we use across twenty eight different templates was declared directly in the main app module, and Angular won't let a component live in two modules at once. Fixing that properly meant relocating it into a shared module first, which felt like a detour at the time but turned out to unblock every future route conversion, not just this one.
I also learned to resist the temptation to import our giant catch all Material module just because it was the path of least resistance. It would have technically worked, but it would have quietly dragged nearly every Material and CDK component we use into a lazy chunk that only needed two of them. Taking the extra few minutes to check exactly what the template used, and importing only that, kept the whole thing honest.
What I'd Tell Past Me
If I could go back to the start of this project, I'd tell myself to pull the performance trace before writing a single line of code. Not after. I spent real time optimizing a query that, while worth fixing, was never going to be the reason the page felt slow to a user. There's a version of this internship where I ship that fix, call it done, and move on to the next ticket without ever discovering the actual bottleneck. I'm glad that's not what happened.
I'd also tell myself that being wrong in front of your manager isn't the failure you think it is when you're twenty one and trying to prove yourself. The moment I said out loud that my backend fix wasn't the main lever, and got met with curiosity instead of disappointment, changed how I approached every problem after that. Good engineering conversations aren't about being right the first time. They're about being willing to follow the evidence even when it means admitting the thing you already built isn't the answer.
Where Things Stand
By the end of this project, the submissions data call itself was reduced from two database round trips to one wherever possible. The login route no longer ships its animation library and form dependencies to users who are just checking their dashboard. The main bundle shrank by over a megabyte from that first conversion alone, with several more routes queued up to follow the same pattern. We went from an eleven second load time with no clear culprit to a prioritized, evidence backed list of exactly where the remaining time is going, and a repeatable process for chipping away at it.
There's still work left. Redemptions, survey builder, and the rest of the routes are next in line for the same lazy loading treatment. But that's the part I'm most proud of, honestly. This wasn't a project with a single clever fix and a victory lap. It was a slow, sometimes humbling process of learning to actually look before assuming, and I think that lesson is going to outlast anything specific I shipped this summer.
