Skip to content
Tech Stack2 November 2026 · 15 min read

Build an Offline PDF Library in a Flutter App

A PDF marked available offline that will not open usually means the app trusted a download it never checked. Write to a .part file, verify size and hash, then promote it.

Build an Offline PDF Library in a Flutter App

What you'll learn

  • Match the symptom to the cause
  • The catalog and the local record
  • Choose a folder the system will not clear
Article contents

A user taps Download on a manual, sees a check mark, and opens it on a plane. The viewer says the file is damaged. Another user finds that a document marked available offline is gone after a few weeks, and a third keeps reading an old version after the remote copy changed. Each of these comes from the same shortcut: the app treats "a download started" or "a file exists" as proof that a usable copy is on the device.

The short answer is to make "available offline" a recorded state that the app sets only after it has checked the bytes. Download into a temporary .part file next to the final location, compare the byte count and SHA-256 hash with what your catalog says the document should be, check the PDF header and end marker, and only then rename the file and write the record. Keep the previous verified copy until the new one passes. Store the files where the operating system will not clear them on its own, track a storage budget, and let the user delete one document without touching the rest.

This guide builds that library in plain Dart for Flutter's Android, iOS, macOS, Windows and Linux targets. The download and verification code below ran against a local test server; the Flutter wiring, the PDF viewer and the platform-specific folder behavior are documented rather than tested here, and the article marks which is which.

Flow diagram of one download. The app writes handbook.v2.pdf.part, then checks that the byte count equals the catalog size, that the SHA-256 hash matches the catalog hash, and that the file starts with %PDF- and ends with %%EOF. Any failed check, a cut-off transfer or a full disk deletes the .part file and keeps the v1 copy. When all checks pass, the app renames the file to handbook.v2.pdf, saves the record as v2 available and then deletes handbook.v1.pdf.

The red box is where most bugs hide. If any check fails, the half-written .part file is deleted and the copy the user already had stays readable. The record that powers the "available offline" badge changes in only one place, after every check has passed.

Match the symptom to the cause

Before changing code, find which of these your app is doing. Each symptom has a different fix.

What the user seesLikely causeFix in this guide
Badge says offline, viewer says the file is damagedThe flag was set when the download started, or a cut-off transfer was saved under the final nameWrite to .part, verify, then rename
A login or error page opens instead of the PDFThe server returned HTML with status 200 and the app saved itCompare size and hash with the catalog
Downloaded files vanish weeks laterFiles were stored in a cache or temporary folder the system may clearUse the app support folder and check the file before opening
An old version opens after the document changedNo version is stored with the local copyStore the version and compare it on refresh
Download fails on a phone with little space and leaves junkPartial files are never cleaned upDelete .part on failure and at startup
Removing one document clears the whole libraryRemoval deletes a folder instead of one fileDelete one file by its recorded name

The catalog and the local record

Verification needs something to verify against. The app's catalog, whether a Firestore collection, a JSON endpoint or Cloud Storage metadata, should describe each document with five fields: a stable id, an integer version, the download url, the exact sizeBytes and a sha256 of the file. Produce the hash on the server when the PDF is published. If the files live in Cloud Storage for Firebase, the FullMetadata class also exposes size, md5Hash and generation, which can serve the same purpose if you compare the Base64 MD5 value instead of SHA-256.

On the device, keep a small index with one record per document. The demo uses a JSON file written with the same write-then-rename pattern, but a local database works too.

class RemoteDoc {
  RemoteDoc({required this.id, required this.version, required this.url,
      required this.sizeBytes, required this.sha256});
  final String id;
  final int version;
  final Uri url;
  final int sizeBytes;
  final String sha256; // lowercase hex
}

enum CopyState { downloading, available, outdated, failed }

A record is available when a verified file is on disk and matches the current catalog version, and outdated when the file is verified but the catalog now lists a newer version. An outdated copy still opens. The downloading and failed states describe the latest attempt and are kept apart from the verified copy, so a failed update never replaces a good file in the index. There is no stored "not downloaded" state: no record means no copy.

Choose a folder the system will not clear

Use getApplicationSupportDirectory from path_provider for the library. Its documentation describes it as the place for app files you do not want exposed to the user, and maps it to NSApplicationSupportDirectory on iOS and macOS and to the app's files directory on Android. The path_provider package supports Android, iOS, Linux, macOS and Windows. It does not list web, and dart:io cannot be imported by web apps, so this design does not apply to Flutter web.

