---
title: 'Resource and Duration Limits'
source: 'https://academia.sh/en/courses/serverless/resource-and-duration-limits'
course: 'Serverless and Edge Computing'
language: en
updated: '2026-09-13T05:07:40+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Applies three thresholds to a job set and counts how many jobs run into which threshold'
    - 'Scans memory tiers to show duration and unit cost moving in opposite directions, with the cheapest point in the middle'
    - 'Measures the throttle rate, the lost requests, and the queue behind them once the concurrency limit is exceeded'
    - 'Counts how sharing the limit at the account level starves one function, and the cost of a reserved share'
---

# Resource and Duration Limits

Three thresholds — memory, duration, and concurrency — are applied to a set of twelve jobs, and how many jobs do not fit is counted; scanning memory tiers shows duration dropping while unit cost draws a valley, with the cheapest point landing in the middle; when the concurrency limit is shared at the account level, one function starving another is measured with the throttle rate and the lost requests.

The same numbers sat in the background across three lessons: the 900-second duration limit, the
memory tiers, and the concurrency limit of 200. This lesson moves all three to the center of
measurement, because this is exactly what handing off capacity management buys — the capacity
decision is gone, and three numbers have arrived in its place.

The constraint shows up in two forms: some jobs do not fit the threshold at all and must be split,
some jobs fit but cannot run in their cheapest or fastest form. Both are countable.

**FC19.** The thresholds are **fictional**: the memory cap is 3008 MB, the duration limit is 900
seconds, the account-level concurrency limit is 200. **FC20.** The measurement network's twelve
fictional jobs are listed with memory, duration, and peak concurrency values; durations are at the
1024 MB tier. **FC21.** Processor share is proportional to memory: one unit at 256 MB, doubling at
each tier, and capping at eight at 2048 MB. **FC22.** If a job's working set does not fit the
chosen memory, garbage collection time is added; the coefficient is 0.15, and below 256 MB the job
drops out from insufficient memory. **FC23.** In the concurrency model the night job is split into
900 pieces, each piece is about 245 seconds, and the deviation comes from a self-written generator,
seed 20260805; field notification is 25 requests per second. **FC24.** A throttled notification
request retries one second later, and is lost after the third attempt; prices are in fictional
units.

## Three Thresholds, Twelve Jobs

```js
// measurement-network/job-set.mjs — three thresholds are applied to a job set, how many jobs do not fit (model)

export const MEMORY_CAP = 3008, DURATION_LIMIT = 900, CONCURRENCY_LIMIT = 200;   // MB, s, concurrent calls

// The regional measurement network's fictional jobs: [name, working set MB, single run s, peak concurrency]
export const JOB = [
  ["reading validation", 180, 0.4, 148],
  ["field notification", 120, 0.3, 25],
  ["branch endpoint report", 300, 45, 8],
  ["daily summary", 512, 60, 3],
  ["billing generation", 900, 240, 40],
  ["nightly batch job (single piece)", 700, 220600, 1],
  ["nightly batch job (246 pieces)", 700, 897, 246],
  ["anomaly scan", 2200, 700, 12],
  ["archive compression", 1400, 1100, 6],
  ["monthly billing close", 2600, 1500, 4],
  ["meter calibration report", 3400, 420, 2],
  ["meter visual inspection", 5200, 95, 3],
];

export const check = ([name, mb, s, cc]) => {
  const exceeded = [];
  if (mb > MEMORY_CAP) exceeded.push("memory");
  if (s > DURATION_LIMIT) exceeded.push("duration");
  if (cc > CONCURRENCY_LIMIT) exceeded.push("concurrency");
  return { name, mb, s, cc, exceeded };
};

// The workaround path for a job that does not fit, and its kind of cost.
export const WORKAROUND = {
  memory: (i) => `externalize state: ${Math.ceil(i.mb / MEMORY_CAP)} pieces and ${i.mb - MEMORY_CAP} MB external read`,
  duration: (i) => `split: ${Math.ceil(i.s / DURATION_LIMIT)} calls and ${Math.ceil(i.s / DURATION_LIMIT) - 1} progress records`,
  concurrency: (i) => `throttle: ${i.cc - CONCURRENCY_LIMIT} calls wait, ${Math.ceil(i.cc / CONCURRENCY_LIMIT)} rounds`,
};

if (import.meta.url === `file://${process.argv[1]}`) {
  console.log(`thresholds: memory ${MEMORY_CAP} MB, duration ${DURATION_LIMIT} s, concurrency ${CONCURRENCY_LIMIT}`);
  console.log("\n" + "job".padEnd(34) + "memory".padEnd(10) + "duration".padEnd(11) + "concurrency".padEnd(14) + "result");
  const d = JOB.map(check);
  for (const i of d)
    console.log(i.name.padEnd(34) + `${i.mb} MB`.padEnd(10) + `${i.s} s`.padEnd(11) +
      String(i.cc).padEnd(14) + (i.exceeded.length ? `exceeds ${i.exceeded.join(" + ")}` : "fits"));
  const unfit = d.filter((i) => i.exceeded.length);
  const count = (e) => d.filter((i) => i.exceeded.includes(e)).length;
  console.log(`\n${unfit.length} of ${JOB.length} jobs do not fit: memory ${count("memory")}, ` +
    `duration ${count("duration")}, concurrency ${count("concurrency")} (a job can exceed more than one threshold)`);
  console.log("\n" + "unfit job".padEnd(34) + "exceeded threshold".padEnd(20) + "workaround path and cost");
  for (const i of unfit)
    for (const e of i.exceeded) console.log(i.name.padEnd(34) + e.padEnd(20) + WORKAROUND[e](i));
}
```

```
thresholds: memory 3008 MB, duration 900 s, concurrency 200

