# Airtable Scripting Runtime Constraints

This document is the canonical reference for Airtable's automation scripting sandbox limitations. These constraints are empirically confirmed through production usage, not just documentation.

---

## Hard Limits

### Execution Time: 180 seconds

The script is killed at 180 seconds with no warning. There is no grace period, no final callback, no way to catch the termination. Any in-progress `fetch()` call or Airtable write is aborted.

**Mitigation:** Set a time budget of 170,000ms (170s) and check `timeRemaining()` before starting any new expensive operation. Reserve the final 10 seconds for cleanup writes (status updates, error logging, resync flag resets).

For scripts that run within automation chains, each script step in the chain gets its own 180-second budget. A 4-step chain gets 4 x 180s = 12 minutes of total execution time.

### fetch() Calls: 50 per script

This is a hard limit across the entire script execution. Every call to `fetch()` counts, including retries, token refreshes, and pagination. There is no way to increase this limit.

**Mitigation:** Use a `trackedFetch()` wrapper that counts calls and throws a clear error before hitting the ceiling. Plan API pagination strategies around the 50-call budget: if an API returns 200 items per page and you need 5,000 items, you need 25 pages = 25 fetch calls, leaving 25 for retries and auth.

### No setTimeout or setInterval

The Airtable scripting runtime does not have `setTimeout`, `setInterval`, or `queueMicrotask`. Any code that uses Promise-based delays (`new Promise(r => setTimeout(r, ms))`) will throw `setTimeout is not defined` at the point where the delay is actually triggered.

This error is sneaky because:
- It only surfaces when the delay code path actually executes
- If you have retry logic that rarely triggers, it might work for weeks before failing
- The error message does not always clearly indicate which line caused it

**The only option:** Synchronous busy-wait loops.

```javascript
function busyWait(ms) {
  var end = Date.now() + ms;
  while (Date.now() < end) {}
}
```

This blocks the event loop and burns CPU, but it is the only mechanism available. Keep delays short (100-2000ms typically).

### input.config() Called Once

`input.config()` returns all input variables as an object. Calling it a second time throws: `input.config may only be called once`. This is enforced at the runtime level.

```javascript
// Always do this at the top of the script
var config = input.config();
```

### input.secret() Is Separate

Secrets stored in Airtable's secret vault are accessed via `input.secret("SecretName")`. This is a separate mechanism from `input.config()` and can be called multiple times. Secrets cannot be passed through input variables.

Common secret names (following the naming convention used in AirtOPSy):
- `MarketoProdAirPulseClientId`
- `MarketoProdAirPulseClientSecret`
- `Iterable-Prod-API-Key`
- `DATABRICKS_PAT`

---

## Soft Limits and Gotchas

### richText Fields Do Not Serialize Through input.config()

If you pass a richText field as an input variable to a scripting step, the content may be truncated, garbled, or empty. This is because richText fields contain structured formatting data that does not survive the input serialization.

**Always read richText directly:**
```javascript
var table = base.getTable("Table Name");
var record = await table.selectRecordAsync(recordId);
var richContent = record.getCellValueAsString("My Rich Text Field");
```

This returns the plain-text representation of the rich text, which is reliable.

### output.set() Size Limits

The `output.set(key, value)` function has an approximate 100KB string size limit. Large JSON payloads silently truncate. If you need to pass large datasets between automation steps, consider writing to an Airtable field and reading it in the next step.

### Batch Write Sizes

- `createRecordsAsync()`: max 50 records per call
- `updateRecordsAsync()`: max 50 records per call
- `deleteRecordsAsync()`: max 50 records per call

Always batch your writes in chunks of 50 with a loop.

### selectRecordsAsync() Loads Everything

`selectRecordsAsync()` loads all matching records into memory. For large tables (10,000+ records), always specify a `fields` array to limit the data loaded:

```javascript
// GOOD: only loads the fields you need
var query = await table.selectRecordsAsync({
  fields: ["Name", "Status", "ID"]
});

// BAD: loads every field on every record
var query = await table.selectRecordsAsync();
```

### Template Literal Inconsistency

Airtable's scripting runtime uses an older JavaScript engine. Template literals (backtick strings) usually work, but can cause subtle issues after multi-edit patches where invisible characters creep in. String concatenation is always safe:

```javascript
// Safer
console.log("Found " + count + " records in " + elapsed() + "ms");

// Usually fine but occasionally breaks
console.log(`Found ${count} records in ${elapsed()}ms`);
```

### No Top-Level await in Some Contexts

While Airtable scripts generally support top-level `await`, wrapping the main logic in a `try/catch` at the top level is more reliable for error handling:

```javascript
try {
  var result = await someAsyncWork();
} catch (err) {
  console.log("FATAL: " + err.message);
  // Write error state to Airtable
}
```

---

## Concurrency Model

Airtable automations are single-threaded within a script, but multiple automations can run concurrently across the same base:

- A single automation trigger can fire multiple runs if multiple records match the trigger condition simultaneously
- Different automations can run in parallel
- All concurrent scripts share the same API rate limits on external services (Marketo, Iterable, etc.)

This means retry logic must account for stampeding: if 20 automations all hit a rate limit and retry at the same deterministic interval, they will all retry simultaneously and hit the rate limit again. Use equal jitter backoff to spread retries.

---

## What Airtable Scripts CAN Do

For clarity, here are capabilities that are available:

- `async/await` syntax
- `fetch()` for HTTP requests (up to 50 calls)
- `base.getTable()`, `selectRecordsAsync()`, `createRecordsAsync()`, `updateRecordsAsync()`, `deleteRecordsAsync()`
- `input.config()` (once), `input.secret()` (multiple times), `output.set()`
- `console.log()`, `console.error()`
- `JSON.parse()`, `JSON.stringify()`
- `new Date()`, `Date.now()`, `Date.parse()`
- `Math.*`, `String.*`, `Array.*` methods
- `Map`, `Set`, `Promise`, `RegExp`
- `encodeURIComponent()`, `decodeURIComponent()`
- `try/catch/finally`
- `var`, `let`, `const` (prefer `var` for maximum compatibility)
