Blog Post

Systems That Only Talk in Windows: Lessons from Ground Passes

Spacecraft only talk to the ground during short passes. The patterns they rely on - store-and-forward, prioritised downlink and time-tagged commands - apply directly to offline-first apps and batch integrations.

Author: Liam ReckziegelBlog Post · deep dive

Systems That Only Talk in Windows: Lessons from Ground Passes

October 18, 2026 (Today)

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.

Three large Deep Space Network dish antennas at the Goldstone complex, lit against the night sky

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.

An illustrative four-hour timeline: four short contact windows of about 5 to 10 minutes, separated by gaps of roughly 45 to 90 minutes with no contact 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.

The app writes to a durable outbox at any time; during a window the outbox drains by priority to the server, which skips ids it has seen and acknowledges; items are deleted only after the acknowledgement

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);
}
A file in six chunks: chunks one to three are sent and confirmed in window one before the link is lost; in window two the client asks for the confirmed offset and sends chunks four to six

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

  1. Treat connectivity as a scheduled, scarce resource.
  2. Write intent to a durable outbox before touching the network.
  3. Drain by priority, within a time budget.
  4. Upload plans while connected, and execute them locally later.
  5. Make every message idempotent, so resending is always safe, and resume large transfers from a confirmed offset.
  6. 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