job                               memory    duration   concurrency   result
reading validation                180 MB    0.4 s      148           fits
field notification                120 MB    0.3 s      25            fits
branch endpoint report            300 MB    45 s       8             fits
daily summary                     512 MB    60 s       3             fits
billing generation                900 MB    240 s      40            fits
nightly batch job (single piece)  700 MB    220600 s   1             exceeds duration
nightly batch job (246 pieces)    700 MB    897 s      246           exceeds concurrency
anomaly scan                      2200 MB   700 s      12            fits
archive compression               1400 MB   1100 s     6             exceeds duration
monthly billing close             2600 MB   1500 s     4             exceeds duration
meter calibration report          3400 MB   420 s      2             exceeds memory
meter visual inspection           5200 MB   95 s       3             exceeds memory

6 of 12 jobs do not fit: memory 2, duration 3, concurrency 1 (a job can exceed more than one threshold)

unfit job                         exceeded threshold  workaround path and cost
nightly batch job (single piece)  duration            split: 246 calls and 245 progress records
nightly batch job (246 pieces)    concurrency         throttle: 46 calls wait, 2 rounds
archive compression               duration            split: 2 calls and 1 progress records
monthly billing close             duration            split: 2 calls and 1 progress records
meter calibration report          memory              externalize state: 2 pieces and 392 MB external read
meter visual inspection           memory              externalize state: 2 pieces and 2192 MB external read
```

Six of the twelve jobs do not fit: three exceed duration, two exceed memory, one exceeds the
concurrency limit. The lower table writes the cost of the workaround in its own unit — because the
nightly batch job takes 220,600 seconds in one piece, it is split into **246 calls and 245 progress
records**. The "where was I" information that is free inside a single job turns, once split, into a
line item kept outside and read and written on every call.

Two rows are the same job's two states. The night job runs into the duration limit in one piece;
splitting it into 246 pieces escapes that but runs into the **concurrency limit**. Working around
one threshold moves the job into another: 246 pieces fit the 200 limit in two rounds, and 46 calls
wait for the first round.

The two jobs that exceed the memory cap take a different workaround path: externalizing state.
Meter visual inspection asks for 5,200 MB, the cap is 3,008 MB, and the 2,192 MB in between is read
externally on every run — trading memory access for network access.

## Memory Tier Scan

Memory is not a limit on its own; it also decides the processor share. So increasing memory
shortens duration, and unit cost becomes the product of two opposing forces.

```js
// measurement-network/memory-scan.mjs — as memory grows duration drops, unit cost first drops then rises (model)
import { DURATION_LIMIT } from "./job-set.mjs";

