Snapshot Restore vs Process Preforking: Same Idea, Different Boundary
Preforking is one of the oldest good ideas in systems programming, and it is still, in 2026, running a shocking fraction of the internet. Load the world once in a parent process, call fork() a few dozen times, and every child gets the fully-initialized application for what feels like free. Unicorn does it. Puma in cluster mode does it. gunicorn does it when you set preload_app. uWSGI does it unless you explicitly ask it not to. Android boots a process called the Zygote whose entire job is to be forked. Chrome runs a zygote too, so that new renderer processes don't each pay the cost of loading the browser's shared libraries.
Snapshot restore is the same idea moved down a level: instead of freezing a process and copying it within one machine, you freeze the whole machine and restore it anywhere. I'm Ajay, I built PandaStack on the second model, and I want to be honest up front that these two techniques are not competitors so much as different-sized hammers. Preforking is cheaper, faster, and older. Snapshot restore is the one that crosses a security boundary and survives a reboot. Most of the confusion I see comes from people expecting one of them to do the other's job.
How preforking actually works
The mechanism is worth spelling out precisely, because the interesting properties fall directly out of it. A parent process starts, imports its whole dependency tree, parses config, builds the routing table, compiles templates, warms whatever caches it warms — the expensive part. Then it calls fork(), once per worker.
fork() does not copy the parent's memory. It creates a new process that shares the parent's physical pages, and it walks the page tables marking every writable private mapping read-only in both parent and child. Nothing has been copied yet. The moment either process writes to one of those pages, the CPU raises a write-protection fault, the kernel traps it, allocates one fresh physical page, copies 4 KiB into it, remaps the writer to the private copy, and lets the instruction retry. That's copy-on-write, and it's the entire reason preforking is cheap: forking a process with a two-gigabyte heap costs page-table setup and reference counting, not two gigabytes of memcpy.
/* cow.c -- watch copy-on-write happen. Build: cc -O0 -o cow cow.c
*
* The parent allocates a big buffer and touches it, so the pages are
* really resident. Then it forks. The child reads the whole buffer
* (shared, free) and writes to exactly one page (a private copy).
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
#define PAGES (256 * 1024) /* 1 GiB at 4 KiB pages */
#define PGSZ 4096
int main(void) {
char *buf = malloc((size_t)PAGES * PGSZ);
memset(buf, 'a', (size_t)PAGES * PGSZ); /* fault them all in */
printf("parent: 1 GiB resident, about to fork\n");
pid_t pid = fork(); /* no memcpy happens here */
if (pid == 0) {
/* READ the whole buffer: every page is still SHARED with the
* parent. This costs zero extra physical memory. */
unsigned long sum = 0;
for (long i = 0; i < PAGES; i++) sum += buf[i * PGSZ];
printf("child: read 1 GiB shared, sum=%lu\n", sum);
/* WRITE one byte: the CPU faults, the kernel copies ONE page.
* Total cost of the child's divergence so far: 4 KiB. */
buf[42 * PGSZ] = 'b';
printf("child: wrote 1 byte -> 1 private page (4 KiB), not 1 GiB\n");
_exit(0);
}
wait(NULL);
/* Compare RSS of the two processes and you will find they do NOT
* add up to 2 GiB. Most of it was never copied at all. */
return 0;
}There are nuances that decide how much you actually save. Reference-counted or garbage-collected runtimes are the classic spoiler: CPython's reference counts live in the object header, so merely reading an object writes to its page, and a child that walks a large shared data structure can end up dirtying most of it without mutating a single logical value. CPython added gc.freeze() partly to mitigate this, moving long-lived objects out of the collector's write path before you fork. Ruby's GC had the same problem and grew copy-on-write-friendly marking to address it. A tracing GC that walks the heap during a collection can dirty everything it touches for the same reason. And glibc's allocator keeps per-thread arenas with metadata interleaved among your data, so allocation activity in a child can dirty pages that hold mostly parent data.
Copy-on-write is a bet that children mostly read what they inherited. Every runtime that writes while reading is quietly collecting on the other side of that bet.
What preforking buys you, cheaply
I don't want to undersell it. Preforking is one of the highest-leverage tricks available to a process-based server, and the reason it survived forty years of fashion cycles is that it genuinely works.
- Worker creation is essentially free. There's no exec, no dynamic linking, no re-import, no config parse. The child begins life at the exact instruction after fork() with everything the parent had. On a busy server that's the difference between recycling a worker being a routine event and being an outage.
- The shared read-only footprint is paid once. Interpreter code, mapped shared libraries, compiled bytecode, template caches, read-only reference data — every worker sees them, one physical copy backs them all. Density improves in proportion to how much of your footprint is read-mostly.
- The math is done by hardware. There's no bookkeeping layer to maintain and nothing to tune. The MMU raises a fault, the kernel copies a page, and the accounting is exact and automatic.
- Worker recycling is trivially cheap. Kill a worker that's leaked memory or wedged itself, fork a replacement from the still-clean parent, and you're back to the warm baseline without re-initializing anything.
- It composes with the accept() model. Workers can inherit a listening socket from the parent and accept connections independently, so there's no dispatcher process in the data path.
- It's already in your stack. gunicorn, uWSGI, Unicorn, Puma cluster mode, php-fpm, Apache's prefork MPM — you very likely have this running today whether or not you configured it deliberately.
# gunicorn.conf.py equivalent, written as config for clarity.
# preload_app is the whole ballgame: import the app in the MASTER,
# then fork workers from it. Without it, every worker imports
# your dependency tree independently and shares nothing.
[gunicorn]
bind = 0.0.0.0:8000
workers = 8
preload_app = true # <-- load once in the parent, fork 8 children
max_requests = 1000 # recycle workers to bound leak damage
# uWSGI expresses the same choice with two opposite flags:
#
# --lazy-apps each worker imports the app itself.
# No CoW sharing. Slower start, MORE memory,
# but each worker is independent -- which is what
# you want if your app is not fork-safe.
#
# (default/master) import once in the master, fork workers.
# CoW sharing, fast workers, and every fork-safety
# landmine below is now yours to own.
[uwsgi]
http = 0.0.0.0:8000
master = true
processes = 8
lazy-apps = false # false == prefork == share memory == be careful
# The tell that you got this wrong: total RSS scales linearly with
# `workers`, and your "shared" baseline isn't shared at all.The limits: it's a performance technique with zero isolation
Here is the sentence that matters most in this whole post: fork() is not a security boundary. Preforked workers share one kernel, one user account, one filesystem view, one network namespace, one set of capabilities, and — unless you went out of your way — one cgroup. A child can read the same files the parent could. It can dial the same internal services. It can see the same environment variables, including the secrets you passed in that way. If it finds a kernel bug, it is exploiting the same kernel that every other worker is running on.
For the workload preforking was designed for — your own trusted application code, serving your own traffic — that's completely fine, and paying for isolation you don't need would be silly. It stops being fine the moment the code in that worker is not code you wrote. Running user-submitted scripts, plugin code from a marketplace, or output from a language model inside a preforked worker means every one of those workers shares everything with every other, which is great right up until one of them is executing something nobody reviewed.
The fork-safety landmines
The second class of limitation is subtler and causes more 3am pages. fork() duplicates the calling thread and only the calling thread. Every other thread in the parent simply does not exist in the child — but all of the state those threads were manipulating comes across intact, mid-manipulation.
- Locks held at fork time. If another thread held a mutex the instant you forked, the child inherits that mutex in the locked state with no thread alive to unlock it. The first child that tries to acquire it blocks forever. This is a very patient bug: it waits for load, for a particular interleaving, for the worst possible Tuesday. POSIX's answer is that after fork() a multithreaded child may only call async-signal-safe functions until it execs, which is a rule almost nobody follows and almost everybody gets away with.
- Allocator and runtime internals. malloc has locks. So do the logging library, the DNS resolver, the profiler, and the runtime's own GC. pthread_atfork exists to let libraries fix up their state around a fork, and its existence is an admission of how many things need fixing.
- Inherited file descriptors. Every open fd crosses the fork, including sockets. Two processes writing to one socket produce interleaved garbage; two processes reading from one connection produce a stolen response. Both children also keep the fd's offset shared, which surprises people writing to a shared log file.
- Database and cache connections. This is the single most common real-world instance of the above. A connection pool created before the fork is now held by every worker at once, and they will interleave on the wire. Every ORM and driver documents this; every team learns it once anyway. The fix is to dispose of pools before forking and re-open lazily in each child.
- Inherited RNG state. Children inherit the parent's seeded PRNG state, so every worker produces the identical "random" sequence unless something re-seeds after the fork. When those values are tokens, nonces, or temp filenames, this is a security bug rather than a curiosity. It is precisely the same failure mode as cloning a VM snapshot, which is not a coincidence — see /blog/copy-on-write-memory-fork-explained for the memory-level view of the same problem.
- Cached identity. PIDs change but cached copies of them don't. Anything that memoized getpid() before the fork — a logging prefix, a lock filename, a metrics label, an ID generator seeded from the PID — is now lying in every child, and lying identically.
And you can only fork from a live parent, here, now
The final limitation is structural and no amount of care fixes it: fork() requires a living parent process on this machine, in this kernel, at this moment. That has three consequences that snapshot restore does not share.
You cannot fork across machines — a warm parent on host A does nothing for host B, which must warm up its own. You cannot fork across time — restart the process, reboot the box, or redeploy, and the warm state is simply gone; you pay the full initialization again to rebuild a parent worth forking from. And you cannot store a fork. There is no artifact. The warm state exists only as the live memory of a running process, which means you can't put it in object storage, can't version it, can't ship it to another region, and can't hand it to a scheduler as something to place. Preforking gives you a warm parent; it doesn't give you a warm thing.
Snapshot restore: the same trick, made into an artifact
Snapshot restore takes the identical insight — don't redo initialization, copy the result of it — and moves the boundary. Instead of copying a process within a kernel, you serialize an entire machine: the guest's physical RAM, the vCPU registers, and the virtual device state, at an instant. That produces files. Files can be uploaded, versioned, replicated to another region, cached on a hundred hosts, and restored six weeks later by a machine that has never met the original.
Restoring maps that memory image and unpauses the vCPUs, and the guest resumes mid-instruction with everything it had already computed still sitting there. Crucially, the copy-on-write mechanics you get for free with fork() still apply: the memory file is mapped MAP_PRIVATE, so restores of the same snapshot share physical pages until each one writes. It's the same page-fault machinery — you're just sharing across VM boundaries instead of process boundaries. On PandaStack that's roughly 49ms for the snapshot-load step, with an end-to-end create landing at 179ms p50 and about 203ms p99; the first spawn of a brand-new template is a genuine cold boot of roughly 3 seconds, once, and every create after that restores.
And unlike a forked worker, each restored guest is a full microVM: its own kernel, its own root filesystem, its own network namespace off a pool of 16,384 pre-allocated /30 subnets per agent, behind a hypervisor boundary. Two restores of the same snapshot share memory pages for efficiency and share nothing at all in terms of privilege.
from pandastack import Sandbox
# THE PREFORK EQUIVALENT, one layer down.
#
# gunicorn --preload: import once in a parent, fork() workers that
# share pages -- but only on this host, only while the parent lives,
# and with zero isolation between them.
#
# Below: initialize once in a guest, SNAPSHOT it (an artifact you can
# store, version, and restore anywhere), then fork that -- with a
# kernel boundary between every child.
parent = Sandbox.create(template="code-interpreter", ttl_seconds=1800)
# The expensive part, done exactly once -- the "preload_app" step.
parent.exec("pip install -q numpy pandas scikit-learn", timeout_seconds=300)
parent.exec("python -c 'import numpy, pandas, sklearn'") # warm the imports
# fork() has no equivalent of this line. This is the difference:
# the warm state becomes a durable artifact instead of a live process.
snap = parent.snapshot()
parent.kill()
# Each child restores that warm machine. Same-host fork 400-750ms;
# cross-host 1.2-3.5s (the artifacts move over the network first) --
# and "cross-host" is a sentence fork() cannot even express.
workers = [snap.fork(ttl_seconds=300) for _ in range(8)]
for w in workers:
# Modules already imported -- nothing re-imported them, they were
# never unimported. But unlike preforked workers, these do NOT
# share a kernel, a user, a filesystem, or a network namespace.
print(w.exec("python -c 'import pandas; print(pandas.__version__)'").stdout)
w.kill()Snapshot restore has its own bill, and it's a real one. The artifact is roughly the size of the guest's RAM, so it's big and it doesn't dedupe the way layered images do — which is why the memory gets streamed on demand rather than downloaded whole. Snapshots go stale: a frozen machine with a vulnerable library keeps waking up with that library resident until someone re-bakes, so bakes belong in your build pipeline with real cadence discipline. vCPU count and RAM are baked into the saved state, so "give this one more memory" means re-baking, not passing a flag. Restores expect the CPU features that were visible at bake time, so heterogeneous fleets need CPU templates masking a common baseline.
Head to head
Same idea, different boundary, different bill:
- What gets copied — Preforking: one process's address space within a running kernel. Snapshot restore: an entire machine — guest RAM, vCPU registers, and device state.
- Where the warm state lives — Preforking: only as the live memory of a running parent process. Snapshot restore: as files you can upload, version, replicate across regions, and cache on many hosts.
- Isolation between children — Preforking: none. Same kernel, same user, same filesystem, same network namespace, usually the same cgroup. Snapshot restore: a hypervisor boundary, a separate guest kernel, a separate root filesystem, and a separate network namespace per guest.
- Cost to create a child — Preforking: page-table setup and reference counting; no exec, no linking, no re-import. Snapshot restore: map memory, load device state, unpause vCPUs — about 49ms for the snapshot-load step on PandaStack, 179ms p50 / ~203ms p99 for a full create; a same-host fork is 400-750ms.
- Memory sharing — Preforking: copy-on-write pages shared with the parent until written; a GC or refcounting runtime can dirty them accidentally. Snapshot restore: the memory image is mapped MAP_PRIVATE, so restores share physical pages until written — the same mechanism, one level up.
- Survives a restart — Preforking: no. Kill the parent and the warm state is gone; you re-initialize from scratch. Snapshot restore: yes. The artifact outlives the process, the host, and the deploy.
- Crosses machines — Preforking: no, ever. A warm parent on host A does nothing for host B. Snapshot restore: yes — cross-host fork lands in 1.2-3.5s because the artifacts transfer first.
- Correctness hazards — Preforking: mutexes held at fork time, non-fork-safe libraries, inherited fds and connection pools, duplicated RNG state, stale cached PIDs. Snapshot restore: frozen clock, duplicated RNG state, stale connections, and staleness of the artifact itself.
- Resource shape — Preforking: children inherit whatever the parent had; limits are the host's and you can change them on restart. Snapshot restore: vCPU count and RAM are baked into the saved state; changing them means a re-bake.
- Portability — Preforking: trivially portable, it's just fork(). Snapshot restore: pickier — the CPU features present at bake time must exist at restore time, so mixed fleets need CPU templates.
- Operational surface — Preforking: one flag in a config file you already have. Snapshot restore: a real substrate — CoW memory, CoW disk, demand paging, and a bake pipeline to own.
- Best fit — Preforking: your own trusted code, many workers of one app, on one box, right now. Snapshot restore: untrusted or multi-tenant code, fleets of short-lived environments, and warm state that must survive restarts and cross hosts.
How they compose: prefork inside a restored microVM
This is the part that makes the whole framing click, and it's why I've been careful not to call these competitors. They operate at different layers, so you can stack them, and the stack is strictly better than either alone.
Restore a microVM from a snapshot. Inside that guest, run gunicorn with preload_app, or Puma in cluster mode, or uWSGI in its default prefork configuration. The snapshot handles the boundary you care about between tenants — separate kernels, separate filesystems, separate network namespaces — and fork() handles the boundary you care about between workers of one tenant, which is to say density and speed, where isolation would be pure overhead you're paying for nothing.
There's a compounding trick available here too. Bake the snapshot after the prefork parent has already loaded the world. Now a restore comes up with the interpreter warm, the framework imported, and the master process ready to fork — so the first worker is available immediately rather than after a full application init. You've cached the output of initialization in an artifact, and you're still getting the free per-worker density inside each guest. That's the zygote pattern, generalized: one warm base, forked cheaply into many children. The VM-level version, and the density math behind it, is covered in /blog/zygote-fork-snapshot-density.
- One guest per tenant, N preforked workers inside it. The hypervisor boundary is where the security is; fork() is where the density is. This is the default shape for multi-tenant app hosting and it's almost always the right one.
- Bake the snapshot with the master already warm. Restore comes up past the import phase, so worker one is servable immediately instead of after a cold init.
- Don't prefork untrusted code. If a worker is running something a language model wrote or a user uploaded, the guest boundary is the only thing protecting you, and putting several such workers in one guest hands them each other. One guest per untrusted unit of work.
- Watch for double-counting your memory savings. CoW at the VM level and CoW at the process level share different things; a write-heavy workload erodes both, and the erosion compounds rather than cancels.
Which one to reach for
Reach for preforking when you're running your own code, you want more workers of one application on one machine, and you want it configured this afternoon. Set preload_app, verify that your database pool is created after the fork and not before, re-seed your RNG in a post-fork hook, and check that total RSS is sublinear in worker count — if it isn't, you didn't get the sharing you thought you did. That's the whole project. It's cheap, it's proven, and for a huge class of services it is completely sufficient.
Reach for snapshot restore when any of four things is true. When the code is untrusted, because fork() gives you no boundary and no amount of care changes that. When the warm state has to outlive the process — surviving restarts, deploys, and host replacement, which a live parent cannot do by construction. When it has to cross machines, because a warm parent on one host is worth nothing on another and a snapshot in object storage is worth the same everywhere. Or when you're starting the same expensive environment thousands of times a day and throwing it away — at which point you are re-running one initialization on a loop, and caching its output as an artifact is the obvious move.
And reach for both when you're doing multi-tenant anything, which in practice is most of the interesting cases. The layers don't conflict. Snapshot for the boundary, fork for the density.
PandaStack is built on the restore path: every sandbox, managed database, and git-driven app comes up by restoring a baked template snapshot rather than booting, and fork() clones a running sandbox's memory and disk copy-on-write. The control-plane API and per-host agent are open source under Apache-2.0, so you can run them on your own Linux KVM hosts and time the restores against your own prefork setup. For the mechanics of the freeze-and-clone step, see /blog/snapshot-and-fork-explained; for the page-fault-level view of what sharing actually costs, /blog/copy-on-write-memory-fork-explained.
Frequently asked questions
What is the prefork model and why is it fast?
A parent process loads the entire application once — imports, config, routing tables, template caches — and then calls fork() once per worker. fork() doesn't copy the parent's memory; it creates a process sharing the parent's physical pages and marks writable mappings read-only in both. A page is only copied when one of them writes to it, which the CPU catches as a write-protection fault. So worker creation costs page-table setup rather than a full memory copy, and every worker starts with the application already initialized. gunicorn's preload_app, uWSGI's default master mode, Unicorn, Puma cluster mode, and php-fpm all work this way, as do Android's Zygote and Chrome's zygote process.
Does preforking provide any isolation between workers?
No. This is the single most important thing to understand about it. Preforked workers share one kernel, one user account, one filesystem view, one network namespace, one capability set, and usually one cgroup. A child can read every file the parent could, reach every internal service the parent could, and see every environment variable including secrets. fork() is a memory optimization that happens to produce something process-shaped, and process-shaped is not sandboxed. For your own trusted application code that's fine and paying for isolation you don't need would be wasteful. For user-submitted scripts, marketplace plugins, or LLM-generated code, preforking contributes nothing to your threat model — you need namespaces, seccomp, or a hypervisor boundary.
What are the classic fork-safety bugs in preforking servers?
fork() duplicates only the calling thread, but all of the state the other threads were manipulating comes across mid-manipulation. If another thread held a mutex at fork time, the child inherits it locked with no thread alive to release it, and the first child to try acquiring it blocks forever — a bug that waits patiently for the right load and interleaving. Open file descriptors cross the fork too, so a database connection pool created before the fork is held by every worker at once and they interleave on the wire; dispose of pools before forking and reopen lazily in each child. Children also inherit the parent's PRNG state, so every worker generates the identical 'random' sequence unless something re-seeds after the fork, which is a security bug when those values are tokens or nonces. And anything that cached getpid() before the fork is now lying identically in every child.
Why can't preforking replace snapshot restore?
Because fork() requires a living parent process, on this machine, in this kernel, right now. That means you can't fork across machines — a warm parent on host A does nothing for host B, which must warm up independently. You can't fork across time — restart the process, reboot the host, or redeploy, and the warm state is simply gone and must be rebuilt from scratch. And you can't store a fork, because there is no artifact: the warm state exists only as the live memory of a running process, so it can't be uploaded, versioned, replicated to another region, or handed to a scheduler as something to place. A snapshot is a file. Files survive restarts, cross hosts, and can be cached on a hundred machines at once.
Can I use preforking inside a restored microVM?
Yes, and it's usually the right architecture. The two techniques work at different layers, so they stack. Restore a microVM from a snapshot to get the boundary that matters between tenants — separate guest kernel, separate root filesystem, separate network namespace, hypervisor isolation — then run gunicorn with preload_app or Puma cluster mode inside that guest to get cheap copy-on-write density between workers of that one tenant, where isolation would be overhead you're paying for nothing. You can compound it by baking the snapshot after the prefork master has already loaded the application, so a restore comes up past the import phase and the first worker is servable immediately. The one rule: don't prefork untrusted code. If a worker is running something a user uploaded or a model wrote, the guest boundary is your only protection, so give each untrusted unit of work its own guest.
49ms p50 cold start. Fork, snapshot, and scale to zero.