Most software assumes the network is there. Maybe slow, maybe flaky, but basically always available. A lot of our patterns, from request-response APIs to "just retry it", quietly depend on that assumption.
Spacecraft can't make it. A satellite in low Earth orbit can only talk to a ground station while it's passing overhead. A paper on emulating space networks for the International Space Station puts typical ground contact windows at around 5–10 minutes per pass, with gaps of 45–90 minutes between them. Everything the spacecraft wants to send, and everything operators want it to do, has to fit through those windows.
That sounds exotic, but plenty of software lives with the same shape of problem: mobile apps on bad connections, field devices that sync once a day, partner integrations that only accept batches overnight. The space industry's answers translate surprisingly well.
Deep Space Network antennas at Goldstone, California. Credit: NASA/JPL-Caltech.
Treat connectivity as a scheduled resource
The first mental shift is that contact isn't a background assumption; it's a scarce, scheduled resource.
Spacecraft operations are planned around predicted passes: a NASA slide deck on delay-tolerant networking walks through an operations scenario where contact graph routing shows an upcoming acquisition of signal with a ground site, a pre-staged command is uplinked when the connection is restored, telemetry bundles are sent automatically, and bundles keep accumulating after the connection is lost.
For a backend or a mobile app, the equivalent question is: when will I next be able to talk, and what's most important to say when I can? If you can't answer that, you'll waste your windows on whatever happened to be first in line.
Store and forward
Because contact is intermittent, spacecraft networks are built on store-and-forward: data is held until a link is available, then passed on. NASA's Delay/Disruption Tolerant Networking (DTN) formalises this: nodes keep bundles of data until they can forward them, rather than dropping them when there's no end-to-end path. NASA describes DTN as an operational service on its Near Space and Deep Space networks, already used by science missions such as PACE.
The software pattern is an outbox: write the intent locally and durably first, and let a separate process deliver it when it can.
interface OutboxItem {
id: string; // stable, client-generated
kind: 'telemetry' | 'event' | 'bulk';
priority: number; // lower = more urgent
payload: unknown;
createdAt: number;
}
async function enqueue(db: Db, item: OutboxItem) {
await db.outbox.put(item); // durable before anything else happens
}
The rule that matters: nothing important lives only in memory waiting for the network.
This isn't a lab curiosity. NASA's page says the PACE ocean-observing satellite became, in 2024, the first NASA Class-B mission to use DTN operationally, for its housekeeping telemetry. DTN starts transfers when a contact occurs and handles disruptions by resuming a downlink when the link becomes available again, and NASA's stated goal is to decrease data loss while increasing insight into the network.
Resume, don't restart
That last detail deserves its own section, because it's where a lot of mobile and integration code falls down. When a transfer is interrupted, the naive fix is to start again from zero. On a ten-minute pass, restarting a large transfer from scratch can mean it never finishes.
The DTN approach is to pick up where you left off. For large payloads in software, that means chunking the data and tracking how far the far side has confirmed:
async function uploadResumable(file: Blob, uploadId: string, chunkSize = 256 * 1024) {
let offset = await api.confirmedOffset(uploadId); // ask the server what it already has
while (offset < file.size) {
const chunk = file.slice(offset, offset + chunkSize);
await api.putChunk(uploadId, offset, chunk); // idempotent per (uploadId, offset)
offset += chunk.size;
}
await api.complete(uploadId);
}
Two things make this work: the server is the source of truth for progress, and each chunk write is safe to repeat. Lose the connection at any point and the next window continues from the confirmed offset instead of from zero.
Spend the window on what matters most
When a pass is ten minutes long, you can't send everything. Spacecraft prioritise what goes down first, so that the most important data survives a short or interrupted contact.
An outbox needs the same idea. Draining in creation order means a backlog of low-value events can starve the one message that actually matters:
async function drain(db: Db, send: (item: OutboxItem) => Promise<void>, budgetMs: number) {
const deadline = Date.now() + budgetMs;
const items = (await db.outbox.all()).sort(
(a, b) => a.priority - b.priority || a.createdAt - b.createdAt,
);
for (const item of items) {
if (Date.now() > deadline) break; // the window is closing
await send(item);
await db.outbox.delete(item.id); // only after the far side has it
}
}
Notice the budgetMs. Treating each sync as a window with a deadline, not an open-ended loop, makes the behaviour predictable when connectivity disappears halfway through.
Time-tagged commands
The other direction is just as interesting. Operators can't send a command at the exact moment they want it executed, because the spacecraft may be out of contact then. So they upload commands in advance, tagged with when to run them.
NASA's open-source flight software framework includes a Stored Command application that does exactly this. It lets a spacecraft be commanded autonomously from sequences loaded in advance, supporting both absolute-time sequences (run this at a specific time) and relative-time sequences (run this a set interval after something else).
The NASA Operational Simulator for Small Satellites (NOS3) documentation adds useful detail on how these are used. Absolute-time sequences are typically used to schedule future ground passes or collection periods. Relative-time sequences send a set of commands with varying delays between them, and depending on the type of boot, a relative-time sequence runs automatically at start-up. The stored-command app itself has no logic of its own; something else, either an onboard rule or an operator, decides when to fire a sequence.
That separation is worth copying. The schedule says what and when; a separate, simpler component decides whether. And having a known sequence run at every start-up is the spacecraft version of a reconciliation job: whatever state the system wakes up in, it runs the same steps to get back to a known baseline.
The software parallel is scheduling work to execute where the data is, rather than holding a connection open to drive it step by step:
type ScheduledCommand =
| { id: string; at: string; action: Action } // absolute: ISO timestamp
| { id: string; afterMs: number; after: string; action: Action }; // relative to another command
// Upload the whole plan while you're connected; the device or worker executes it later.
await api.uploadPlan(deviceId, commands);
This is how good offline-first apps and scheduled integrations behave: agree the plan while connected, execute it independently, and report results at the next window.
Make every message safe to repeat
Intermittent links mean you often don't know whether the far side received something before the connection dropped. The safe assumption is that anything might be delivered twice.
That's why the outbox above uses a client-generated id. The receiving side should treat it as an idempotency key:
async function receive(item: OutboxItem) {
const seen = await processed.has(item.id);
if (seen) return; // already applied; acknowledge and move on
await apply(item);
await processed.add(item.id);
}
Without this, retries across a broken window turn into duplicate orders, double-counted events, or commands executed twice. With it, "send it again" becomes a safe default.
Idempotency keys and a durable outbox are the two pieces that make every other intermittent-connectivity pattern safe.
Plan the gaps, not just the contacts
Finally, spacecraft operations think as hard about the gaps as about the passes: what will the vehicle do on its own until next contact, and what data will it need to hold? A NASA Glenn paper on DTN scheduling and routing notes that transmission scheduling is still largely configured by hand from a planned contact graph, which tells you how seriously the gaps are planned.
In software terms:
- How much can the outbox hold before something has to give, and what gets dropped first?
- What does the app do on its own while offline, and what must wait?
- How does the user see that something is queued, not lost?
What I'd take away
- Treat connectivity as a scheduled, scarce resource.
- Write intent to a durable outbox before touching the network.
- Drain by priority, within a time budget.
- Upload plans while connected, and execute them locally later.
- Make every message idempotent, so resending is always safe, and resume large transfers from a confirmed offset.
- Design the gaps as carefully as the contacts.
You don't need a ground station for any of this. You just need to stop assuming the network will be there when you want it.
💡Key Takeaways
- 1Spacecraft only talk to the ground in short, scheduled windows, and design everything around that
- 2Store-and-forward, as an outbox in software, keeps intent safe until the link is available
- 3Prioritised draining within a time budget makes short windows count
- 4Time-tagged command plans let work execute without a live connection
- 5Idempotency keys make resending after a broken window safe
- 6Designing for the gaps matters as much as designing for the contacts
