Skip to main content
Digital SignageAndroidOffline-FirstSaaSSystem Design

Offline Digital Signage: How Our Android Player Survives Outages

ZsTechLabs Team·September 18, 2026·8 min read

A signage screen has one job: keep showing the right content, and a black rectangle over a shop counter is a visible failure. That's why we treated offline digital signage as the core design constraint in Veysign, our own signage platform, and not as an edge case. The Android digital signage player has to keep playing when the venue's internet drops. It must never show a playlist that only half downloaded, and it has to pick up changes cheaply when the connection comes back. This post covers the content sync design behind that.

The server decides; the device keeps a copy

In Veysign the server is authoritative. It resolves schedules and produces a versioned content manifest per device, and the player only has to render what the manifest says. Every manifest carries valid_from, valid_until, server_time, timezone and next_refresh_at, so a player knows how long its current content stays valid and when to check back.

Two details in how the manifest is versioned turned out to matter:

  • The version only increments when the resolved content actually changes. It is derived from a hash of what produced the manifest: the resolution source, the schedule and when it was last updated, and the playlist and its version. It is compared against the device's previous manifest.
  • Media is immutable once published. Replacing an image or video creates a new object with a new checksum. The server never overwrites a published file in place. This rule is what makes the player-side verification described below trustworthy: a checksum in a manifest always refers to exactly one set of bytes.

The manifest also returns each media asset's ID, SHA-256 checksum, size and download URL. Media is served from S3-compatible object storage through short-lived signed URLs, so the app server issues a redirect and the bytes come straight from storage.

How the Android digital signage player stages, verifies, then swaps

The central rule of the player is that it never changes its active state in place. A sync runs as a staged pipeline:

  1. Fetch the manifest, sending the ETag of the currently active one.
  2. If it changed, treat it as a candidate, not as the new truth.
  3. Compare the required media against the local cache by SHA-256.
  4. Download missing or changed assets into a separate staging directory.
  5. Verify the size and checksum of every asset.
  6. Only when every asset verifies, activate the candidate atomically.
  7. Keep the previous working manifest and its media until the new one is confirmed stable.

The downloader enforces step 5 before a file can reach the real media directory:

val stagingFile = File(stagingDir, "${media.id}.download")
download(media.url, stagingFile)

if (media.sizeBytes != null && stagingFile.length() != media.sizeBytes) {
    stagingFile.delete()
    error("Size mismatch for ${media.id}")
}
if (media.checksumSha256 != null && sha256(stagingFile) != media.checksumSha256) {
    stagingFile.delete()
    error("Checksum mismatch for ${media.id}")
}
if (!stagingFile.renameTo(finalFile)) { /* delete and fail */ }

Assets already in the cache with a matching checksum are reused, not downloaded again. Before a download starts, the player also checks free storage and refuses any download that would leave less than a fixed safety margin. A clean refusal up front is easier to handle than a disk-full error halfway through a write.

One detail is easy to get wrong. "Never activate a partially downloaded playlist" also has to mean never partially persist one. A half-written manifest row in the local Room database is as dangerous as a half-downloaded video. So the sync repository downloads and verifies every asset before it writes anything about the candidate to the database. If any asset fails, nothing about that manifest is stored, and the sync reports a failure while the current content keeps playing.

The activation itself is a single database transaction that switches which row is active:

@Transaction
suspend fun activate(version: Long) {
    deactivateAll()
    markActive(version)
}

Because both statements run in one transaction, the playback layer can never see zero active manifests or two. After a successful activation the player keeps exactly one generation back as a fallback. If a new manifest keeps crashing the player, the crash-recovery path can return to the last known-good manifest. Anything older is pruned, along with media that no manifest references any more.

The result: a failed or partial sync leaves the previous manifest playing. A bad update can't blank a screen. The cost is some extra local storage while the old and candidate media sit side by side, and the post-activation cleanup keeps that bounded.

Cheap digital signage content sync: ETag, 304 and a cached manifest

With offline tolerance in place, the next problem is scale. Every screen in a fleet polls for changes. On Android the poll runs as a periodic WorkManager job every 15 minutes, and only when the device has network connectivity. If every poll ran the full schedule resolution and playlist queries, the database would do work that almost always ends in "nothing changed".

Two layers keep this digital signage content sync cheap.

