Lowering Cost and Increasing Resilience with AWS Event-Driven & Lambda-Oriented Architecture
12 min read

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:#000

Each 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:#000

Coupling 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 TypeCharacteristicsRight Trigger
Scheduled / Event-basedRuns on schedule or triggered by eventsEventBridge
Webhook / sporadic HTTPUnstable traffic, unexpected burstsAPI Gateway
Async background jobCan be delayed, doesn’t need real-timeSQS
Rare heavy computeExpensive but rarely neededStandalone Lambda
Always-on core APIStable traffic, latency sensitiveECS / 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:#000

EventBridge 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:#000

With 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:#000

The 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:#000

Eliminating 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.

MetricBeforeAfter
Instance size (core service)Large (carrying all the load)Can be smaller
Occasional workload costPaid 24/7Paid per execution
Over-provisioning bufferNeeds a large marginNot needed
Cost during low trafficStays highDrops 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:#000

Built-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 MonolithLambda-Oriented (1 repo = 1 service)
Deployment riskOne deploy affects everythingDeploy per service, risk isolated
OnboardingNeed to understand the whole systemJust understand one service
OwnershipAmbiguous, shared aroundClear per team / per repo
RollbackRoll back everything or nothingRoll 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:

AspectGolangNode.jsPython
Cold startVery fast (< 100ms)FastMedium
Memory footprintLowMediumMedium–high
Binary sizeSmall (single binary)Large (node_modules)Medium
ConcurrencyNative goroutinesEvent loopThread-based
Lambda costCheaper (shorter runtime)MediumMedium

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:#000

Cold 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-offImpactMitigation
Complex observabilityHarder debuggingCloudWatch + X-Ray from the start
Cold startExtra latency on first executionGolang minimizes it, Provisioned Concurrency if needed
Vendor lock-inHard cloud migrationA conscious decision, clearly documented
Complex local devSlower development initiallyLocalStack, 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:#000

In 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

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:#000

Setting 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.

Portfolio