The Friday Deploy
The plain English newsletter for people who run the business, not the tech stack. No jargon. Every Friday.
We Made an Online Book Store Faster by Reducing its Performance Capacity.
Four code changes. Zero new features. Increased performance. One continuous thread.
We recently took on an online bookstore that had been ticking along without dedicated technical oversight.
The codebase worked, the infrastructure was established, and on the surface everything looked fine. But there’s a moment when you look closely at a platform that hasn’t had active maintenance, and you start to find things. Not dramatic failures. Just a slow accumulation of configuration drift, missed optimisations, and quiet assumptions that nobody had ever tested.
What we found revealed it was capable of much greater performance without upgrading its infrastructure. Not by increasing anything, but reducing things, and following a single thread all the way to the end.
Here’s how it unfolded.
It started with memory pressure on staging
The staging environment was hitting R14 errors on startup. R14 is Heroku’s way of telling you a dyno has exceeded its memory limit. The app was breaching that limit before it had even handled a single request. We bumped its memory to match production and it was fine. We put it back and it fell in a heap.
That put memory usage front of mind.
The opportunity: a change to S3 hosting
Around the same time, we were migrating the application to a new AWS account for image hosting. That work touched the AWS configuration, which prompted a closer look at the gems the app was using to talk to AWS.
We found the app was running the full AWS SDK. Not just S3, the one and only AWS service it actually uses. Every AWS service client, loaded into memory on every startup, for services the app had never used and would never use.
Removing it and replacing it with just the S3 library shrank the dependency list by roughly 1,690 lines. That’s not a typo. Here’s the pull request for that change:
Boot time reduced. CI build times dropped too, which saves real time and build minutes on every deploy from here on. Every future deploy is a little faster and a little cheaper because of one dependency cleanup. But memory pressure on staging remained an issue.
While we were in there: fixing how the app handles errors
A well-configured web application returns the right response code for every request. Sounds basic. In practice it often isn’t.
This app had three problems. Requests for missing non-HTML file types were throwing 500 errors instead of returning a clean 404. Missing pages that should have been responding with 404 were returning 200, telling crawlers the page existed when it didn’t. 404 handling needed tightening up.
The HTTP response codes matter because crawlers use them to decide what to do next. A 404 means “nothing here, move on.” A 500 means “something is broken right now, come back later and it might work” so the bots keep coming back. A false 200 is the worst of all: crawlers index pages that don’t exist, polluting your search presence with ghost pages.
One line of code. Three problems fixed. Error logs quieter, crawlers better behaved, search engines getting an accurate picture of what’s actually on the site.
The Most Kafkaesque Bug We’ve Seen This Year
With the S3 change in place, we ran a proper test on staging. That meant running imports, which is how the application processes its core bibliographic data.
Automated imports worked, but manual imports via the admin were failing mid-run. No error message. No alert. They just stopped.
Investigating why surfaced a failure mode that, left unaddressed, could have caused a massive problem in production.
The app runs many import jobs simultaneously. Each one reports its progress to a status bar in the browser. That sounds trivial. It should be. This wasn’t.
Each progress update opens a database connection. When many jobs run at once, they all try to open connections simultaneously. When the connection pool ran out, the progress update failed. And when the progress update failed, it took the entire import job down with it.
A progress bar was killing data imports.
The job didn’t fail because the data was wrong, or because a service was unavailable. It failed because it couldn’t update a progress indicator that exists solely to reassure you that it is working. All because the app had been configured to expect far more database connections than it actually had available.
Here’s the insidious part: each retry incremented the progress counter with an update tick, but the job was still failing before it could finish. The counter stopped partway. The import looked like it had stalled mid-run. The completion job never fired. No error message anywhere.
This is why the automated imports were working, they never had to update a progress bar. Only the manual imports had that feature.
The fix was to treat the progress bar update as what it actually is: a nice-to-have. If the progress bar misses an update tick, that’s fine. The import pipeline should never be held hostage to a UI update tick. One rescue clause, and the problem is gone.
Remember where this all started?
The app was hitting memory limits on staging before it had handled a single request. Four fixes later, we finally found out why:
The import failure pointed us at the cable role – the cable is a separate database connection pool used exclusively for pushing live updates to the browser (like updating a progress bar). Fixing it properly meant giving it its own dedicated pool of connections, independent of the main database connections.
That work required doing something that apparently had never been done on this application: the connection maths.
How many processes is staging running? How many threads per process? How many database connections does each role actually need? Getting this wrong is one of the most common causes of database performance problems that don’t show up until real traffic arrives.
The staging database has a hard limit of 20 connections.
Working through the numbers we found that staging had been configured with production values, giving a theoretical maximum of 1,447 connections against that limit of 20.
The memory pressure was no longer a mystery. The import failures made perfect sense. Staging was doomed from the start. It was simply misconfigured.
We weren’t looking for this. We found it because we followed a thread that started with an S3 bucket migration. That’s the job.
We updated the values to something that actually works for the available capacity; a theoretical maximum of 43 connections. That’s still well over 20, but in practice staging never sees more than a couple of concurrent users and never approaches the theoretical maximum. It’s the most sensible configuration given the constraints, and it works. If you ever need to load test staging, you’d want to temporarily upgrade to a production configuration first, but for day-to-day use, this is fine.
Applying the same analysis to production found a time bomb
Having done the connection maths properly for staging, we applied the same analysis to production.
What we found: production’s database pool was configured to allow a theoretical maximum of 1,927 connections. The actual limit on that database was 200.
It was working because traffic is currently manageable. But a busy afternoon, an email campaign, a media mention: any of those could have triggered a scale-up event that pushed real connection usage past 200. The app would have hit a hard wall.
We updated the settings to bring the theoretical maximum to 147. There’s now 53 connections of safe headroom for traffic spikes. And with the app no longer trying to manage a pool of connections that never existed in the first place, every request got meaningfully faster.
Sometimes you really can do more with less.
That’s how we made an online book store noticeably faster by reducing its performance capacity.
Not a new server. Not a bigger plan. Not a performance overhaul.
A few configuration values that were wrong, corrected.
When you tell an app it has 1,927 connections available and it actually has 200, it spends every request managing a fantasy. It allocates memory for connections that don’t exist. It queues work against a pool it can never utilise. It’s using imaginary resources, to manage the real work it has to do. Tell it the truth, and it uses its expertly engineered resource management systems to work with what it actually has.
It allocates memory for connections using algorithms designed to manage resources efficiently. It queues work against a pool managed by algorithms that track every connection, every thread, every wait — down to the millisecond.
That’s where the speed came from; it was always there, the machine just had to be properly tuned to deliver the performance it was capable of.
This Is What Your Retainer Should Be Delivering
One observation. One thread. Four problems fixed.
A retainer isn’t just paying for features. It’s paying for someone whose job it is to keep pulling threads. Fix the presenting problem, yes. But also notice what’s sitting next to it. Follow it. Fix that too.
None of these problems announce themselves. That’s exactly why they need someone paying attention.
When did someone last actually look at your online store? Not just update it. Actually look at how well it is working.
MV Studio provides ongoing technical delivery for web platforms and ecommerce applications. If you’re not sure whether your platform is being actively maintained or just kept alive, get in touch.
Read more Technical Translation
Maintenance, Performance, Rails, Web Development
Previous NextThe Friday Deploy
The plain English newsletter for people who run the business, not the tech stack. No jargon. Every Friday.