FolderWhat the platform documentation saysUse it for the library?
TemporaryApple says the system periodically purges tmp/ when the app is not runningNo
CacheAndroid may delete cache files when internal storage is low; iOS may delete Caches/ when the device is very low on spaceOnly if a copy that silently disappears is acceptable
Application documentspath_provider reserves it for user-generated data that cannot be recreated; on iOS it can be exposed through file sharingNo, unless users should see the PDFs
Application supportNot described as purgeable on either platform; removed on uninstallYes

Two platform details remain. Apple's guide says files in Documents/ and Application Support/ are backed up by default and that any file that can be downloaded again must be excluded from the backup. Flutter has no built-in call for that, so an iOS app needs a small platform channel that sets isExcludedFromBackup on each saved PDF; this guide did not test one. On Android, the app-specific storage page says these files are removed when the app is uninstalled and that internal storage tends to be small.

Even in the support folder, check that the file exists and has the recorded size before you open it. Users clear app data and restore devices. The demo's openable method drops the record when the file is missing, so the badge falls back to Download instead of promising a file that is gone.

Download to a temporary file and promote only verified bytes

The .part file sits in the same folder as the final file. That matters because the Dart File.rename documentation warns that on some platforms a rename cannot move a file between file systems, and the cache or temporary folder may not be on the same one as the support folder.

Future<File> download(RemoteDoc doc) async {
  final previous = _copies[doc.id];
  if (usedBytes - (previous?.sizeBytes ?? 0) + doc.sizeBytes > budgetBytes) {
    throw _fail(doc, 'storage-budget');
  }
  final name = '${doc.id}.v${doc.version}.pdf';
  final part = File('${dir.path}/$name.part');
  final target = File('${dir.path}/$name');
  _attempts[doc.id] = LocalCopy(
      id: doc.id, version: doc.version, state: CopyState.downloading);

  IOSink? sink;
  try {
    final request = await _client.getUrl(doc.url);
    request.headers.set(HttpHeaders.acceptEncodingHeader, 'identity');
    final response = await request.close();
    if ([403, 404, 410].contains(response.statusCode)) {
      await response.drain<void>();
      throw DownloadFailure('stale-link-${response.statusCode}');
    }
    if (response.statusCode != 200) {
      await response.drain<void>();
      throw DownloadFailure('http-${response.statusCode}');
    }

    sink = part.openWrite();
    final digest = _DigestSink();
    final hasher = sha256.startChunkedConversion(digest);
    var written = 0;
    await for (final chunk in response) {
      written += chunk.length;
      if (written > doc.sizeBytes) throw DownloadFailure('too-large');
      hasher.add(chunk);
      sink.add(chunk);
    }
    await sink.flush();
    await sink.close();
    sink = null;
    hasher.close();

    if (written != doc.sizeBytes) throw DownloadFailure('incomplete');
    if (digest.value.toString() != doc.sha256) {
      throw DownloadFailure('checksum');
    }
    if (!await _looksLikePdf(part)) throw DownloadFailure('not-a-pdf');

    await part.rename(target.path);
    _copies[doc.id] = LocalCopy(id: doc.id, version: doc.version,
        state: CopyState.available, fileName: name, sizeBytes: doc.sizeBytes);
    await _saveIndex();
    _attempts.remove(doc.id);
    if (previous?.fileName != null && previous!.fileName != name) {
      final old = File('${dir.path}/${previous.fileName}');
      if (await old.exists()) await old.delete();
    }
    return target;
  } on DownloadFailure catch (e) {
    throw _fail(doc, e.reason);
  } on FileSystemException catch (e) {
    throw _fail(doc, 'disk:${e.osError?.errorCode}');
  } on HttpException {
    throw _fail(doc, 'interrupted');
  } on SocketException {
    throw _fail(doc, 'offline');
  } finally {
    try { await sink?.close(); } catch (_) {}
    if (await part.exists()) await part.delete();
  }
}

_fail stores a failed attempt with its reason and returns the exception; it never touches the verified record. The sha256 and startChunkedConversion calls come from package:crypto, and _DigestSink is a five-line Sink<Digest> that keeps the result. The full file has about 250 lines including the index, refresh and remove.

