State across runs with PropertiesService
Store state across Apps Script runs with PropertiesService, CacheService, and LockService for durable script properties, per-user keys, and caches.
Read and validate stored state, update it under a shared lock, and distinguish authoritative properties, memoized results, optional caches, and uncertain external outcomes.
Remember a decision without repeating an action
Keep a seat-limit policy and three request decisions between editor runs, update the policy for future requests without rewriting earlier decisions, and store a per-user display preference.
In Fetch data from web APIs, local variables and top-level bindings in a .gs file exist only for a single execution. A const declaration prevents reassignment within its scope, and a top-level let resets when the execution ends. To preserve state across runs, a script must read and write an external store.
This project stores the durable record in Script Properties, keeps each user's display preference in User Properties, caches a disposable summary in the Script cache, and serializes read-modify-write updates with a script lock. If the cache entry is evicted, the script rebuilds the summary from Script Properties.
Choose who shares each value
A property store is a collection of named string values. A key identifies one value within that store; the same key text in a different store refers to different data. Google's Properties guide describes three scopes:
| Store | Who shares it | Use in this lesson |
|---|---|---|
| Script Properties | Users of this script project | One shared policy, revision and three-request work record |
| User Properties | The current or effective user within this script project | That user's compact or detailed display preference |
| Document Properties | Users of this script in its supported document context | Not used by this standalone project |
The PropertiesService reference gives the Document Properties restriction: they are available when a script is bound to a Google file type, or when it is published and executing as an add-on. The getter returns null when the store is unavailable. Knowing a spreadsheet ID or having its browser tab open does not give a standalone project that context. Document Properties belong to this script too; a different script accessing the same document cannot use them as a shared database.
Script Properties are scoped to a single script project; a copied or newly created project starts with its own empty store. Changing the property getter changes who shares the value.
For a manual standalone editor run, the execution user is the signed-in person who clicks Run. An installable trigger instead runs as its creator; a web app's actor depends on deployment settings. Those distinctions from Google's execution-identity table matter to User Properties: a preference belongs to the effective execution user rather than whoever caused an external event or visited an owner-executed web app.
The key prefix book.state-across-runs. is a namespace convention that prevents collisions with other keys in the project; the periods are ordinary characters rather than hierarchical access controls. Because anyone with edit access to the script project can read Script Properties and modify code that later runs as other users, neither Script Properties nor User Properties isolates secrets from project editors.
Keep a small, versioned record
The shared record stores the policy and request queue under one JSON key. Its schema defines the expected fields, types, and bounds:
| Field | Meaning |
|---|---|
version | The stored data format this code understands: 1 |
revision | A counter increased by each actual shared policy or work change; starts at 1 and stops at 100 |
seatLimit | The current policy, an integer from 1 through 10; starts at 5 |
work | Three requests in fixed order, each with an ID, seat count, decision, and recorded limit |
The initial requests are R-101 (2 seats), R-102 (6 seats), and R-103 (4 seats). Each starts with decision: 'pending' and limitUsed: null. Planning a request sets its decision to 'fits' or 'tooLarge' and records the seatLimit applied.
version identifies the schema format, while revision increments on each state change. The work array always holds those three fixed request records.
Properties stores every value as a string. Passing a non-string to setProperty coerces it rather than serializing its structure, so the script calls JSON.stringify before writing and JSON.parse after reading. A numeric seatLimit is written as a JSON number inside that string and parsed back as a number.
JSON.stringify omits undefined and function properties, serializes Date instances as ISO strings rather than live Date objects, and throws on circular references. This schema uses only plain objects, arrays, fixed strings, bounded integers, and null.
A missing property is different from an invalid one. getProperty returns null for an absent key. Empty text, malformed JSON, a wrong type, an unsupported version, extra or missing fields, altered request fixtures, and inconsistent decisions all throw an error. initializePractice creates absent keys from the baseline fixture and refuses to overwrite an existing malformed value.
Install the complete standalone project
const SEAT_LIMIT_TO_SAVE = 5;
const VIEW_TO_SAVE = 'compact';
/** @type {boolean} */
const DELETE_PRACTICE_STATE = false;
const STATE_KEY = 'book.state-across-runs.shared';
const USER_KEY = 'book.state-across-runs.preference';
const SUMMARY_KEY = 'book.state-across-runs.summary.v1';
const SAMPLE_REQUESTS = [
{ id: 'R-101', seats: 2 },
{ id: 'R-102', seats: 6 },
{ id: 'R-103', seats: 4 }
];
/**
* @typedef {'pending' | 'fits' | 'tooLarge'} PracticeDecision_
*/
/**
* @typedef {{id: string, seats: number, decision: PracticeDecision_, limitUsed: number | null}} PracticeRequest_
*/
/**
* @typedef {{version: number, revision: number, seatLimit: number, work: PracticeRequest_[]}} PracticeState_
*/
/**
* @typedef {{version: number, view: string}} PracticePreference_
*/
/**
* @typedef {{pending: number, fits: number, tooLarge: number}} PracticeSummary_
*/
function initializePractice() {
withPracticeLock(() => {
const sharedStore = PropertiesService.getScriptProperties();
const userStore = PropertiesService.getUserProperties();
const sharedText = sharedStore.getProperty(STATE_KEY);
const userText = userStore.getProperty(USER_KEY);
let state;
let preference;
if (sharedText === null) {
state = {
version: 1,
revision: 1,
seatLimit: 5,
work: SAMPLE_REQUESTS.map((request) => {
return { id: request.id, seats: request.seats, decision: 'pending', limitUsed: null };
})
};
} else {
state = decodeState(sharedText);
}
if (userText === null) {
preference = { version: 1, view: 'compact' };
} else {
preference = decodePreference(userText);
}
// Validate both stores before any initialization write. Existing data is never reset here.
validateState(state);
validatePreference(preference);
if (sharedText === null) {
sharedStore.setProperty(STATE_KEY, encodeRecord(state));
Logger.log('Shared state created; revision 1.');
} else {
Logger.log('Shared state preserved; revision ' + state.revision + '.');
}
if (userText === null) {
userStore.setProperty(USER_KEY, encodeRecord(preference));
Logger.log('My view created: compact.');
} else {
Logger.log('My view preserved: ' + preference.view + '.');
}
});
}
function inspectPracticeState() {
withPracticeLock(() => {
const text = PropertiesService.getScriptProperties().getProperty(STATE_KEY);
const state = decodeState(text);
const preference = decodePreference(PropertiesService.getUserProperties().getProperty(USER_KEY));
const summary = cachedSummary(text, state);
Logger.log('Shared revision: ' + state.revision + '; seat limit: ' + state.seatLimit);
Logger.log('My view: ' + preference.view);
Logger.log('Requests: pending=' + summary.pending + '; fits=' + summary.fits + '; tooLarge=' + summary.tooLarge);
if (preference.view === 'detailed') {
Logger.log('Work: ' + JSON.stringify(state.work));
}
});
}
function saveSeatLimit() {
if (!boundedInteger(SEAT_LIMIT_TO_SAVE, 1, 10)) {
throw new Error('Set SEAT_LIMIT_TO_SAVE to a whole number from 1 to 10.');
}
withPracticeLock(() => {
const store = PropertiesService.getScriptProperties();
const state = decodeState(store.getProperty(STATE_KEY));
if (state.seatLimit === SEAT_LIMIT_TO_SAVE) {
Logger.log('Seat limit unchanged; revision ' + state.revision + '.');
return;
}
advanceRevision(state);
state.seatLimit = SEAT_LIMIT_TO_SAVE;
writeState(store, state);
Logger.log('Saved seat limit ' + state.seatLimit + '; revision ' + state.revision + '.');
});
}
function planNextRequest() {
withPracticeLock(() => {
const store = PropertiesService.getScriptProperties();
const state = decodeState(store.getProperty(STATE_KEY));
for (const request of state.work) {
if (request.decision === 'pending') {
advanceRevision(state);
request.limitUsed = state.seatLimit;
request.decision = decisionFor(request.seats, state.seatLimit);
writeState(store, state);
Logger.log('Planned ' + request.id + ': ' + request.decision + '; revision ' + state.revision + '. No booking or message was made.');
return;
}
}
Logger.log('No pending requests; revision ' + state.revision + ' unchanged.');
});
}
function saveMyView() {
if (VIEW_TO_SAVE !== 'compact' && VIEW_TO_SAVE !== 'detailed') {
throw new Error('Set VIEW_TO_SAVE to compact or detailed.');
}
withPracticeLock(() => {
const store = PropertiesService.getUserProperties();
const preference = decodePreference(store.getProperty(USER_KEY));
if (preference.view === VIEW_TO_SAVE) {
Logger.log('My view unchanged: ' + preference.view + '.');
return;
}
preference.view = VIEW_TO_SAVE;
validatePreference(preference);
store.setProperty(USER_KEY, encodeRecord(preference));
Logger.log('Saved my view: ' + preference.view + '. Shared state was not changed.');
});
}
function forgetPracticeSummary() {
withPracticeLock(() => {
removeSummaryCache();
Logger.log('No property keys were changed.');
});
}
function removePracticeState() {
if (DELETE_PRACTICE_STATE !== true) {
throw new Error('Inspect the disposable project, then set DELETE_PRACTICE_STATE to true to delete only this exercise state.');
}
withPracticeLock(() => {
// Deliberately allow exact-key cleanup even when a stored exercise value is malformed.
PropertiesService.getScriptProperties().deleteProperty(STATE_KEY);
PropertiesService.getUserProperties().deleteProperty(USER_KEY);
Logger.log('Removed the shared exercise key and my preference key; unrelated keys and other users\' preferences were not touched.');
removeSummaryCache();
});
}
/**
* @template T
* @param {() => T} operation
* @returns {T}
*/
function withPracticeLock(operation) {
const lock = LockService.getScriptLock();
if (!lock.tryLock(3000)) {
throw new Error('Practice state is busy. No state operation started; inspect running executions before trying again.');
}
try {
return operation();
} finally {
lock.releaseLock();
}
}
/**
* @param {string | null} text
* @param {string} label
*/
function parseRecord(text, label) {
if (text === null) {
throw new Error(label + ' is missing. Run initializePractice in this project as the current user.');
}
if (typeof text !== 'string' || text.length === 0 || text.length > 2048) {
throw new Error(label + ' must be a nonempty JSON string of at most 2048 characters. Stop and inspect; nothing was reset.');
}
let record;
try {
record = JSON.parse(text);
} catch (error) {
throw new Error(label + ' is not valid JSON. Stop and inspect; nothing was reset.');
}
if (!record || typeof record !== 'object' || Array.isArray(record) || record.version !== 1) {
throw new Error(label + ' has an unsupported version or record shape. Expected version 1; do not reset or migrate it silently.');
}
return record;
}
/**
* @param {string | null} text
*/
function decodeState(text) {
const state = parseRecord(text, 'Shared practice state');
validateState(state);
return state;
}
/**
* @param {PracticeState_} state
*/
function validateState(state) {
if (!hasFields(state, ['version', 'revision', 'seatLimit', 'work']) || state.version !== 1 ||
!boundedInteger(state.revision, 1, 100) || !boundedInteger(state.seatLimit, 1, 10) ||
!Array.isArray(state.work) || state.work.length !== SAMPLE_REQUESTS.length) {
throw new Error('Shared practice state has invalid fields or bounds. Stop and inspect; nothing was reset.');
}
let index = 0;
let planned = 0;
let pendingSeen = false;
for (const request of state.work) {
const sample = SAMPLE_REQUESTS[index];
if (!hasFields(request, ['id', 'seats', 'decision', 'limitUsed']) ||
request.id !== sample.id || request.seats !== sample.seats) {
throw new Error('Stored work must contain the three unchanged sample requests in order.');
}
if (request.decision === 'pending') {
if (request.limitUsed !== null) throw new Error('A pending request must have a null limitUsed.');
pendingSeen = true;
} else {
if (pendingSeen || !boundedInteger(request.limitUsed, 1, 10) ||
request.decision !== decisionFor(request.seats, request.limitUsed)) {
throw new Error('Stored decisions must form a planned prefix and match their recorded seat limits.');
}
planned = planned + 1;
}
index = index + 1;
}
if (state.revision < planned + 1) throw new Error('Stored revision cannot precede its planned work.');
}
/**
* @param {string | null} text
*/
function decodePreference(text) {
const preference = parseRecord(text, 'My practice preference');
validatePreference(preference);
return preference;
}
/**
* @param {PracticePreference_} preference
*/
function validatePreference(preference) {
if (!hasFields(preference, ['version', 'view']) || preference.version !== 1 ||
(preference.view !== 'compact' && preference.view !== 'detailed')) {
throw new Error('My practice preference has invalid fields. Stop and inspect; nothing was reset.');
}
}
/**
* @param {unknown} value
* @param {string[]} fields
* @returns {boolean}
*/
function hasFields(value, fields) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const keys = Object.keys(value);
if (keys.length !== fields.length) return false;
for (const key of keys) {
if (!fields.includes(key)) return false;
}
return true;
}
/**
* @param {unknown} value
* @param {number} minimum
* @param {number} maximum
* @returns {value is number}
*/
function boundedInteger(value, minimum, maximum) {
return typeof value === 'number' && Number.isInteger(value) &&
value >= minimum && value <= maximum;
}
/**
* @param {number} seats
* @param {number} limit
* @returns {PracticeDecision_}
*/
function decisionFor(seats, limit) {
if (seats <= limit) return 'fits';
return 'tooLarge';
}
/**
* @param {PracticeState_} state
*/
function advanceRevision(state) {
if (state.revision >= 100) throw new Error('Practice revision limit reached. Inspect and deliberately clean up the exercise before restarting.');
state.revision = state.revision + 1;
}
/**
* @param {unknown} record
* @returns {string}
*/
function encodeRecord(record) {
const text = JSON.stringify(record);
if (text.length > 2048) throw new Error('Practice record exceeds the 2048-character write limit.');
return text;
}
/**
* @param {PropertiesService.Properties} store
* @param {PracticeState_} state
*/
function writeState(store, state) {
validateState(state);
store.setProperty(STATE_KEY, encodeRecord(state));
}
/**
* @param {string | null} source
* @param {PracticeState_} state
* @returns {PracticeSummary_}
*/
function cachedSummary(source, state) {
let cache = null;
let text = null;
try {
cache = CacheService.getScriptCache();
text = cache.get(SUMMARY_KEY);
} catch (error) {
cache = null;
Logger.log('Cache read unavailable; rebuilding from properties.');
}
if (text !== null && text.length <= 8192) {
let entry;
try {
entry = JSON.parse(text);
} catch (error) {
entry = null;
}
if (hasFields(entry, ['version', 'source', 'summary']) && entry.version === 1 &&
entry.source === source && validSummary(entry.summary)) {
Logger.log('Summary source: cache hit.');
return entry.summary;
}
}
const summary = { pending: 0, fits: 0, tooLarge: 0 };
for (const request of state.work) {
summary[request.decision] = summary[request.decision] + 1;
}
Logger.log('Summary source: rebuilt from properties.');
if (cache !== null) {
try {
cache.put(SUMMARY_KEY, JSON.stringify({ version: 1, source: source, summary: summary }), 60);
} catch (error) {
Logger.log('Cache write unavailable; the rebuilt summary is still usable.');
}
}
return summary;
}
/**
* @param {PracticeSummary_} summary
* @returns {boolean}
*/
function validSummary(summary) {
return hasFields(summary, ['pending', 'fits', 'tooLarge']) &&
boundedInteger(summary.pending, 0, 3) && boundedInteger(summary.fits, 0, 3) &&
boundedInteger(summary.tooLarge, 0, 3) && summary.pending + summary.fits + summary.tooLarge === 3;
}
function removeSummaryCache() {
try {
CacheService.getScriptCache().remove(SUMMARY_KEY);
Logger.log('Exercise summary cache removal requested.');
} catch (error) {
Logger.log('Cache removal unavailable. Any leftover entry is disposable, not authoritative state.');
}
}
{
"timeZone": "Etc/UTC",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": []
}
At
script.google.com, create a new standalone project namedRequest state practice.Replace the contents of
Code.gswith the complete file above.In Project Settings, enable Show "appsscript.json" manifest file in editor, replace
appsscript.jsonwith the project manifest, and save both files. Keep the V8 runtime and the emptyoauthScopesarray.Leave
SEAT_LIMIT_TO_SAVE = 5,VIEW_TO_SAVE = 'compact', andDELETE_PRACTICE_STATE = false.Select
initializePracticeand click Run, then selectinspectPracticeStateand run it.
Because PropertiesService, CacheService, and LockService are built-in script infrastructure that require no OAuth scopes, oauthScopes is [] and running the project does not trigger a consent screen.
On a fresh initialization, expect:
Shared state created; revision 1.
My view created: compact. The inspection should include:
Shared revision: 1; seat limit: 5
My view: compact
Requests: pending=3; fits=0; tooLarge=0 You will also see a cache-source line (cache hit or rebuilt from properties), which does not affect the property-derived state.
Run initializePractice again before changing anything. It reports that shared revision 1 and your compact view were preserved rather than resetting them. Because Script Properties and User Properties are separate stores, if the shared write succeeds before the user-preference write fails, re-running initializePractice preserves the shared record and creates only the missing preference.
Read, change, then explicitly save
The Properties reference distinguishes reading a copy from updating storage. Parsing the saved text gives this execution a local object. Assigning state.seatLimit or a request's decision mutates that local object in memory. writeState validates the modified record, serializes it, and calls setProperty to save the replacement string.
Because setProperty replaces the entire string stored under a key rather than merging JSON fields, the script reads the full record, updates the target fields, and writes the full record back while holding the script lock.
A multi-key write is a separate operation: setProperties(values) or setProperties(values, false) updates the supplied keys while leaving other keys in the store intact. Passing true as the second argument (setProperties(values, true)) or calling deleteAllProperties() deletes every other key in that store, so this project reads, writes, and deletes only its own named keys.
The SEAT_LIMIT_TO_SAVE and VIEW_TO_SAVE constants supply inputs to saveSeatLimit and saveMyView; editing either constant in Code.gs takes effect only when you run its save function.
Change the policy for the next request
Follow this sequence from the fresh baseline, with no other users running the project:
Run
planNextRequestonce. It recordsR-101asfitsusing limit5and incrementsrevisionto2. RuninspectPracticeState: two requests remain pending and one fits.Change
SEAT_LIMIT_TO_SAVEfrom5to6and saveCode.gs.R-102requests 6 seats, which exceeds the old limit of 5 but fits the new limit of 6.Run
saveSeatLimit. ExpectSaved seat limit 6; revision 3.RunsaveSeatLimita second time without editingCode.gs; it logsSeat limit unchanged; revision 3.without incrementing the revision.Run
planNextRequestonce.R-102becomesfitsat revision4, leaving one pending request and two that fit.Restore
SEAT_LIMIT_TO_SAVE = 5, saveCode.gs, and runsaveSeatLimit. Expect revision5. UpdatingseatLimitapplies only to futurependingrequests;R-102retainslimitUsed: 6.Run
planNextRequestonce more.R-103(4 seats) fits limit5, advancingrevisionto6with zero pending requests and three that fit. RunningplanNextRequestagain reports no pending requests and leavesrevisionat6.
planNextRequest updates at most one pending request per run and returns immediately after writing. A subsequent run reads the saved decisions and advances to the next pending record. If a run ends with an uncertain outcome, run inspectPracticeState before retrying planNextRequest to check whether the write already succeeded.
decodeState validates each decided request against its own recorded limitUsed rather than the current seatLimit, and requires decided requests to form a contiguous prefix of the work array before any pending entries.
Keep your view separate from the shared policy
Change VIEW_TO_SAVE from 'compact' to 'detailed', save Code.gs, and run saveMyView. Run inspectPracticeState again. The summary lines still report the same shared revision and counts, and an additional Work: JSON line shows each request with its recorded limitUsed (5, 6, and 5, even though seatLimit is now 5).
Restore VIEW_TO_SAVE = 'compact', save, and run saveMyView. Subsequent inspections omit the Work: line. Saving a view preference writes to User Properties for the execution user without modifying Script Properties or incrementing the shared revision.
If a second authorized user runs initializePractice in this same project under their own account, the function preserves the shared policy and decisions while initializing that user's missing compact preference. Each user's saveMyView updates only their own User Properties, while saveSeatLimit and planNextRequest update the shared Script Properties visible to both accounts.
The Script cache stores only shared data: the serialized shared record and its derived counts. inspectPracticeState formats output according to the current user's preference after reading or rebuilding the shared summary, so one user's preference never leaks into the shared cache entry.
Protect the whole read-modify-write step
Without coordination, two concurrent runs can both read revision 1 with R-101 pending, and the second write will overwrite the first. Locking only the final setProperty call is insufficient because the second run computed its update from a stale read.
LockService.getScriptLock() returns a lock handle shared across users of the script; tryLock(3000) attempts to acquire it for up to 3,000 milliseconds. If acquisition times out, withPracticeLock throws before reading or writing Properties or CacheService.
A critical section is the sequence of operations that must not overlap another cooperating execution: reading and validating the current record, computing the change, writing the updated record, and logging the result. Every entrypoint in this project acquires the same script lock so all state transitions follow a single serialization rule.
By contrast, a User lock (getUserLock()) serializes executions only for a single user while allowing concurrent writes to shared Script Properties from other users, and a Document lock (getDocumentLock()) requires a bound document context and returns null in a standalone project. See the LockService reference.
withPracticeLock(operation) runs operation() synchronously inside a try block and releases the lock in the finally block whether operation() returns normally or throws an error. Google also documents automatic lock release when the script terminates, though an unexpected termination does not roll back partial writes.
The 3,000 ms argument bounds only the lock acquisition wait, not the total execution time. A script lock coordinates only code in this project that acquires it; it does not block manual edits to Script Properties in Project Settings or calls in other script projects.
Validate the saved format before using it
A manual edit in Project Settings or an older code version can leave a string that parses as JSON but violates the expected schema. decodeState checks the schema version, exact field names, integer bounds, and invariants before any planner or policy update uses the record, rejecting wrong types such as "5" instead of coercing them.
hasFields uses Object.keys and includes to require an exact set of own property names, rejecting both missing and unexpected fields. That strict check fits a persisted state record owned by this project, unlike the forward-compatible HTTP decoding in the previous chapter that ignores extra fields from external APIs.
boundedInteger checks Number.isInteger against inclusive bounds. The validation loop verifies each request ID and seat count against the fixture, tracks pendingSeen so decided records cannot appear after a pending record, verifies that each decided record's decision matches its limitUsed, and checks that revision is at least 1 plus the number of decided records.
In the summary loop, summary[request.decision] uses computed property access to increment pending, fits, or tooLarge after decodeState has verified that request.decision is one of those three strings.
If you evolve a persisted schema in your own projects, increment version and write an explicit migration that reads and validates version 1, transforms it to version 2, and writes the result under the script lock. This example supports only version 1 and stops on any other version number.
Rebuild a summary when the cache disappears
A cache holds a disposable derived value that can be recomputed from authoritative state. CacheService provides a Script cache shared by users of the project, a User cache scoped to the current user and project, and a Document cache that returns null in a standalone project. Because the request counts are shared across the project, this example uses CacheService.getScriptCache().
Cache keys and values are strings. This project stores its summary under book.state-across-runs.summary.v1 as a JSON string containing the cache format version, the exact serialized Script Properties string (source), and the derived counts, with a time to live (TTL) of 60 seconds. The Cache reference accepts TTL values from 1 through 21600 seconds (6 hours), but entries can be evicted before their TTL expires, so callers must always handle null from cache.get().
Every inspectPracticeState run reads and validates Script Properties first, then reuses the cached summary only if its cache version, summary structure, and source string match the current property record. If the derived calculation or summary structure changes in code, bump the cache key or version so old entries are ignored.
When the cache entry matches, inspectPracticeState logs Summary source: cache hit.; otherwise it recomputes the counts, writes the cache, and logs Summary source: rebuilt from properties. Cache read or write errors log a bounded warning and fall back to the freshly computed summary, whereas a malformed Script Properties record throws and stops the execution.
To observe cache invalidation:
With valid initialized state, run
inspectPracticeStatetwice.Run
forgetPracticeSummary, which removesbook.state-across-runs.summary.v1from the Script cache without touching Script Properties or User Properties.Run
inspectPracticeStateagain. ExpectSummary source: rebuilt from properties.with the same revision, policy, preference, and counts.
Because cache entries can be evicted at any time and cache reads and writes are not atomic, never use CacheService as a completion checkpoint or mutual-exclusion lock.
Memoize deterministic work with an explicit key
Memoization caches a function's return value for a given input. Memoize only pure, deterministic calculations whose results can be recomputed on a cache miss without side effects. Never memoize email sends, file creation, random generation, current-time lookups, or mutable remote reads.
Build the cache key from a namespace, a version tag, and a cryptographic digest of the serialized arguments rather than function.toString(), which changes when comments or formatting change:
function memoizeJson(cache, namespace, args, ttlSeconds, compute) {
if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > 21600) {
throw new Error('ttlSeconds must be an integer from 1 through 21600.');
}
const input = JSON.stringify(args);
if (input === undefined) {
throw new Error('Memoized arguments must be JSON-serializable.');
}
const digest = Utilities.computeDigest(
Utilities.DigestAlgorithm.SHA_256,
input,
Utilities.Charset.UTF_8
);
const key = namespace + ':' + Utilities.base64EncodeWebSafe(digest);
const cached = cache.get(key);
if (cached !== null) {
return JSON.parse(cached);
}
const result = compute();
const encoded = JSON.stringify(result);
if (encoded === undefined) {
throw new Error('Memoized results must be JSON-serializable.');
}
cache.put(key, encoded, ttlSeconds);
return result;
}
function getMemoizedSummary(requests) {
return memoizeJson(
CacheService.getScriptCache(),
'request-summary.v2',
requests,
600,
() => summarizeRequests(requests)
);
} Replace summarizeRequests with the pure calculation whose JSON result you want to cache.
memoizeJson supports only JSON-serializable inputs and return values. JSON.stringify converts Date instances to ISO strings, serializes RegExp instances as {}, omits undefined and function properties on objects, converts them to null inside arrays, and depends on object key insertion order. Validate input shapes before memoizing so equivalent calls serialize to identical strings.
Two concurrent cache misses can both execute compute(). That is harmless for a fast, side-effect-free calculation; if recomputation is expensive, acquire a lock and recheck cache.get(key) inside the lock before computing.
Separate the cache contract from experiments
Design against the documented Cache limits: keys are at most 250 characters, values are at most 100 KB, and each cache holds up to 1,000 items. When a write exceeds 1,000 items, the service retains the 900 entries farthest from expiration.
An experiment that inserts more than 1,000 entries with identical TTLs can resemble first-in, first-out eviction because the oldest entries are also closest to expiration, as shown in the CacheService eviction experiment. Do not rely on insertion order, least-recently-used behavior, or a fixed eviction batch size; any entry can be evicted at any time.
Pass string keys and values and validate that ttlSeconds is an integer from 1 through 21600 before calling put, rather than relying on undocumented runtime coercion or out-of-range TTL acceptance.
Stay within a small storage budget
Google's current quotas limit PropertiesService to 9 KB per property value and 500 KB per property store, with daily read/write quotas of 50,000 for consumer accounts and 500,000 for Google Workspace accounts (subject to change). Because a single property cannot exceed 9 KB, keep bounded state in Properties and use a spreadsheet or database for growing queues or audit logs. Notice that inspectPracticeState still reads Script Properties on a cache hit to verify the source string, so caching a derived summary does not reduce property reads unless you accept bounded staleness without source comparison.
This project caps each property JSON string at 2,048 characters, the work array at 3 records, and revision at 100, and caps cached summary strings at 8,192 characters. Note that JavaScript .length counts UTF-16 code units rather than UTF-8 bytes when storing non-ASCII text.
Recover a bad record without hiding it
To verify that decodeState and initializePractice reject corrupted or unsupported records instead of overwriting them:
Ensure no executions are running, then open Project Settings and locate
book.state-across-runs.sharedunder Script Properties. Copy its complete JSON value to a local note so you can restore it.Edit
book.state-across-runs.sharedin Project Settings, changing"version":1to"version":0, and save. RuninspectPracticeState: it throws an unsupported-version error instead of returning a default state or cached summary.Run
initializePractice. It also throws an unsupported-version error rather than overwriting the existing property.Restore the exact original JSON string from your note, save the property, and run
inspectPracticeStateto confirm the saved revision and decisions are intact.To compare malformed JSON against an unsupported version, remove the final
}frombook.state-across-runs.shared, save, and runinspectPracticeStateto observe the JSON parse error. Restore the complete original JSON string immediately and runinspectPracticeStateto verify recovery.
Project Settings exposes only Script Properties; User Properties must be inspected or repaired by running code as that user.
Distinguish a local plan from an external checkpoint
In this project, the property write is the entire state transition: if a run fails, inspecting book.state-across-runs.shared shows whether the update took effect. Even within PropertiesService, multi-step routines like initializePractice and removePracticeState make separate calls to Script Properties, User Properties, and CacheService that can stop partway through without rolling back earlier calls.
When a workflow also mutates an external system, such as creating a Drive file or sending an email, local state and the remote action cannot be updated in a single transaction. A pending marker written beforehand records only intent, while a completion marker written afterward can fail after the remote action has already succeeded.
External workflows therefore need three states: planned work, confirmed completion, and an uncertain outcome that requires reconciliation (checking the target service's actual state or saved resource ID before retrying).
Remove only the exercise's keys
To remove the keys created by this chapter:
In
Code.gs, changeDELETE_PRACTICE_STATEfromfalsetotrue, save, and runremovePracticeState.Restore
DELETE_PRACTICE_STATE = falseand saveCode.gs.removePracticeStatedeletesbook.state-across-runs.sharedfrom Script Properties, deletesbook.state-across-runs.preferencefrom the current user's User Properties, and removesbook.state-across-runs.summary.v1from the Script cache.Run
inspectPracticeStateand confirm that it throws a missing-state error rather than reconstructing state from the cache.
If a second user also ran the exercise under their own account, they must run removePracticeState as their own account to delete their User Properties key. Next, install time-driven triggers to run a repeat-safe workflow on a clock schedule.
Project files
Complete source files for this chapter’s examples.
Keep a request policy and local work record between runs
Code.gs
Download fileconst SEAT_LIMIT_TO_SAVE = 5;
const VIEW_TO_SAVE = 'compact';
/** @type {boolean} */
const DELETE_PRACTICE_STATE = false;
const STATE_KEY = 'book.state-across-runs.shared';
const USER_KEY = 'book.state-across-runs.preference';
const SUMMARY_KEY = 'book.state-across-runs.summary.v1';
const SAMPLE_REQUESTS = [
{ id: 'R-101', seats: 2 },
{ id: 'R-102', seats: 6 },
{ id: 'R-103', seats: 4 }
];
/**
* @typedef {'pending' | 'fits' | 'tooLarge'} PracticeDecision_
*/
/**
* @typedef {{id: string, seats: number, decision: PracticeDecision_, limitUsed: number | null}} PracticeRequest_
*/
/**
* @typedef {{version: number, revision: number, seatLimit: number, work: PracticeRequest_[]}} PracticeState_
*/
/**
* @typedef {{version: number, view: string}} PracticePreference_
*/
/**
* @typedef {{pending: number, fits: number, tooLarge: number}} PracticeSummary_
*/
function initializePractice() {
withPracticeLock(() => {
const sharedStore = PropertiesService.getScriptProperties();
const userStore = PropertiesService.getUserProperties();
const sharedText = sharedStore.getProperty(STATE_KEY);
const userText = userStore.getProperty(USER_KEY);
let state;
let preference;
if (sharedText === null) {
state = {
version: 1,
revision: 1,
seatLimit: 5,
work: SAMPLE_REQUESTS.map((request) => {
return { id: request.id, seats: request.seats, decision: 'pending', limitUsed: null };
})
};
} else {
state = decodeState(sharedText);
}
if (userText === null) {
preference = { version: 1, view: 'compact' };
} else {
preference = decodePreference(userText);
}
// Validate both stores before any initialization write. Existing data is never reset here.
validateState(state);
validatePreference(preference);
if (sharedText === null) {
sharedStore.setProperty(STATE_KEY, encodeRecord(state));
Logger.log('Shared state created; revision 1.');
} else {
Logger.log('Shared state preserved; revision ' + state.revision + '.');
}
if (userText === null) {
userStore.setProperty(USER_KEY, encodeRecord(preference));
Logger.log('My view created: compact.');
} else {
Logger.log('My view preserved: ' + preference.view + '.');
}
});
}
function inspectPracticeState() {
withPracticeLock(() => {
const text = PropertiesService.getScriptProperties().getProperty(STATE_KEY);
const state = decodeState(text);
const preference = decodePreference(PropertiesService.getUserProperties().getProperty(USER_KEY));
const summary = cachedSummary(text, state);
Logger.log('Shared revision: ' + state.revision + '; seat limit: ' + state.seatLimit);
Logger.log('My view: ' + preference.view);
Logger.log('Requests: pending=' + summary.pending + '; fits=' + summary.fits + '; tooLarge=' + summary.tooLarge);
if (preference.view === 'detailed') {
Logger.log('Work: ' + JSON.stringify(state.work));
}
});
}
function saveSeatLimit() {
if (!boundedInteger(SEAT_LIMIT_TO_SAVE, 1, 10)) {
throw new Error('Set SEAT_LIMIT_TO_SAVE to a whole number from 1 to 10.');
}
withPracticeLock(() => {
const store = PropertiesService.getScriptProperties();
const state = decodeState(store.getProperty(STATE_KEY));
if (state.seatLimit === SEAT_LIMIT_TO_SAVE) {
Logger.log('Seat limit unchanged; revision ' + state.revision + '.');
return;
}
advanceRevision(state);
state.seatLimit = SEAT_LIMIT_TO_SAVE;
writeState(store, state);
Logger.log('Saved seat limit ' + state.seatLimit + '; revision ' + state.revision + '.');
});
}
function planNextRequest() {
withPracticeLock(() => {
const store = PropertiesService.getScriptProperties();
const state = decodeState(store.getProperty(STATE_KEY));
for (const request of state.work) {
if (request.decision === 'pending') {
advanceRevision(state);
request.limitUsed = state.seatLimit;
request.decision = decisionFor(request.seats, state.seatLimit);
writeState(store, state);
Logger.log('Planned ' + request.id + ': ' + request.decision + '; revision ' + state.revision + '. No booking or message was made.');
return;
}
}
Logger.log('No pending requests; revision ' + state.revision + ' unchanged.');
});
}
function saveMyView() {
if (VIEW_TO_SAVE !== 'compact' && VIEW_TO_SAVE !== 'detailed') {
throw new Error('Set VIEW_TO_SAVE to compact or detailed.');
}
withPracticeLock(() => {
const store = PropertiesService.getUserProperties();
const preference = decodePreference(store.getProperty(USER_KEY));
if (preference.view === VIEW_TO_SAVE) {
Logger.log('My view unchanged: ' + preference.view + '.');
return;
}
preference.view = VIEW_TO_SAVE;
validatePreference(preference);
store.setProperty(USER_KEY, encodeRecord(preference));
Logger.log('Saved my view: ' + preference.view + '. Shared state was not changed.');
});
}
function forgetPracticeSummary() {
withPracticeLock(() => {
removeSummaryCache();
Logger.log('No property keys were changed.');
});
}
function removePracticeState() {
if (DELETE_PRACTICE_STATE !== true) {
throw new Error('Inspect the disposable project, then set DELETE_PRACTICE_STATE to true to delete only this exercise state.');
}
withPracticeLock(() => {
// Deliberately allow exact-key cleanup even when a stored exercise value is malformed.
PropertiesService.getScriptProperties().deleteProperty(STATE_KEY);
PropertiesService.getUserProperties().deleteProperty(USER_KEY);
Logger.log('Removed the shared exercise key and my preference key; unrelated keys and other users\' preferences were not touched.');
removeSummaryCache();
});
}
/**
* @template T
* @param {() => T} operation
* @returns {T}
*/
function withPracticeLock(operation) {
const lock = LockService.getScriptLock();
if (!lock.tryLock(3000)) {
throw new Error('Practice state is busy. No state operation started; inspect running executions before trying again.');
}
try {
return operation();
} finally {
lock.releaseLock();
}
}
/**
* @param {string | null} text
* @param {string} label
*/
function parseRecord(text, label) {
if (text === null) {
throw new Error(label + ' is missing. Run initializePractice in this project as the current user.');
}
if (typeof text !== 'string' || text.length === 0 || text.length > 2048) {
throw new Error(label + ' must be a nonempty JSON string of at most 2048 characters. Stop and inspect; nothing was reset.');
}
let record;
try {
record = JSON.parse(text);
} catch (error) {
throw new Error(label + ' is not valid JSON. Stop and inspect; nothing was reset.');
}
if (!record || typeof record !== 'object' || Array.isArray(record) || record.version !== 1) {
throw new Error(label + ' has an unsupported version or record shape. Expected version 1; do not reset or migrate it silently.');
}
return record;
}
/**
* @param {string | null} text
*/
function decodeState(text) {
const state = parseRecord(text, 'Shared practice state');
validateState(state);
return state;
}
/**
* @param {PracticeState_} state
*/
function validateState(state) {
if (!hasFields(state, ['version', 'revision', 'seatLimit', 'work']) || state.version !== 1 ||
!boundedInteger(state.revision, 1, 100) || !boundedInteger(state.seatLimit, 1, 10) ||
!Array.isArray(state.work) || state.work.length !== SAMPLE_REQUESTS.length) {
throw new Error('Shared practice state has invalid fields or bounds. Stop and inspect; nothing was reset.');
}
let index = 0;
let planned = 0;
let pendingSeen = false;
for (const request of state.work) {
const sample = SAMPLE_REQUESTS[index];
if (!hasFields(request, ['id', 'seats', 'decision', 'limitUsed']) ||
request.id !== sample.id || request.seats !== sample.seats) {
throw new Error('Stored work must contain the three unchanged sample requests in order.');
}
if (request.decision === 'pending') {
if (request.limitUsed !== null) throw new Error('A pending request must have a null limitUsed.');
pendingSeen = true;
} else {
if (pendingSeen || !boundedInteger(request.limitUsed, 1, 10) ||
request.decision !== decisionFor(request.seats, request.limitUsed)) {
throw new Error('Stored decisions must form a planned prefix and match their recorded seat limits.');
}
planned = planned + 1;
}
index = index + 1;
}
if (state.revision < planned + 1) throw new Error('Stored revision cannot precede its planned work.');
}
/**
* @param {string | null} text
*/
function decodePreference(text) {
const preference = parseRecord(text, 'My practice preference');
validatePreference(preference);
return preference;
}
/**
* @param {PracticePreference_} preference
*/
function validatePreference(preference) {
if (!hasFields(preference, ['version', 'view']) || preference.version !== 1 ||
(preference.view !== 'compact' && preference.view !== 'detailed')) {
throw new Error('My practice preference has invalid fields. Stop and inspect; nothing was reset.');
}
}
/**
* @param {unknown} value
* @param {string[]} fields
* @returns {boolean}
*/
function hasFields(value, fields) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const keys = Object.keys(value);
if (keys.length !== fields.length) return false;
for (const key of keys) {
if (!fields.includes(key)) return false;
}
return true;
}
/**
* @param {unknown} value
* @param {number} minimum
* @param {number} maximum
* @returns {value is number}
*/
function boundedInteger(value, minimum, maximum) {
return typeof value === 'number' && Number.isInteger(value) &&
value >= minimum && value <= maximum;
}
/**
* @param {number} seats
* @param {number} limit
* @returns {PracticeDecision_}
*/
function decisionFor(seats, limit) {
if (seats <= limit) return 'fits';
return 'tooLarge';
}
/**
* @param {PracticeState_} state
*/
function advanceRevision(state) {
if (state.revision >= 100) throw new Error('Practice revision limit reached. Inspect and deliberately clean up the exercise before restarting.');
state.revision = state.revision + 1;
}
/**
* @param {unknown} record
* @returns {string}
*/
function encodeRecord(record) {
const text = JSON.stringify(record);
if (text.length > 2048) throw new Error('Practice record exceeds the 2048-character write limit.');
return text;
}
/**
* @param {PropertiesService.Properties} store
* @param {PracticeState_} state
*/
function writeState(store, state) {
validateState(state);
store.setProperty(STATE_KEY, encodeRecord(state));
}
/**
* @param {string | null} source
* @param {PracticeState_} state
* @returns {PracticeSummary_}
*/
function cachedSummary(source, state) {
let cache = null;
let text = null;
try {
cache = CacheService.getScriptCache();
text = cache.get(SUMMARY_KEY);
} catch (error) {
cache = null;
Logger.log('Cache read unavailable; rebuilding from properties.');
}
if (text !== null && text.length <= 8192) {
let entry;
try {
entry = JSON.parse(text);
} catch (error) {
entry = null;
}
if (hasFields(entry, ['version', 'source', 'summary']) && entry.version === 1 &&
entry.source === source && validSummary(entry.summary)) {
Logger.log('Summary source: cache hit.');
return entry.summary;
}
}
const summary = { pending: 0, fits: 0, tooLarge: 0 };
for (const request of state.work) {
summary[request.decision] = summary[request.decision] + 1;
}
Logger.log('Summary source: rebuilt from properties.');
if (cache !== null) {
try {
cache.put(SUMMARY_KEY, JSON.stringify({ version: 1, source: source, summary: summary }), 60);
} catch (error) {
Logger.log('Cache write unavailable; the rebuilt summary is still usable.');
}
}
return summary;
}
/**
* @param {PracticeSummary_} summary
* @returns {boolean}
*/
function validSummary(summary) {
return hasFields(summary, ['pending', 'fits', 'tooLarge']) &&
boundedInteger(summary.pending, 0, 3) && boundedInteger(summary.fits, 0, 3) &&
boundedInteger(summary.tooLarge, 0, 3) && summary.pending + summary.fits + summary.tooLarge === 3;
}
function removeSummaryCache() {
try {
CacheService.getScriptCache().remove(SUMMARY_KEY);
Logger.log('Exercise summary cache removal requested.');
} catch (error) {
Logger.log('Cache removal unavailable. Any leftover entry is disposable, not authoritative state.');
}
}
appsscript.json
Download file{
"timeZone": "Etc/UTC",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": []
}