Conditional requests. The manifest endpoint supports If-None-Match. When nothing has changed, it returns 304 Not Modified with no body. The ETag is a hash of the content hash, valid_until and any pre-staged upcoming transition. It changes exactly when the content or its validity boundary changes, and not on every request, because server_time is deliberately left out. We didn't write our own header parsing: the Laravel controller uses Symfony's built-in setEtag() and isNotModified(), which already handle quoting, weak comparison and multi-value If-None-Match lists.

A per-device manifest cache. The built payload and its ETag are stored per device in the application cache (Redis in production). The cache TTL equals the manifest's own expiry. The controller checks the cache first:

$cached = $cache->get($device);

if ($cached !== null) {
    [$payload, $etag] = [$cached['payload'], $cached['etag']];
} else {
    $manifest = $builder->build($device);
    // strip internal bookkeeping, then cache until the manifest expires
    $cache->put($device, $payload, $etag, $manifest->expires_at);
}

$response = response()->json($payload)->setEtag($etag);
$response->isNotModified($request);

When a device polls more often than its manifest changes, the poll costs one cache read and, usually, an empty 304. The expensive build only runs on a cache miss: the first request from a device, or when the TTL runs out at the manifest's own validity boundary.

One trade-off is deliberate. Cache entries are not invalidated when a schedule or playlist is edited mid-window. Invalidating them would mean fanning out to every device a changed schedule, group or location might affect, which is a much bigger feature than read-side caching. Staleness is instead bounded by the manifest's conservative valid_until.

The schedule fallback chain

"What should this screen show right now?" needs a deterministic answer, including when nobody has scheduled anything. The resolver works through an explicit fallback chain:

  1. A matching active schedule. Schedules can target a device, a device group or a location. Candidates are ranked by priority, then specificity (device beats group beats location), then most recently updated, then ID as a final tie-breaker.
  2. The device's default playlist.
  3. The location's default playlist.
  4. Nothing: an explicit no-content state.

The location default wasn't in the original schema. The spec asked for a "location fallback" tier, but by the time a device reaches the fallback tiers, location-targeted schedules have already been considered and ruled out. We added an explicit location-level default playlist so the third tier means something real. The delete rules differ on purpose: deleting a playlist removes any schedule or device assignment that points to it, but only clears a location's default. It never deletes the location.

The four-level ranking is written as chained stable sorts, least significant key first. That is easier to get right than one multi-key comparator:

return $candidates
    ->sortBy(fn ($s) => $s->getKey())
    ->sortByDesc(fn ($s) => $s->updated_at->getTimestamp())
    ->sortByDesc(fn ($s) => self::SPECIFICITY[$s->target_type])
    ->sortByDesc('priority')
    ->first();

The resolution source also sets the manifest's validity window. A matching schedule is valid until that schedule's own end time. A fallback is valid until the end of the day in the device's effective timezone. A device with nothing to show gets a short, configurable retry window, so a newly set up screen picks up its content quickly instead of waiting until tomorrow.

Schedules run in their own timezone

A breakfast menu that should switch at 11:00 has to switch at 11:00 where the screen is, whatever timezone the server uses. So each schedule stores its own timezone. Start and end times, days of the week and date ranges are all evaluated after converting the moment being resolved into that timezone:

$local = $at->copy()->setTimezone($schedule->timezone);

The server resolves in UTC and never compares business hours against server time directly. One organisation can then run screens across several timezones without any per-server configuration.

Switching on time without a network call

There is one more piece. When the server can see an upcoming schedule change within a configurable lookahead window, it includes a next_transition in the manifest. That is a timestamp plus the complete manifest that applies from that moment. The player stages that future manifest with the same download-and-verify pipeline, and the playback layer activates it locally when the time comes. Every screen that carries the same transition switches together, whenever each one last polled, and the switch works even if the network is down at that moment. If pre-staging fails, the regular poll fetches the correct manifest once the transition time has passed.

What we'd tell anyone building offline-first sync

  • Make the server authoritative and keep the device responsible for rendering only.
  • Make published media immutable so a checksum means something.
  • Stage, verify, then activate in one transaction, and keep one known-good generation for rollback.
  • Make "nothing changed" the cheapest response your API has.
  • Write your fallback order down, and store timezones on the business object, not the server.

Veysign is live at veysign.com if you want to see the result on a screen. If you're building a product with similar constraints, such as field devices, kiosks, or anything that has to keep working through bad connectivity, our SaaS product development team designs and builds these systems end to end.