The Accept-Encoding: identity header is there because Dart's HttpClient decompresses gzip responses by default, and the autoUncompress documentation notes that Content-Length then still reports the compressed size. Comparing against the catalog's sizeBytes instead of a response header avoids that mismatch entirely.

If the files are in Cloud Storage for Firebase, you can fetch the URL with getDownloadURL() and keep this code, or call writeToFile on the reference. The download guide says writeToFile returns a DownloadTask with running, paused, success, canceled and error states. Point it at the .part file and run the same checks before renaming. Avoid getData() for PDFs: it loads the whole file into memory and takes a maximum size.

What the PDF check does and does not prove

The PDF specification, ISO 32000-1 sections 7.5.2 and 7.5.5, says the first line of a PDF is a header beginning with %PDF- and the last line contains only %%EOF. The demo reads the first five bytes and looks for %%EOF in the last 1,024 bytes, allowing for trailing line breaks. That catches a saved HTML page or a truncated file when no catalog hash is available. It does not prove the document renders. With a matching SHA-256 from your own catalog, the check is a second line of defense; the hash is what proves you have the exact file you published.

Respect a storage budget and remove one copy at a time

A short black shelf packed with identical off-white folders between two copper bookends. A hand pulls a single folder out from the middle, leaving a gap one folder wide while every other folder stays in place.

Two different limits apply, and the app should report them differently. The first is a budget you choose, such as 200 MB for downloaded documents. The demo adds the recorded sizes of verified copies and refuses a download that would exceed the budget, counting the space an older version will free. The user sees "Library full, remove a document" before any bytes move.

The second limit is the device itself. Android's storage guidance says you may skip a free-space check and catch the write error instead, and offers getAllocatableBytes() to ask how much space the device can provide, which Flutter reaches through a platform channel or plugin. The demo catches the FileSystemException, deletes the .part file and records a disk failure. The run below filled a real 2 MB file system and got the Linux error code for a full device.

Removal deletes one recorded file and one record:

Future<void> remove(String id) async {
  final c = _copies.remove(id);
  _attempts.remove(id);
  await _saveIndex();
  if (c?.fileName != null) {
    final f = File('${dir.path}/${c!.fileName}');
    if (await f.exists()) await f.delete();
  }
}

The record is saved before the file is deleted. If the app stops between the two steps, the result is an unlisted file rather than a listed file that does not exist. A startup sweep can delete PDFs that no record references, the same way load() already deletes leftover .part files.

Keep the old copy while a new version downloads

Timeline of a version update. When the catalog says v2, v1 is marked outdated and still opens. While v2 downloads to a .part file, v1 still opens. If the v2 download is cut off, the .part file is deleted and v1 still opens. When v2 is verified, it is renamed and recorded and v2 opens. Cleanup then deletes the v1 file.

Run refresh with the current catalog whenever the app is online. It marks any copy with a lower version as outdated and leaves the file alone, so the user can keep reading and choose when to update. Show the difference in the list, for example "Version 2 available", rather than hiding the old copy.

A stale link is a separate case. When the catalog URL returns 403, 404 or 410, the demo records stale-link and keeps the local copy. Signed or tokenized URLs expire, so fetch a fresh URL from your backend and retry instead of deleting the user's file.

Why this guide restarts interrupted downloads

Resuming from the middle sounds simple, but it depends on the server. RFC 9110 says a server may ignore the Range header and send the whole file with status 200. A correct resume also needs If-Range with a validator such as an ETag, so the server sends the full new file instead of mixing bytes from two versions. The demo deletes the .part file and starts again, which is the safe default for documents of a few megabytes.

If you need resumable or background transfers for large files, test your actual server's 206 Partial Content behavior first. Packages such as background_downloader document native transfers through URLSession on iOS and WorkManager on Android, with pause and resume. Keep the same rule either way: the file is promoted only after the hash matches.

Open the verified file in a viewer

Flutter has no built-in PDF widget, so the viewer is a package choice. pdfrx, for example, lists Android, iOS, Windows, macOS, Linux and web, and has a PdfViewer.file constructor. Pass it the path from openable(id) and never the .part path:

final file = await library.openable(doc.id);
if (file == null) {
  // Show "Download" again; the copy is gone or never finished.
} else {
  Navigator.of(context).push(MaterialPageRoute(
    builder: (_) => Scaffold(body: PdfViewer.file(file.path)),
  ));
}

