Let's be honest, a large, ten-year-old application legacy is a little scary. You know the kind of project I mean. It’s the kind of codebase where altering the billing module somehow breaks the user profile page. Deployments are a gamble. We spend Friday afternoons sweating over logs on AWS CloudWatch.
Sound familiar?
When a monolith program is outgrowing a single EC2 instance, the default response is to burn it down and rewrite everything as microservices. To be fair, starting from fresh sounds too good to be true. But big-bang rewrites almost always blow up.
The secret to a successful AWS move isn’t a rewrite. It’s a smart piece-by-piece extraction. And to achieve so you have to fix the biggest toxic attribute of the monolith: application state.
In this article I’ll show you how to take a vulnerable, closely linked software and turn it into a robust, stateless distributed system on AWS.
Why State Ruins Scale
To tear a monolith apart, you have to go find where it is holding information.
Imagine a local coffee shop where the bartender knows each customer’s favorite drink by heart. If that bartender takes a lunch break, the whole thing falls apart since the “state” (memory of the orders) is locked up in their head.
Servers do that all the time. If your software saves data in its own local memory, you can't scale out.
The Local Memory Trap
Suppose you’re writing a rate limiter in an effort to prevent abuse of an API. In a single server arrangement, maintaining request counts in memory seems like an easy win.
Here’s the form that harmful anti-pattern takes:
// BAD: Storing state in the local EC2 server's memory
const userRequestCounts = {};
function checkRateLimit(userId) {
if (!userRequestCounts[userId]) {
userRequestCounts[userId] = 0;
}
userRequestCounts[userId]++;
if (userRequestCounts[userId] > 100) {
throw new Error("Rate limit exceeded");
}
}
Here’s the thing: the second you install an Application Load Balancer (ALB) in front of two servers, your rate limiter is broken. A user can request Server A 100 times, and Server B 100 times, totally ignoring your limitations.
Moving State to ElastiCache
The answer is to make your application stateless. The servers should not store any local memory. Instead, all of them should point to a blazing fast external data store such as Amazon ElastiCache (Redis).
// GOOD: Using Amazon ElastiCache (Redis)
const redis = require('redis');
// Point this to your ElastiCache cluster endpoint
const client = redis.createClient({ url: 'redis://your-elasticache-endpoint' });
async function checkRateLimit(userId) {
const key = `rate_limit:${userId}`;
// Increment the count in the shared AWS cache
const currentCount = await client.incr(key);
// Set a 1-minute expiration if it's a new window
if (currentCount === 1) {
await client.expire(key, 60);
}
if (currentCount > 100) {
throw new Error("Rate limit exceeded");
}
}
Now, it doesn’t matter if your Auto Scaling Group spins up one server or one hundred. They all have the same exact "brain".
Ditching Sticky Sessions
Sticky sessions at the load balancer level is one classic indication of a stateful monolith.
If you store large user objects in the server local session memory then your AWS Load Balancer will have no option but to redirect the user back to the same EC2 instance on every single request.
If that instance is terminated due to a scale-in or deployment, the user is immediately logged out. Quick rolling deployments can into a customer care nightmare.
Going Stateless
To solve this problem, we need to cease thinking of the server as a storage store. Make your session cookies nice and thin. Simply store a User ID and pull the rest of the info on the fly.
# The Stateless Way: Store only the ID in the session
@app.route('/dashboard')
def dashboard():
# 1. Grab the lightweight ID from the session cookie
user_id = session.get('user_id')
if not user_id:
return redirect('/login')
# 2. Fetch the complex data from Amazon RDS on demand
current_user = db.users.find_by_id(user_id)
return render_template('dashboard.html', user=current_user)
Your load balancer can equally balance traffic by isolating your session data from the physical server. You can spin up new containers on AWS Fargate at 2 PM on a Tuesday, drain off old tasks gracefully, and your users won’t notice a hiccup.
Decoupling with SQS
You might be thinking, okay, now you've gotten rid of local state, which section of the app do you extract first?
The best possibilities are the ones that do not require a prompt answer. Think emails, push notifications or background data processing.
The Synchronous Bottleneck
Legacy systems typically run in a sequential fashion. The server attempts to accomplish everything if a user buys a product, before telling the user "Success!"
// Synchronous Monolith: Slow and easily broken
async function completePurchase(order) {
await database.save(order);
// The user has to wait while we talk to Amazon SES!
await emailService.sendReceipt(order.userEmail, order.total);
// The user is STILL waiting while we update a reporting dashboard...
await reportingService.updateMetrics(order);
return "Order Complete!";
}
If the email API is having a slow day then your user is left looking at a frozen loading spinner.
Enter Async Events
To break this barrier we introduce Amazon Simple Queue Service (SQS) or Amazon EventBridge.
The main application doesn't handle the heavy lifting, it only shouts "Hey, an order happened!" into a SQS queue and quickly gives a speedy answer to the user.
// Asynchronous Approach with AWS SQS: Lightning fast
const AWS = require('aws-sdk');
const sqs = new AWS.SQS();
async function completePurchase(order) {
// 1. Save core data to RDS
await database.save(order);
// 2. Publish an event to SQS and move on
await sqs.sendMessage({
QueueUrl: process.env.ORDER_QUEUE_URL,
MessageBody: JSON.stringify({
orderId: order.id,
email: order.userEmail,
amount: order.total
})
}).promise();
// 3. Return instantly!
return "Order Complete!";
}
Somewhere else in your AWS infrastructure, a distinct AWS Lambda function or independent microservice is listening on that queue. It processes the message and sends the email at its own time.
Handling Cloud Failures
Now here’s where it gets interesting. They are very powerful but also chaotic. Cloud distributed systems. Networks fail, APIs time out, and queues may transmit the same message again.
You've got to build on the defensive side.
The Idempotency Rule
Idempotent is a fancy name for something simple. It means “If I run this exact same command ten times, then the end result should be exactly the same as running it once.”
Because SQS guaranties at-least-once delivery, your worker may receive the identical OrderCompleted event twice. Be careful, otherwise you may be emailing the consumer two receipts.
So every event needs a unique ID and your worker needs to look in ElastiCache or DynamoDB to see if it has previously processed the event before it does anything.
Catching Bugs with DLQs
Sometimes the message breaks down at the core. Since the payload does not contain an email address, your Lambda function crashes totally.
SQS will attempt to redeliver the message which will cause an unending cycle of crashes.
To solve this, configure a Dead Letter Queue (DLQ) in AWS. You have a redrive policy: “If this message fails 3 times, take it off of the main queue and put it into the DLQ.” The DLQ is a separate ward for problematic tasks, so your system can continue to run while you configure CloudWatch alarms to examine the broken payload.
Managing Microservice Data
At the end of the day, as you pull additional services, you'll meet the ultimate boss of microservices: data consistency.
The Saga Pattern
It's all in a single database, a monolith. If it gets part way through a complicated transaction, the database immediately rolls everything back.
In a microservices architecture, your SubscriptionService and BillingService have their own databases. What happens if Billing charges the credit card correctly, but Subscription fails to create the account?
You need the Saga Pattern (usually coordinated with AWS Step Functions or EventBridge).
A Saga is a series of events with “undo” buttons built in. If a downstream service fails, it sends a failure event backward which triggers a compensation transaction (like a refund). It is like booking a vacation, you successfully booked a flight and the hotel booking failed, the system needs to trigger a cancelation of the flight automatically.
Faster Reads with CQRS
And last, as your services fall down, it’s a pain to generate reports. Joining tables across 5 separate microservice databases is hard.
The solution is CQRS (Command Query Responsibility Segregation).
Whenever a service accomplishes anything of importance, a reporting worker listens to those events and updates a completely flat, disorganized database such as Amazon DynamoDB. This DynamoDB table is meant to be easily accessed by your frontend dashboards. Heavy analytical queries should not be run on your primary transactional databases.
Key Takeaways
Audit your state first: Before you do anything else, find where your program is hiding local memory, then move it into Amazon ElastiCache.
Clean up your sessions: Don't store huge items in cookies. Store lightweight IDs and fetch data on the fly so your Application Load Balancers don’t need to rely on sticky sessions.
Extract the easy stuff: Begin by moving async work like email and web hooks off the monolith to Amazon SQS.
Build defensively: Wrap your background workers with idempotency checks so duplicate events don’t corrupt user data. Always attach Dead Letter Queues to your SQS queues.
Embrace eventual consistency: Use Saga pattern with EventBridge or Step Functions to manage rollbacks across several microservices. Use CQRS with DynamoDB to keep your read-heavy dashboards fast.
Conclusion
Bottom line? Moving from a monolith on AWS is a marathon, not a sprint. There’s no need to stop feature development and redo all of the code tomorrow.
The short version – concentrate on statelessness and event driven design and you can gently grasp the monolith. You will gradually reduce bottlenecks, your architecture will scale gracefully, and you will finally reclaim your Friday afternoons without worry of breaking production.
About the Author
As an AWS Community Builder, I enjoy sharing the things I've learned through my own experiences and events, and I like to help others on their path. If you found this helpful or have any questions, don't hesitate to get in touch! 🚀
🔗 Connect with me on LinkedIn

Top comments (0)