Cost Engineering
GCP
The Frugal Approach to Google Cloud Functions Costs
Craig Conboy

With usage-billed compute services like Google Cloud Functions, cost optimization starts with your code. It's not just that applications run on the functions; applications spend. Every invocation, every configured gigabyte-second, every retry contributes to your bill.

A naming note before the walk-through: Cloud Functions (2nd gen) is now called Cloud Run functions—it runs on Cloud Run infrastructure and bills like it. The patterns below apply across generations; where behavior differs, it's called out.

Taking an application- and code-centric approach to cost reduction means understanding what your code costs before and after each invocation. Here's a practical walk-through of what to look for and how to optimize Cloud Functions costs at the code and configuration level.

Cost Trap Efficiency Pattern
Over-provisioned memory and CPU Right-size allocations to observed usage plus headroom
Excessive function timeout Set timeouts just above observed worst-case runtime
Synchronous function-to-function chains Decouple with Cloud Tasks, Pub/Sub, or Workflows
Idle minimum instances Size and schedule min instances to real traffic
Always-allocated CPU for spiky traffic Use request-based billing unless background work demands otherwise
Processing Pub/Sub messages one at a time Batch work per invocation
Unbounded event retries Cap retries, add dead-letter topics, make handlers idempotent
Verbose logging from functions Log less—Cloud Logging ingestion charges add up fast
Paying for idle wait Don't block on slow externals; hand off and call back
Cross-region data access Run functions in the same region as the data

Attribute the Costs

Start with your bill. Break down costs by function, service, and environment to understand where spend concentrates. Cloud Functions charges across several dimensions: invocations (~$0.40 per 1M after the free 2M), compute time billed separately for vCPU (~$0.000024 per vCPU-second) and memory (~$0.0000025 per GiB-second), and network egress (~$0.12/GB to the internet). Gen1 rounds duration up to the nearest 100 ms; on Cloud Run functions the CPU allocation setting decides whether you bill only during requests or for the instance's entire lifetime.

Don't stop at the functions line item. Functions generate downstream costs on other bills: Cloud Logging ingestion (~$0.50/GiB past the 50 GiB monthly free allotment) for everything written to stdout, Pub/Sub delivery, and cross-region data transfer to the databases and buckets your functions actually talk to.

Attribute costs down to specific functions and code paths. Which functions are over-provisioned on memory? Which keep minimum instances warm all weekend for Monday traffic? Which retry the same poison message ten thousand times? This granular attribution reveals which of the 10 efficiency patterns below deliver the highest impact.


Tackling Compute Time Costs

Compute cost = configured vCPU and memory × billed time. All three factors are in your control.

Right-size memory and CPU.

Don't pay full compute rates to wait.

Keep the timeout honest.

Right-size memory and CPU

Cost impact: Compute

Functions bill for configured resources, not used resources. A function configured at 2 GiB that peaks at 300 MiB pays for the other 1.7 GiB to sit empty on every invocation. Check actual usage in Cloud Monitoring (memory/used_bytes against the limit) before believing any memory setting:

# Bad: memory set by copy-paste from another function
# gcloud functions deploy ingest --memory=2048MB

# Good: observed peak plus headroom
# gcloud functions deploy ingest --memory=512MB

On gen1, CPU is fixed to the memory tier, so oversizing memory silently oversizes (and overbills) CPU too. On Cloud Run functions, vCPU is configurable separately—but be careful shrinking CPU on concurrency-enabled services, since the utilization metric is shared across concurrent requests and can make a busy instance look idle. Memory is the safe knob; treat CPU reductions as advisory until you've measured under real concurrency.

Benefit: 20-60% reduction in compute costs for over-provisioned functions.

Don't pay to wait

Cost impact: Compute

Functions bill wall-clock time. A function that blocks 20 seconds on a slow third-party API pays for 20 seconds of vCPU and memory to do nothing:

# Bad: poll-and-sleep inside the function (billed the whole time)
def handler(request):
    job_id = start_render(request.json['doc'])
    while not render_complete(job_id):
        time.sleep(5)          # you are paying for this sleep
    return fetch_result(job_id)
# Good: hand off and let the callback resume the workflow
def start_handler(request):
    start_render(request.json['doc'], callback_url=CALLBACK_URL)
    return ('queued', 202)     # function exits; billing stops

def callback_handler(request):
    # invoked by the render service's completion webhook
    store_result(request.json)
    return ('ok', 200)

For multi-step processes, Cloud Workflows charges per step, not per second—waiting is nearly free there and expensive inside a function. Cloud Tasks gives you the same hand-off for queued work with managed retry.

