When “Good Enough” Isn't Good Enough

Progressive enhancement has been part of the web developer's vocabulary since Nick Finck and Steve Champeon introduced the concept in 2003. The core idea—deliver essential content and functionality first, then layer on more advanced features for capable browsers—remains as relevant today as it was nearly two decades ago. What has changed is the tooling. In 2003, progressive enhancement meant using then-modern CSS and unobtrusive JavaScript. Today, it means leveraging the latest browser APIs without leaving older user agents behind.

Modern browsers have reached a point where the JavaScript language itself is rarely the bottleneck. Core ES 2015 features—promises, modules, classes, template literals, arrow functions, let and const, destructuring, generators, and Map/Set—enjoy universal support. ES 2017's async/await is available everywhere, and even ES 2020 additions like optional chaining and nullish coalescing have been adopted quickly. When the language stops being the constraint, the focus shifts to what browser APIs can actually do.

The Fugu Greetings Test Case

To see progressive enhancement in action with modern capabilities, consider Fugu Greetings, a drawing PWA that lets users create virtual greeting cards and send them to loved ones. The app embodies core PWA concepts: it is reliable, fully offline-enabled, installable on a device's home screen, and integrates with the operating system as a standalone application.

Starting every card from a blank canvas gets tedious, which is why the app supports importing images as a starting point. The traditional approach uses an <input type="file"> element, which requires creating the element, setting its accept property with MIME types, programmatically clicking it, and listening for change events. Exporting works similarly: create an anchor with a download attribute and a blob URL as href, click it, and then remember to revoke the object URL.

That pattern has UX problems. Users don't think of saving a greeting card as downloading it—the browser bypasses a save dialog and drops files directly into the Downloads folder without asking where to put them.

A Better File Handling Workflow

The File System Access API offers a more natural interaction model: open a local file, edit it, and save changes either to a new file or back to the original. The API adds window.chooseFileSystemEntries(), which makes feature detection straightforward. For the import and export modules in the sample app, different code paths load based on whether this method exists.

On browsers without the API, legacy scripts load. On Chrome—which supports the API—only the new modules load via dynamic import(), a language feature supported by all modern browsers.

For importing an image with the File System Access API, the call to window.chooseFileSystemEntries() accepts an accepts property where both file extensions and MIME types work. A file handle is returned, and calling getFile() yields the actual file. Export requires a type parameter of 'save-file' (since 'open-file' is the default); after the save dialog appears and the user confirms, a writable stream is created with createWritable(), the image blob is written, and the stream closes. Error handling matters here—disk space, read/write failures, or user cancellation can all occur—so each call sits in a try...catch block.

Sharing Without Native Apps

Mobile and desktop operating systems now include built-in sharing mechanisms, and The Web Share API brings those to the web. Level 1 of the API supports sharing title, text, and url but not files; Web Share Level 2 adds file sharing capabilities. For images, this raises the important question of whether a given payload can be shared at all—the navigator.canShare() method answers it. Calling it with the intended data object reveals whether the browser supports the share. Only if canShare() returns true does the app then call navigator.share(), again wrapped in a try...catch since any share attempt can fail.

Feature detection follows the same progressive enhancement pattern used for file system access. Only when both 'share' and 'canShare' exist on the navigator object is the share.mjs module dynamically imported. Browsers that satisfy just one of those conditions—like earlier versions of mobile Safari when only navigator.share() was available—fall back to whatever the baseline UI provides.