export const TIER = [128, 256, 512, 768, 1024, 1536, 2048, 3008];   // MB, fictional tiers
export const BASE_MB = 256, CAP_SHARE = 8;       // processor share is proportional to memory, caps at 2048
export const WORKING_SET = 900, GC_FACTOR = 0.15, LOWER_LIMIT = 256;
export const SERIAL = 4000, PARALLEL = 944000;   // ms: the serial and parallel portion of billing generation
export const GB_S = 0.001, CALL_UNIT = 0.0005;   // fictional unit

export const share = (mb) => Math.min(mb / BASE_MB, CAP_SHARE);
export const gc = (mb) => (mb >= WORKING_SET ? 0 : (WORKING_SET / mb - 1) ** 2 * GC_FACTOR * PARALLEL);
export const run = (mb) => {
  if (mb < LOWER_LIMIT) return { mb, fits: false, reason: "not enough memory" };
  const duration = (SERIAL + PARALLEL / share(mb) + gc(mb)) / 1000;
  return { mb, fits: duration <= DURATION_LIMIT, reason: duration > DURATION_LIMIT ? "duration limit" : "",
    duration, share: share(mb), gc: gc(mb) / 1000,
    cost: (mb / 1024) * duration * GB_S + CALL_UNIT };
};

if (import.meta.url === `file://${process.argv[1]}`) {
  console.log(`billing generation: working set ${WORKING_SET} MB, parallel portion ` +
    `${PARALLEL / 1000} s (at ${BASE_MB} MB), processor share cap ${CAP_SHARE}`);
  console.log("\n" + "memory".padEnd(10) + "processor share".padEnd(17) + "garbage collection".padEnd(20) +
    "duration".padEnd(12) + "unit cost".padEnd(16) + "result");
  const s = TIER.map(run);
  for (const x of s)
    console.log(`${x.mb} MB`.padEnd(10) + (x.fits === false && !x.duration ? "—".padEnd(17) + "—".padEnd(20) +
      "—".padEnd(12) + "—".padEnd(16) : x.share.toFixed(2).padEnd(17) + `${x.gc.toFixed(1)} s`.padEnd(20) +
      `${x.duration.toFixed(1)} s`.padEnd(12) + x.cost.toFixed(4).padEnd(16)) +
      (x.fits ? "fits" : x.reason));
  const fitting = s.filter((x) => x.fits);
  const cheapest = fitting.reduce((a, b) => (b.cost < a.cost ? b : a));
  const fastest = fitting.reduce((a, b) => (b.duration < a.duration ? b : a));
  const largest = fitting[fitting.length - 1];
  console.log(`\ncheapest tier ${cheapest.mb} MB: ${cheapest.duration.toFixed(1)} s, ${cheapest.cost.toFixed(4)} units`);
  console.log(`fastest tier ${fastest.mb} MB: ${(cheapest.duration / fastest.duration).toFixed(2)}x faster, ` +
    `${((fastest.cost / cheapest.cost - 1) * 100).toFixed(1)}% more expensive`);
  console.log(`largest tier ${largest.mb} MB: same duration, ` +
    `${((largest.cost / cheapest.cost - 1) * 100).toFixed(0)}% more expensive — because the processor share hits its cap`);
}
```

```
billing generation: working set 900 MB, parallel portion 944 s (at 256 MB), processor share cap 8

memory    processor share  garbage collection  duration    unit cost       result
128 MB    —                —                   —           —               not enough memory
256 MB    1.00             896.1 s             1844.1 s    0.4615          duration limit
512 MB    2.00             81.3 s              557.3 s     0.2792          fits
768 MB    3.00             4.2 s               322.8 s     0.2426          fits
1024 MB   4.00             0.0 s               240.0 s     0.2405          fits
1536 MB   6.00             0.0 s               161.3 s     0.2425          fits
2048 MB   8.00             0.0 s               122.0 s     0.2445          fits
3008 MB   8.00             0.0 s               122.0 s     0.3589          fits

