Skip to content

Scratch org pooling

Creating a scratch org from scratch — provisioning it, installing packages, pushing all of force-app, importing data — takes the better part of an hour. That is far too slow to sit in front of on every pull request. Pooling solves this by building orgs ahead of time and handing out a ready one on demand.

Pooling is owned

Pooling lives entirely in this repository — the org-pool/ library and the DevHub metadata in force-org-pooling/ — with no external pooling dependency.

How a pool works

graph TD
    Schedule["Scheduled pipeline
    'Maintain … Pool'"] --> Create["Create a bare
    scratch org"]
    Create --> Hook{"Pool definition
    names a seedScript?"}
    Hook -->|yes| Seed["Run the seed hook"]
    Hook -->|no| Available
    Seed --> Available["Mark it Available"]
    PR["Pull request needs an org"] --> Claim{"Pool has a
    free org?"}
    Claim -->|yes| ClaimedInit["Claim it, run the
    normal init pipeline"]
    Claim -->|no| OnDemand["Create one on demand,
    run the normal init pipeline"]

A pool definition (config/project-<tag>-pool-def.json, every key in the pool definition reference) declares how many orgs a pool holds, how long they live, and optionally a seedScript hook plus a seedBaseline: the commit a seed hook that pushes metadata is expected to leave the org at. A scheduled Maintain … Pool pipeline runs org pool-prepare, whose lifecycle per org is: create a bare scratch org from the definition file, run the optional seed hook, then mark the org Available. When the pool definition also sets a seedBaseline, the org is stamped on the DevHub with the commit pool prepare's checkout is at — an assertion that the seed hook left the org's metadata at that commit, not a runtime check of what the hook actually did.

The pool state itself lives in fields on the DevHub's ScratchOrgInfo — which is why the pooling metadata in force-org-pooling/ must be deployed to the DevHub once before pooling works. That folder is deliberately not a package directory; it never rides along with a normal project deploy.

When a run needs an org it claims one from the pool (org scratch-create --use-pool) and then runs the same initialization pipeline an on-demand org gets. Which path the claim takes depends on two things: whether the org carries a stamped seed commit, and whether delta is enabled for the SCRATCH stage (DELTA_DEPLOY_SCRATCH).

  • Stamped baseline and delta enabled — the claim re-runs the package install, which skips versions already present and so costs a single org query when nothing changed, then deploys a metadata delta from the stamped commit to HEAD. Only if that delta fails does it fall back to the full metadata push, this time without re-installing packages.
  • No baseline, or delta disabled — the package install is skipped outright, because a claimed pool org already has its packages, and the metadata push runs in full.
  • Pool miss — no org was available, so one is created on demand and takes the full-install, full-push path. A miss is slower but never fatal.

Permission set assignment, the setup Apex script and the data import run last, on every path. Seeding does not make claim-time initialization skippable — it makes it cheap.

What a seed hook is for

A seed hook (see write a pool seed script) earns its place in only two ways, and both follow from the claim paths above.

The first is pre-doing claim-time work so it shrinks or no-ops. Install packages during seeding and the claim-time install step finds them already present and does nothing; push metadata to the seedBaseline commit during seeding and the claim-time push becomes a delta from that baseline to HEAD instead of the whole project. The typical hook therefore just runs org scratch-create --reuse against the fresh org — the same initialization pipeline a claim runs later — so that the claim pays only for the difference.

The second is doing org work claim-time initialization never does. A scratch-org definition's features can provision a permission set that has no metadata under force-app, so nothing in the regular deploy set ever assigns it; a side directory outside sfdx-project.json's packageDirectories never rides along with the claim-time push, pooled or on demand. Seeding is the one moment in an org's life where such things can be done once and stay done.

A hook that does neither adds seeding time without shortening a claim.

Pooled delta validation

The stored seed commit is what makes pooled orgs fast to validate against. Because the org already holds the metadata as of its seed commit, PR validation can deploy only the diff from that seed commit to your HEAD rather than the whole project — the same delta idea as delta deployments, applied to a pooled org — running RunRelevantTests. It applies only to a claimed pool org with a stamped seed commit; a freshly created org always gets the full push.

graph LR
    Seed["Org seeded at
    commit S"] --> Delta["Deploy diff
    S → HEAD"]
    Delta --> Validated["Work proportional to your
    change, not the whole project"]

The delta runs through the deploy orchestrator with the scratchinit .forceignore context, so a fallback to the full push deploys the same set the push would. A delta that fails for any reason other than Apex tests hands the verdict back to org scratch-create, which runs the full push of the same org; an Apex-test-only failure ends the run with exit 21 instead, because org plus delta already is the full state and a full push would only re-fail the same tests (see the fallback ladder).

Whether the delta actually deployed anything matters for the test run that follows the step. When components landed, RunRelevantTests has already exercised them, so the step writes a dist/delta-validated marker and the pipeline skips its separate test-suite run — parity with a static delta. An empty delta — the org already matches the branch — deploys nothing and runs no tests, so no marker is written and the suite run stays in place; a validation that ran no tests must not claim it did.

There is a subtlety worth internalising:

Pooled delta validates more than your PR

The deployed package is your change plus everything merged into the branch after the pool org was seeded — the "drift". That is by design: the org can only move forward from the state it actually has, and the combined result is exactly the state the branch will be in once your PR merges. A failure is therefore not automatically your fault — the drift may be broken. The run logs both file sets (drift, and change-under-review) so you can tell which side a failure came from. Keep pools fresh (rebuild on a schedule) to keep the drift window small.

How the validation step decides to run

The Validate against Scratch step is fronted by a gate (scratch-validation-gate.mjs) that runs before npm ci and prints true or false; the pipeline skips the step — and claims no org — only on false. The gate is dependency-free on purpose: it has to run before anything is installed. It answers false when validation is disabled for the run (no SCRATCH_ORG_VALIDATION_<NAME> match, or the DISABLE_SCRATCH_ORG_VALIDATION kill switch), or when SCRATCH_ORG_VALIDATION_SKIP_WITHOUT_METADATA is set and the pull request touches neither force-app nor .forceignore — a PR that changes only scripts or docs has nothing a scratch org could tell it. Any error inside the gate — a git hiccup, a missing target branch — fails open: the validation runs. A glitch may cost a pool org; it never silently skips a real check.

Why the seed hook runs out-of-process

The seedScript hook (see write a pool seed script) gets a run(file, args) helper rather than being left to call other modules directly. A seed hook typically delegates to org scratch-create --reuse — the same initialization pipeline a claim runs later — or shells out to other CLI work, and that work mutates process state: environment variables, cwd, signal handlers. Parallel seedings share one Node process during pool prepare, so an in-process call from one seeding's hook would leak that mutated state into every other seeding running alongside it. run spawns node <file> ...args as a real child process instead, giving each seeding its own process — and its own git worktree — so seedings running in parallel cannot step on each other. Work that is only a plain API call against targetOrgConnection, rather than a CLI invocation, does not mutate process state and needs no run.

The same isolation is why seedConcurrency is a property of the runner, not of the pool size. Each seeding is a full scratch-org initialization in its own process tree — a Node child, the sf CLI it spawns, a worktree of its own — and a standard pipeline runner carries about four of those before it starves them all. Topping up a larger pool is done with more parallel pipeline steps (maxOrgsPerRun per step), not with a higher concurrency per step.

Where this lives

  • The pooling library: scripts/dia-scripts/org-pool/ (claim, pool definition, pool state, seed, seed-baseline, scratch-org auth, DevHub helpers, concurrency).
  • Commands: the org pool-* family and org scratch-create (see the CLI reference).
  • DevHub metadata: force-org-pooling/, deployed once via org pool-activate.
  • Pool definitions: config/project-<tag>-pool-def.json (see the pool definition reference).
  • Pooling's use in PR validation is optional — see how to validate a pull request against a scratch org.