Benefit: Eliminates billed idle time; long waits drop from function duration to ~$0.

Keep the timeout honest

Cost impact: Risk containment

Teams set the maximum timeout "to be safe" on functions whose worst observed runtime is 4 seconds. The timeout is the cap on what a stuck invocation can cost you—with a 540-second gen1 timeout, a hung dependency turns every invocation into 135x normal spend until someone notices.

# Bad: maximum "just in case" (observed p100 runtime: 4s)
gcloud functions deploy checkout --timeout=540s

# Good: worst-case plus margin
gcloud functions deploy checkout --timeout=15s

One behavioral note: on Cloud Run functions the container survives the timeout's 504—so a tight timeout there bounds client-visible latency (fail fast) more than it caps compute. Either way, a timeout tracking reality converts a silent runaway into a visible error.

Benefit: Bounds worst-case spend per invocation; direct savings are $0 until the bad day, at which point they're the whole point.


Tackling Invocation & Orchestration Costs

How functions call each other matters as much as what they do.

Break synchronous invocation chains.

Batch event processing.

Bound retries.

Break synchronous invocation chains

Cost impact: Compute (double-billed)

When function A makes a blocking HTTPS call to function B and waits for the result, you pay for A's compute during B's entire execution. Chain three functions and the top of the chain pays three times:

# Bad: synchronous chain — A is billed while B runs
def handler_a(request):
    resp = requests.post(
        'https://region-project.cloudfunctions.net/function-b',
        json=payload,
        timeout=60
    )                          # A blocks and bills for all of B's runtime
    return process(resp.json())
# Good: decouple with Pub/Sub when A doesn't need the answer
def handler_a(request):
    publisher.publish(topic_path, json.dumps(payload).encode())
    return ('queued', 202)

# function-b subscribes to the topic and runs on its own bill

When A genuinely needs B's result, that's a workflow, not a function call—use Cloud Workflows to sequence the steps, or Cloud Tasks for queued hand-offs. And if B is just shared code, make it a library instead of a network hop you pay for twice.

Benefit: Eliminates double/triple billing on chained invocations.

Batch event processing

Cost impact: Invocations and Compute

A Pub/Sub-triggered function fires once per message. At 50M messages a month, that's 50M invocation charges plus 50M rounds of per-invocation overhead—connection setup, client init, the works:

# Bad: one message, one invocation, one database write
def handler(event, context):
    record = json.loads(base64.b64decode(event['data']))
    db.insert(record)          # 50M single-row inserts/month
# Good: a scheduled function drains the subscription in batches
def handler(request):
    sub = subscriber.subscription_path(PROJECT, 'jobs-sub')
    while True:
        resp = subscriber.pull(subscription=sub, max_messages=1000)
        if not resp.received_messages:
            break
        records = [json.loads(m.message.data) for m in resp.received_messages]
        db.insert_many(records)                    # 1,000 rows per round-trip
        subscriber.acknowledge(
            subscription=sub,
            ack_ids=[m.ack_id for m in resp.received_messages]
        )

