Lowering Cost and Increasing Resilience with AWS Event-Driven & Lambda-Oriented Architecture
In many systems that have been running for a while, there’s a recurring pattern: there are several processes that rarely run, but there always has to be a server standing by to run them. The result? Resources allocated 24/7 for workloads that are actually only active for a few minutes per day. This isn’t a small technical problem — it’s a pattern that keeps draining operational costs and makes scaling harder.
This article discusses a consistent, repeatable architecture strategy for solving this problem: moving processes that don’t run continuously to AWS event-driven and Lambda-oriented architecture. Not just a technical optimization, but a design decision with direct impact on cost, system resilience, and team velocity.
The Big Picture Architecture
Before diving into the reasons and impacts, it’s important to understand the architecture pattern first. The core idea is simple:
graph TB
subgraph "Monolith (Core Business)"
M["Main Service<br/>Always-on, core API"]
end
subgraph "Lambda Service A — Scheduled Jobs"
EB["EventBridge<br/>(Trigger)"] --> LA["Lambda<br/>Cron / Scheduled Task"]
end
subgraph "Lambda Service B — Webhooks"
AG["API Gateway<br/>(Trigger)"] --> LB["Lambda<br/>Webhook Handler"]
end
subgraph "Lambda Service C — Async Processing"
SQ["SQS Queue<br/>(Trigger)"] --> LC["Lambda<br/>Background Worker"]
end
subgraph "Infra Management"
TF["Terraform<br/>1 repo = 1 service<br/>Infra + Code lifecycle"]
end
M -.->|"Emit events"| SQ
M -.->|"Emit events"| EB
ExtSystem["External System<br/>(Stripe, Shopee, etc.)"] --> AG
TF -.-|"Manages"| LA & LB & LC
style M fill:#bfdbfe,stroke:#2563eb,color:#000
style EB fill:#fef3c7,stroke:#d97706,color:#000
style AG fill:#fef3c7,stroke:#d97706,color:#000
style SQ fill:#fef3c7,stroke:#d97706,color:#000
style LA fill:#bbf7d0,stroke:#16a34a,color:#000
style LB fill:#bbf7d0,stroke:#16a34a,color:#000
style LC fill:#bbf7d0,stroke:#16a34a,color:#000
style TF fill:#f5f5f4,stroke:#78716c,color:#000
style ExtSystem fill:#f5f5f4,stroke:#78716c,color:#000Each Lambda service follows one consistent pattern: 1 repository = 1 logical service, with several Lambdas inside that have different triggers. One service can have one Lambda for scheduled jobs, one for webhooks, and one for async processing — all in one repo, one Terraform module, one team ownership.
The Problem Being Solved — Monoliths with Non-Continuous Load
Before talking about the solution, it’s necessary to understand the problem happening in the field.
Resources Always Active for Occasional Load
Many monoliths must always be alive with a certain capacity because of heavy processes that are rare but can’t be left unhandled: daily cron jobs consuming high CPU for 10 minutes, webhooks from payment gateways arriving sporadically, or background jobs only active when certain events happen.
As a result, servers are provisioned for peak capacity rather than average capacity — and the difference is money that keeps being paid even while resources are idle.
graph LR
subgraph "❌ Monolith: Idle Resources Still Being Paid"
Server["EC2 / ECS Instance<br/>Always active, always paid"]
Cron["Cron Job<br/>Active 10 minutes/day"]
Webhook["Webhook Handler<br/>Active when requests arrive"]
Worker["Async Worker<br/>Active when messages arrive"]
Server --> Cron & Webhook & Worker
Idle["CPU idle 90% of the time<br/>Memory allocated 100% of the time"]
Server --> Idle
end
style Server fill:#fecaca,stroke:#dc2626,color:#000
style Idle fill:#fecaca,stroke:#dc2626,color:#000
style Cron fill:#fef3c7,stroke:#d97706,color:#000
style Webhook fill:#fef3c7,stroke:#d97706,color:#000
style Worker fill:#fef3c7,stroke:#d97706,color:#000Coupling That Creates a Shared Failure Domain
More dangerous than cost is the failure domain that isn’t isolated. When a heavy cron job shares the database connection pool with the main API, and an async worker shares the deployment cycle with core business logic — one non-critical process can cause degradation across the entire system.
Inefficient Linear Scaling
In a monolith, scaling must happen as a whole. Adding an instance means all logic gets scaled, even though the only thing that actually needs scaling is one process. This is a structural waste that’s hard to fix from inside the monolith.
Design Principle — Pay Only for What Runs
This whole strategy rests on one simple but high-impact principle:
If a process doesn’t run continuously, don’t pay for resources as if it ran 24/7.
From here, processes in the system are classified by execution pattern, not just business domain.
| Process Type | Characteristics | Right Trigger |
|---|---|---|
| Scheduled / Event-based | Runs on schedule or triggered by events | EventBridge |
| Webhook / sporadic HTTP | Unstable traffic, unexpected bursts | API Gateway |
| Async background job | Can be delayed, doesn’t need real-time | SQS |
| Rare heavy compute | Expensive but rarely needed | Standalone Lambda |
| Always-on core API | Stable traffic, latency sensitive | ECS / EC2 (stays in the monolith) |
This classification matters: not every process fits Lambda. Processes with stable traffic and very latency-sensitive requirements still fit better on traditional servers. Lambda isn’t a replacement for all workloads — it’s the right place for non-continuous workloads.
Four Trigger Patterns Used
EventBridge + Lambda — Scheduled and Business Events
EventBridge is a much cleaner replacement for server cron jobs. No dedicated server is needed to maintain a schedule — EventBridge triggers Lambda exactly when needed, then the Lambda shuts down after finishing.
graph LR
EB1["EventBridge<br/>Schedule: every day at 02:00"]
EB2["EventBridge<br/>Business event: OrderShipped"]
L1["Lambda<br/>Daily Report Generator"]
L2["Lambda<br/>Post-shipment Notifier"]
EB1 --> L1
EB2 --> L2
style EB1 fill:#fef3c7,stroke:#d97706,color:#000
style EB2 fill:#fef3c7,stroke:#d97706,color:#000
style L1 fill:#bbf7d0,stroke:#16a34a,color:#000
style L2 fill:#bbf7d0,stroke:#16a34a,color:#000EventBridge also works well as an event bus between services — service A emits an event, services B and C react independently without needing to know each other. This is the right way to do loosely coupled integration between domains.
API Gateway + Lambda — Webhooks and Sporadic HTTP
Webhooks from payment gateways, shipping providers, or third-party services almost always have the same pattern: unpredictable arrival times, but must be processed reliably. This pattern is very expensive when handled by an always-on server.
graph LR
Stripe["Stripe Webhook"]
Shopee["Marketplace Webhook"]
Internal["Internal HTTP<br/>Admin trigger"]
AG["API Gateway"]
L3["Lambda<br/>Payment Event Handler"]
L4["Lambda<br/>Order Sync Handler"]
L5["Lambda<br/>Admin Action Handler"]
Stripe --> AG
Shopee --> AG
Internal --> AG
AG --> L3 & L4 & L5
style AG fill:#fef3c7,stroke:#d97706,color:#000
style L3 fill:#bbf7d0,stroke:#16a34a,color:#000
style L4 fill:#bbf7d0,stroke:#16a34a,color:#000
style L5 fill:#bbf7d0,stroke:#16a34a,color:#000With API Gateway + Lambda: no standby server, cost near zero when idle, and traffic spikes from external systems don’t break the core service.
SQS + Lambda — Async Processing with Full Isolation
SQS is the most powerful trigger for background processing because it provides automatic backpressure, built-in retries, and a Dead Letter Queue (DLQ) without extra effort.
graph LR
Monolith["Core Service"] -->|"Enqueue"| SQS["SQS Queue"]
SQS --> LC["Lambda Worker"]
LC -->|"Success"| Done["Process complete"]
LC -->|"Failure (3x retry)"| DLQ["Dead Letter Queue"]
DLQ --> Alert["CloudWatch Alert<br/>Manual investigation"]
style Monolith fill:#bfdbfe,stroke:#2563eb,color:#000
style SQS fill:#fef3c7,stroke:#d97706,color:#000
style LC fill:#bbf7d0,stroke:#16a34a,color:#000
style DLQ fill:#fecaca,stroke:#dc2626,color:#000
style Alert fill:#f5f5f4,stroke:#78716c,color:#000The advantage of this pattern: worker failures never reach the end user. If the Lambda fails, the message stays in the queue, gets retried per policy, and only enters the DLQ after retries are exhausted for investigation. The core service doesn’t know and doesn’t care whether the worker is failing.
Standalone Lambda — Rare Heavy Compute
For processes that are computationally expensive but only needed occasionally — large data transformations, complex report generation, or heavy external integrations — standalone Lambda is the most cost-efficient choice.
Cost Impact — More Than Just Savings
True Pay-Per-Use
The Lambda billing model is per request + per 100ms of execution time. There’s no concept of an “idle instance” — if there’s no execution, there’s no cost.
graph LR
subgraph "Cost Model: Monolith vs Lambda"
M_Cost["EC2 / ECS<br/>Cost = Hours × Resources<br/>Doesn't care whether there's traffic or not"]
L_Cost["Lambda<br/>Cost = Number of executions × Duration<br/>Zero when there are no executions"]
end
M_Cost -->|"Traffic spike"| M_Peak["Cost rises, needs scale-out"]
M_Cost -->|"Low traffic"| M_Idle["Cost stays, resources idle"]
L_Cost -->|"Traffic spike"| L_Peak["Cost rises proportionally"]
L_Cost -->|"Low traffic"| L_Idle["Cost drops toward zero"]
style M_Cost fill:#fecaca,stroke:#dc2626,color:#000
style M_Peak fill:#fecaca,stroke:#dc2626,color:#000
style M_Idle fill:#fecaca,stroke:#dc2626,color:#000
style L_Cost fill:#bbf7d0,stroke:#16a34a,color:#000
style L_Peak fill:#bbf7d0,stroke:#16a34a,color:#000
style L_Idle fill:#bbf7d0,stroke:#16a34a,color:#000Eliminating Over-Provisioning
In a monolith, provisioning is done for the worst case — peak traffic, peak concurrent jobs, peak memory. Lambda removes this need because scaling happens automatically and granularly. No capacity buffer for “just in case.”
A Leaner Monolith
An often-overlooked indirect impact: when non-critical processes move to Lambda, the monolith becomes smaller and more focused. Instances can be downgraded, memory allocation reduced, and the database connection pool is no longer burdened by background jobs.
| Metric | Before | After |
|---|---|---|
| Instance size (core service) | Large (carrying all the load) | Can be smaller |
| Occasional workload cost | Paid 24/7 | Paid per execution |
| Over-provisioning buffer | Needs a large margin | Not needed |
| Cost during low traffic | Stays high | Drops significantly |
Resilience Impact — True Isolation
Separate Failure Domains
This is the most significant resilience impact. When a Lambda fails — whatever the cause — the monolith and other services don’t know and aren’t affected.
graph TB
subgraph "❌ Without Isolation: Failure Spreads"
Mono["Monolith<br/>(Core API + Cron + Worker + Webhook)"]
Bug["Bug in a cron job"] -->|"Shared DB, shared memory"| Mono
Mono -->|"Core API also affected"| Down["Degradation / Downtime"]
end
subgraph "✅ With Isolation: Failure Contained"
CoreOK["Monolith<br/>(Core API only)"]
LambdaFail["Lambda Worker fails"] -->|"SQS DLQ, isolated retry"| Recover["Message enters DLQ<br/>Alert to team"]
CoreOK -->|"Not affected"| OK["Core API stays healthy"]
end
style Bug fill:#fecaca,stroke:#dc2626,color:#000
style Down fill:#fecaca,stroke:#dc2626,color:#000
style LambdaFail fill:#fef3c7,stroke:#d97706,color:#000
style Recover fill:#fef3c7,stroke:#d97706,color:#000
style CoreOK fill:#bbf7d0,stroke:#16a34a,color:#000
style OK fill:#bbf7d0,stroke:#16a34a,color:#000Built-in Retry and DLQ
SQS and EventBridge provide automatic retries with exponential backoff and a Dead Letter Queue without any additional implementation. This is fault tolerance that would normally take significant effort to build inside a monolith.
Auto-Scaling Without Capacity Planning
Lambda scales automatically based on concurrency — no scaling policy configuration needed, no min/max instance counts to set, no alarms to trigger scale-out. For bursty workloads (webhooks, async jobs), this is highly advantageous.
Team Impact — Clearer Ownership
One Repository, One Service, One Team
The 1 repository = 1 logical service pattern provides unambiguous ownership. Teams know exactly what they manage, what they can change without affecting other teams, and what needs coordination.
| Big Monolith | Lambda-Oriented (1 repo = 1 service) | |
|---|---|---|
| Deployment risk | One deploy affects everything | Deploy per service, risk isolated |
| Onboarding | Need to understand the whole system | Just understand one service |
| Ownership | Ambiguous, shared around | Clear per team / per repo |
| Rollback | Roll back everything or nothing | Roll back per service |
Safer Deployment
Changes to a Lambda service don’t require deploying the monolith. This reduces cross-team coordination overhead and enables more frequent deployments with smaller risk.
Golang as the Language Choice
Using Golang for Lambda isn’t a preference, but a technical decision that supports the business decision:
| Aspect | Golang | Node.js | Python |
|---|---|---|---|
| Cold start | Very fast (< 100ms) | Fast | Medium |
| Memory footprint | Low | Medium | Medium–high |
| Binary size | Small (single binary) | Large (node_modules) | Medium |
| Concurrency | Native goroutines | Event loop | Thread-based |
| Lambda cost | Cheaper (shorter runtime) | Medium | Medium |
For Lambda billed per 100ms, a faster runtime and lower memory directly reduce cost.
Trade-offs — What to Be Aware of from the Start
This strategy isn’t without cost. There are real trade-offs to recognize before committing to this approach.
Observability Complexity
With many small Lambda services, distributed tracing and centralized logging become a must, not an option. Without them, debugging cross-service problems becomes very hard.
graph LR
subgraph "Minimum Observability Stack"
CW["CloudWatch Logs<br/>(all Lambdas)"]
XRay["AWS X-Ray<br/>(distributed tracing)"]
Alarm["CloudWatch Alarms<br/>(DLQ depth, error rate)"]
Dashboard["CloudWatch Dashboard<br/>(unified view)"]
end
Lambda1["Lambda A"] & Lambda2["Lambda B"] & Lambda3["Lambda C"] --> CW & XRay
CW & XRay --> Dashboard
CW --> Alarm
style CW fill:#bfdbfe,stroke:#2563eb,color:#000
style XRay fill:#bfdbfe,stroke:#2563eb,color:#000
style Alarm fill:#fef3c7,stroke:#d97706,color:#000
style Dashboard fill:#bbf7d0,stroke:#16a34a,color:#000Cold Start
Lambdas that are rarely invoked will experience cold starts — runtime initialization before the first execution after an idle period. For Golang, cold starts are usually under 100ms and rarely a problem. For other runtimes or Lambdas with heavy dependencies, this can be more noticeable.
Available mitigation: Provisioned Concurrency for latency-sensitive Lambdas, though this removes part of the pay-per-use cost advantage.
Vendor Lock-in
This architecture is very AWS-specific: EventBridge, SQS, API Gateway, Lambda, DLQ — all AWS managed services. Moving to another cloud provider or on-premise would require significant effort. This needs to be recognized and accepted as a conscious decision, not avoided.
More Complex Local Development
Event-driven architecture is hard to simulate locally. Running the full EventBridge → Lambda → SQS → Lambda flow requires additional tooling (LocalStack, AWS SAM CLI, or a mocking layer). Teams need to invest time setting up a sufficiently representative development environment.
| Trade-off | Impact | Mitigation |
|---|---|---|
| Complex observability | Harder debugging | CloudWatch + X-Ray from the start |
| Cold start | Extra latency on first execution | Golang minimizes it, Provisioned Concurrency if needed |
| Vendor lock-in | Hard cloud migration | A conscious decision, clearly documented |
| Complex local dev | Slower development initially | LocalStack, SAM CLI, build testable abstractions |
When This Strategy Isn’t a Good Fit
There are conditions where this approach actually becomes a burden. It’s important to recognize these signals.
graph TD
Q1{"Does the workload run<br/>continuously with<br/>stable traffic?"}
Q2{"Is latency<br/>very critical<br/>(sub-10ms)?"}
Q3{"Is the logic<br/>very stateful and<br/>hard to event-drive?"}
Q4{"Does the team have<br/>capacity to manage<br/>distributed observability?"}
Q1 -->|"Yes"| NotFit1["Lambda fits less well<br/>ECS / EC2 is more efficient"]
Q1 -->|"No"| Q2
Q2 -->|"Yes"| NotFit2["Lambda fits less well<br/>Cold start is unacceptable"]
Q2 -->|"No"| Q3
Q3 -->|"Yes"| NotFit3["Needs deeper evaluation<br/>May need a redesign first"]
Q3 -->|"No"| Q4
Q4 -->|"No"| NotFit4["Hold off, invest in observability first<br/>Before adding more Lambdas"]
Q4 -->|"Yes"| Fit["This strategy fits"]
style NotFit1 fill:#fecaca,stroke:#dc2626,color:#000
style NotFit2 fill:#fecaca,stroke:#dc2626,color:#000
style NotFit3 fill:#fef3c7,stroke:#d97706,color:#000
style NotFit4 fill:#fef3c7,stroke:#d97706,color:#000
style Fit fill:#bbf7d0,stroke:#16a34a,color:#000In short, this strategy is less ideal when:
- Traffic is high and stable 24/7 — always-on servers are more cost-efficient
- Ultra-low latency is very critical — Lambda cold starts are unacceptable
- Logic is very stateful and hard to break into event-driven pieces
- The team isn’t ready or lacks tooling for distributed observability
Recommended Implementation Order
Don’t move everything at once. An evolutionary approach is far safer and allows validation at every stage.
graph LR
T1["Stage 1<br/>Identify non-continuous<br/>processes in the monolith"]
T2["Stage 2<br/>Set up observability first<br/>CloudWatch + X-Ray"]
T3["Stage 3<br/>Move the safest<br/>single process to Lambda"]
T4["Stage 4<br/>Validate: cost, reliability,<br/>is observability enough?"]
T5["Stage 5<br/>Repeat for the next<br/>process gradually"]
T1 --> T2 --> T3 --> T4
T4 -->|"Yes"| T5
T4 -->|"No"| Fix["Fix first before continuing"]
T5 --> T3
style T1 fill:#f5f5f4,stroke:#78716c,color:#000
style T2 fill:#bfdbfe,stroke:#2563eb,color:#000
style T3 fill:#fef3c7,stroke:#d97706,color:#000
style T4 fill:#fef3c7,stroke:#d97706,color:#000
style T5 fill:#bbf7d0,stroke:#16a34a,color:#000
style Fix fill:#fecaca,stroke:#dc2626,color:#000Setting up observability before moving the first process is a step that’s often skipped but very important. Without visibility into Lambda execution, logs, and error rates — problems that appear after migration will be very hard to debug.
Summary
- Core principle: if a process doesn’t run continuously, don’t pay for resources as if it ran 24/7. Lambda is the right place for non-continuous workloads — scheduled jobs, webhooks, async processing, and rare heavy compute.
- Four trigger patterns: EventBridge for scheduled and business events, API Gateway for webhooks/sporadic HTTP, SQS for async processing with built-in retry/DLQ, standalone Lambda for rare heavy compute.
- Repo pattern: 1 repository = 1 logical service, with several Lambdas inside having different triggers. The whole lifecycle is managed with Terraform.
- Cost: true pay-per-use — zero cost when idle, billing proportional to business activity, and a leaner monolith that no longer carries non-critical load.
- Resilience: real failure domain isolation — a failing Lambda doesn’t affect the core service. SQS provides retry and DLQ without extra implementation.
- Golang for Lambda: very fast cold starts, low memory footprint, shorter runtime = lower cost.
- Trade-offs to recognize: distributed observability becomes mandatory, cold starts exist (minimized with Golang), AWS vendor lock-in, and more complex local development.
- Gradual implementation: start with the safest single process, set up observability before moving the first process, validate at every stage before continuing.