cheapest tier 1024 MB: 240.0 s, 0.2405 units
fastest tier 2048 MB: 1.97x faster, 1.7% more expensive
largest tier 3008 MB: same duration, 49% more expensive — because the processor share hits its cap
```
The duration column falls in a single direction: from 1,844 seconds to 122 seconds. The cost
column falls and then rises: it drops from 0.4615 to 0.2405, then climbs to 0.3589; the cheapest
tier, **1024 MB**, sits in the middle of the table. The reason for the rise on the right sits in
the last row — the processor share caps at 2048 MB, and choosing 3008 MB raises the bill 49%
without shortening duration at all.

The reason for the rise on the left is different. At 256 MB the job's working set does not fit in
memory, and garbage collection adds 896 seconds; the job takes 1,844 seconds and **falls outside
the duration limit**. This row shows the two thresholds are linked: dropping the memory tier throws
the job into the duration limit. At 512 MB the same effect drops to 81 seconds and the job fits,
but it is both slower and more expensive than 1024 MB.

The last rows say the decision is not single: 2048 MB is 1.97x faster than 1024 MB and only 1.7%
more expensive. For work where latency matters this trade is cheap; for a night job no one is
waiting on, it is unnecessary. The same table suggests two different rows for two different jobs.

## The Shared Limit and Throttling

Duration and memory are the job's own decisions. Concurrency is not: the limit sits **at the
account level**, and every function shares it.

```js
// measurement-network/concurrency.mjs — the account-level shared concurrency limit and throttling (model)
import { CONCURRENCY_LIMIT } from "./job-set.mjs";

export const STEP_MS = 40, WINDOW_S = 2400, STEPS = WINDOW_S * 1000 / STEP_MS;
export const PIECE = 900, PIECE_S = 245, DEVIATION = 20;   // night job: 900 pieces, ~245 s
export const NOTIFICATION_STEPS = 8, RETRY = 25;           // 300 ms processing, retry 1 s later

export const rng = (t) => { let s = t >>> 0; return () => {
  s ^= s << 13; s >>>= 0; s ^= s >>> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; }; };

export const run = (reserve) => {
  const cap = CONCURRENCY_LIMIT - reserve, r = rng(20260805);
  const doneNight = new Int32Array(STEPS + 20000), doneNotif = new Int32Array(STEPS + 20000);
  const wait = [new Int32Array(STEPS + 200), new Int32Array(STEPS + 200), new Int32Array(STEPS + 200)];
  let nightActive = 0, notifActive = 0, remaining = PIECE, throttled = 0, lost = 0, success = 0, delay = 0;
  let nightEnd = -1, peakNight = 0, peakNotif = 0;
  for (let a = 0; a < STEPS; a++) {
    nightActive -= doneNight[a]; notifActive -= doneNotif[a];
    for (let d = 0; d < 3; d++) {
      const n = (d === 0 ? 1 : 0) + wait[d][a];
      for (let i = 0; i < n; i++) {
        if (nightActive + notifActive < CONCURRENCY_LIMIT) {
          notifActive++; doneNotif[a + NOTIFICATION_STEPS]++; success++; delay += d * RETRY * STEP_MS;
        } else { throttled++; if (d < 2) wait[d + 1][a + RETRY]++; else lost++; }
      }
    }
    while (remaining > 0 && nightActive + notifActive < CONCURRENCY_LIMIT && nightActive < cap) {
      const s = PIECE_S + (r() * 2 - 1) * DEVIATION;
      nightActive++; doneNight[a + Math.round(s * 1000 / STEP_MS)]++; remaining--;
    }
    if (nightActive > peakNight) peakNight = nightActive;
    if (notifActive > peakNotif) peakNotif = notifActive;
    if (remaining === 0 && nightActive === 0 && nightEnd < 0) nightEnd = a;
  }
  return { reserve, cap, nightEnd: nightEnd * STEP_MS / 1000, rounds: Math.ceil(PIECE / cap),
    throttled, lost, success, incoming: STEPS, throttleRate: throttled / STEPS, peakNight, peakNotif };
};