Run it from Cloud Scheduler at a frequency that matches how fresh the data needs to be. Latency-tolerant, high-volume streams don't need per-message invocation; that's a real-time luxury applied to a batch problem. (On Cloud Run functions, raising per-instance --concurrency for I/O-bound HTTP workloads achieves a similar effect—more requests share one instance's billed time.)

Benefit: 100-1000x fewer invocations for high-volume streams; batched downstream writes as a bonus.

Bound retries

Cost impact: Invocations and Compute (unbounded)

Event-driven functions with retry enabled redeliver failed events for up to 7 days. A poison message hitting a non-idempotent handler doesn't fail once—it fails continuously, at your expense, all week:

# Bad: retry forever, no dead end for poison messages
gcloud functions deploy process-order --trigger-topic=orders --retry
# Good: bounded retry with a dead-letter topic
gcloud pubsub subscriptions update orders-sub \
  --dead-letter-topic=orders-dlq \
  --max-delivery-attempts=5
# And make the handler idempotent so retries are safe, not compounding
def handler(event, context):
    msg_id = context.event_id
    if already_processed(msg_id):      # cheap idempotency check
        return
    process(event)
    mark_processed(msg_id)

Discard non-retryable errors (bad input will still be bad input on attempt 5,000) by catching them and returning success, so retry capacity is spent only on genuinely transient failures.

Benefit: Converts a week of unbounded failure cost into five attempts and a visible dead-letter queue.


Tackling Idle Capacity Costs

Size minimum instances to real traffic

Cost impact: Instance time

Minimum instances keep containers warm to eliminate cold starts—and bill continuously, traffic or not. Teams set them during a latency scare and never revisit. Ten min instances at 1 GiB, kept warm for a month, cost real money before serving a single request; if traffic is business-hours-only, two-thirds of that spend buys warmth for nobody.

# Bad: launch-day setting, never revisited
gcloud functions deploy checkout --min-instances=10   # weekend traffic: 0

# Good: sized to what cold-start latency actually costs you
gcloud functions deploy checkout --min-instances=2

For strongly cyclical traffic, schedule the floor: a Cloud Scheduler job can raise min instances at 8am and drop them to zero at 7pm, the same way you'd schedule any other capacity.

Benefit: Cuts idle instance spend to match the hours cold starts actually matter.

Use request-based billing for spiky traffic

Cost impact: Instance time

Cloud Run functions offer two billing modes: request-based (CPU allocated only while serving requests) and instance-based (CPU always allocated, billed for the instance's entire lifetime at a lower rate). Always-allocated CPU is the right call for sustained traffic or background work outside the request path. For a spiky, request-driven function it means paying for every idle minute between requests:

# Bad: always-allocated CPU on a function idle 90% of the time
gcloud run services update my-function --no-cpu-throttling

# Good: pay only while serving requests
gcloud run services update my-function --cpu-throttling

The break-even is utilization: instances busy most of the time favor instance-based billing; instances that mostly wait favor request-based. Measure, don't guess.

Benefit: Stops billing idle time between requests on spiky workloads.


Tackling Downstream Costs

Log less from functions

Cost impact: Cloud Logging (adjacent bill)

Everything a function writes to stdout lands in Cloud Logging, which charges ~$0.50/GiB for ingestion past the 50 GiB monthly free allotment. A function printing a 2 KB debug payload on each of 50M monthly invocations generates ~100 GiB of ingestion—a logging bill that can exceed the compute bill of the function producing it.

# Bad: debug-everything in production
def handler(event, context):
    print(f"EVENT: {json.dumps(event)}")          # full payload, every call
    result = process(event)
    print(f"RESULT: {json.dumps(result)}")
    return result

# Good: structured, leveled, and quiet by default
logger = logging.getLogger()
logger.setLevel(os.environ.get("LOG_LEVEL", "WARNING"))

def handler(event, context):
    result = process(event)
    logger.info("processed", extra={"id": event["id"], "ms": result.elapsed})
    return result

Back it up with an exclusion filter on the _Default sink for known-noisy patterns (health checks, framework chatter), and set bucket retention to match how long you actually query function logs.

Benefit: 80-95% reduction in log ingestion costs for chatty functions.

Run functions in the same region as the data

Cost impact: Network egress

A function in us-central1 reading from a Cloud SQL instance or GCS bucket in europe-west1 pays cross-region transfer on every byte, on every invocation—plus latency that inflates billed duration for free:

# Bad: function deployed to us-central1, data in europe-west1
# every read crosses an ocean and a billing meter

# Good: co-locate function and data
# gcloud functions deploy report-gen --region=europe-west1

Same-region traffic to Google APIs is free; cross-region is not. When data must live elsewhere, move the computation to the data rather than the data to the computation—deploy the function per-region, or aggregate server-side (a SQL query returning 50 rows beats shipping 5 GB to filter in the function).

Benefit: Eliminates cross-region transfer charges and shaves billed latency on every invocation.


Closing Thoughts

Cost-effective Cloud Functions requires two steps. First, let observed costs guide what needs optimization. Attribute your bill down to specific functions and code paths—which are over-provisioned on memory, which chain synchronously, which keep instances warm all weekend, which have retried the same poison message since Tuesday. This granular attribution reveals which of the 10 efficiency patterns above matter most for your workloads. Second, apply the optimizations that address your cost concentrations: right-size memory and CPU (20-60% compute savings), break synchronous chains (eliminates double billing), batch high-volume event processing (100-1000x fewer invocations), bound retries before a bad message runs for a week, size minimum instances and billing mode to real traffic, quiet down logging, and co-locate functions with their data.

You don't lose performance or reliability. You gain precision. Serverless made compute a per-invocation purchasing decision—treat each configuration flag as having a price tag and optimize based on measured impact.

Looking for help with cost optimizations like these? Book a Demo or Explore the Sandbox to see Frugal in action. Frugal attributes Google Cloud Functions costs to your code, finds inefficient usage, provides Frugal Fixes that reduce your bill, and helps keep you out of cost traps for new and changing code.

Back to Top
background Image
Robo Image

Take Frugal for a live test drive

Explore how Frugal scans your code and cloud services to find waste, recommend optimizations, and generate ready-to-use fixes in a secure, read-only environment.
background Image
robo image