Wire the library at startup with PdfLibrary(Directory('${(await getApplicationSupportDirectory()).path}/pdf-library'), budgetBytes: 200 * 1024 * 1024) and call load() once. These two snippets were not run on a device for this guide.

Where FlutterFlow's built-in widgets stop

FlutterFlow's PdfViewer documentation lists network URLs and assets uploaded to the project as sources. A network URL needs a connection, so it does not give you an offline library. The download, verification, index and removal belong in a custom action, which can add path_provider and crypto as dependencies, and the viewer for a local file belongs in a custom widget. If more of the feature ends up in custom code than in the builder, the trade-offs in when to move from FlutterFlow custom code to pure Flutter apply.

What the test run showed

The PdfLibrary class ran in Dart 3.13.7 on Linux against a local HttpServer with synthetic one-page PDFs of about 250 KB to 3 MB, generated for this test and confirmed readable with pdfinfo. It did not run inside a Flutter app, against Firebase or on a phone.

ScenarioResult
Download v1, restart the library, open offlinev1 opened from the index after restart
v2 transfer cut off at 40%interrupted; v1 still opened; no .part left
Server returned an HTML page with status 200checksum; v1 kept
Catalog URL returned 403stale-link-403; v1 kept
Successful v2 updatev2 recorded, then the v1 file deleted
Budget of 700,000 bytes250 KB document accepted, 3 MB refused before download
3 MB download on a 2 MB file systemdisk:28 (no space); partial file removed; other copy intact
Remove one documentOnly that file and record removed
File deleted outside the appopenable returned null and dropped the record
Leftover .part after a crashDeleted on the next load()

All ten scenarios passed. Not tested: the path_provider folders on real devices, iOS backup exclusion, Android cache eviction, pdfrx rendering, Cloud Storage writeToFile, and FlutterFlow custom code. Treat those parts as a reference implementation to verify in your own project.

Diagnostic checklist

  1. Search your code for where the offline badge is set. It should change only after the size, hash and PDF checks pass.
  2. Print the directory your files are saved in. If it is a cache or temporary folder, move the library to application support.
  3. Confirm the catalog has a version, an exact size and a hash for every document.
  4. Kill the app during a download, relaunch, and confirm no .part file remains and the badge is unchanged.
  5. Point a document at a URL that returns an HTML page and confirm the app refuses it.
  6. Fill your budget, then try one more download and check the message.
  7. Publish a new version and confirm the old copy still opens until the new one is verified.
  8. Remove one document and confirm the others still open with the device offline.

For the Firestore side of an offline-capable app, the Flutter and Firebase SaaS stack overview covers the surrounding architecture, and what happens when offline Firestore writes are rejected later covers queued writes, which have a different failure mode from files.

Start with step 1. If the badge is set anywhere before the bytes are checked, move that line below the rename and run steps 4 and 5 again.

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps using AI-augmented workflows — without agency coordination overhead. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

Where should a Flutter app store PDFs for offline use?

Store them in the application support directory from path_provider, not in the cache or temporary folder. Apple documents that tmp/ is purged when the app is not running and that Caches/ can be cleared when space is very low, and Android may delete cache files when internal storage is low. On iOS, also exclude re-downloadable files from backup, which needs a small platform channel.

How can a Flutter app tell that a downloaded PDF is complete?

Compare the bytes you wrote with an exact size and SHA-256 hash published in your own catalog, then check that the file starts with %PDF- and contains %%EOF near the end. Do not rely on Content-Length: Dart's HttpClient decompresses gzip by default and the header then still shows the compressed size. Rename the file and mark it available only after all checks pass.

Can I resume an interrupted PDF download instead of starting again?

Only if your server supports byte ranges and you test it. RFC 9110 lets a server ignore the Range header and return the whole file with status 200, and a safe resume needs If-Range with an ETag so two versions are never mixed. For documents of a few megabytes, deleting the .part file and restarting is simpler and safe.

Can FlutterFlow's PdfViewer show a downloaded file offline?

Not according to its current documentation, which lists network URLs and assets uploaded to the project as sources. A network URL needs a connection. Download and verify the file in a custom action, then show it with a custom widget built on a package such as pdfrx, whose PdfViewer.file constructor takes a local path.