if (import.meta.url === `file://${process.argv[1]}`) {
  console.log(`account limit ${CONCURRENCY_LIMIT}; night job ${PIECE} x ~${PIECE_S} s, ` +
    `notification ${1000 / STEP_MS}/s x ${NOTIFICATION_STEPS * STEP_MS} ms`);
  console.log("\n" + "arrangement".padEnd(24) + "night cap".padEnd(13) + "night ends".padEnd(13) +
    "rounds".padEnd(9) + "throttled notifications".padEnd(25) + "throttle rate".padEnd(15) +
    "lost".padEnd(12) + "peak slots (night / notification)");
  const p = run(0), y = run(40);
  for (const s of [p, y])
    console.log((s.reserve ? `reserved share ${s.reserve}` : "shared").padEnd(24) +
      String(s.cap).padEnd(13) + `${s.nightEnd.toFixed(0)} s`.padEnd(13) + String(s.rounds).padEnd(9) +
      String(s.throttled).padEnd(25) + `${(s.throttleRate * 100).toFixed(1)}%`.padEnd(15) +
      String(s.lost).padEnd(12) + `${s.peakNight} / ${s.peakNotif}`);
  console.log(`\nthe reserved share delays the night job by ${(y.nightEnd - p.nightEnd).toFixed(0)} s; ` +
    `in return it prevents ${p.throttled} throttling events and ${p.lost} lost notifications — ` +
    `of ${p.incoming} incoming requests the throttle rate is ${(p.throttled / p.incoming * 100).toFixed(1)}%`);
}
```

```
account limit 200; night job 900 x ~245 s, notification 25/s x 320 ms

arrangement             night cap    night ends   rounds   throttled notifications  throttle rate  lost        peak slots (night / notification)
shared                  200          1254 s       5        14814                    24.7%          4923        199 / 14
reserved share 40       160          1496 s       6        0                        0.0%           0           160 / 8

the reserved share delays the night job by 242 s; in return it prevents 14814 throttling events and 4923 lost notifications — of 60000 incoming requests the throttle rate is 24.7%
```
In the shared arrangement, the night job holds 199 slots. **24.7% of notification requests are
throttled**, 8.2% are lost after three attempts: 4,923 notifications are never sent. The night job
sees no error during this. Starving is a one-sided event; it shows up in the victim's log, not the
offender's.

The reserved share fixes this and writes its cost openly: once the night cap drops to 160, the job
finishes in six rounds instead of five and takes 1,496 seconds instead of 1,254. **242 seconds of
delay is the price of 14,814 throttling events and 4,923 lost notifications.** The reserved share
does not create new capacity; it divides up what already exists, and it forces that division to be
made once, in advance.

## Summary

- Six of the twelve jobs do not fit the thresholds: three exceed duration, two exceed memory, one
  exceeds the concurrency limit.
- Working around one threshold moves the job into another: the night job exceeds the duration
  limit in one piece, runs into the concurrency limit once split into 246 pieces, and pays 245
  progress records.
- In the memory scan, duration drops from 1,844 seconds to 122 seconds while cost draws a valley;
  the cheapest tier is 1024 MB, and 3008 MB raises the bill 49% without shortening duration at all.
- At 256 MB the working set does not fit, so garbage collection adds 896 seconds and the job falls
  outside the duration limit: dropping the memory tier throws the job into a second threshold.
- Because the concurrency limit is shared at the account level, the night job holds 199 slots;
  24.7% of notification requests are throttled, 8.2% are lost, and the night job never sees any of
  it.
- The reserved share drops throttling to zero and writes the cost onto the night job: six rounds
  instead of five, 242 seconds more.

## Next Step

This lesson measured the three walls around the function, and every measurement carried a silent
assumption: the code being measured is the code running.

But the change that splits the night job into 246 pieces, or the setting that pulls the memory
tier from 512 to 1024, gets published on some day, and on that day two versions exist side by
side. Which version traffic goes to, and whether settings travel along with the version, calls for
a separate measurement. The next lesson measures the function's versioning and how traffic is
split across versions.
