From Tweet to PWA: The File APIs Behind Excalidraw

Excalidraw began on January 1, 2020, when Christopher Chedeau tweeted about a small drawing app for boxes and arrows with a hand-drawn feel. Within two weeks it had 12K unique active users, 1.5K GitHub stars, and 26 contributors. Today it is a fully installable PWA with offline support, dark mode, and native file open and save operations powered by the File System Access API.

One of Excalidraw's most prolific contributors is Panayiotis Lipiridis, known as lipis. His first contribution added the Open Color library — colors still in use today — and he later built the backend for sharing drawings. Asked why he dedicates so much time to the project, he says: "Whoever tried Excalidraw is looking to find excuses to use it again."

File Operations With and Without the File System Access API

In Chrome and other browsers that support the File System Access API, clicking save opens a genuine save dialog where you can choose the file's name and location. Subsequent saves write to the same file, with no versioned duplicates accumulating in your Downloads folder. In browsers without API support, Excalidraw falls back to a plain download, so repeated edits produce files like untitled (1).excalidraw.

Opening Files

Opening in Excalidraw begins with the loadFromJSON() function calling fileOpen(), a helper from the browser-fs-access library that provides File System Access API support with a legacy fallback.

When the API is available, showOpenFilePicker() returns an array of file handles — one per selected file — and the code attaches the handle to the returned file object so it can be referenced again later for saving.

export default async (options = {}) => {
  const accept = {};
  // Not shown: deal with extensions and MIME types.
  const handleOrHandles = await window.showOpenFilePicker({
    types: [
      {
        description: options.description || '',
        accept: accept,
      },
    ],
    multiple: options.multiple || false,
  });
  const files = await Promise.all(handleOrHandles.map(getFileWithHandle));
  if (options.multiple) return files;
  return files[0];
  const getFileWithHandle = async (handle) => {
    const file = await handle.getFile();
    file.handle = handle;
    return file;
  };
};

The fallback for older browsers creates a hidden <input type="file"> element, accepts MIME types and extensions, and triggers a click to show the file dialog. The promise resolves on the change event after the user confirms their selection.

export default async (options = {}) => {
  return new Promise((resolve) => {
    const input = document.createElement('input');
    input.type = 'file';
    const accept = [
      ...(options.mimeTypes ? options.mimeTypes : []),
      options.extensions ? options.extensions : [],
    ].join();
    input.multiple = options.multiple || false;
    input.accept = accept || '*/*';
    input.addEventListener('change', () => {
      resolve(input.multiple ? Array.from(input.files) : input.files[0]);
    });
    input.click();
  });
};

Saving and Save As

For saveAsJSON(), Excalidraw serializes its elements array to JSON and passes the resulting blob to fileSave() from the same library. With File System Access API support, the already-obtained file handle means no dialog on subsequent saves; a writable stream handles writing the content. A saveAs feature is implemented by deliberately ignoring an existing handle and presenting the picker again, which leaves the original file untouched.

export default async (blob, options = {}, handle = null) => {
  options.fileName = options.fileName || 'Untitled';
  const accept = {};
  // Not shown: deal with extensions and MIME types.
  handle =
    handle ||
    (await window.showSaveFilePicker({
      suggestedName: options.fileName,
      types: [
        {
          description: options.description || '',
          accept: accept,
        },
      ],
    }));
  const writable = await handle.createWritable();
  await writable.write(blob);
  await writable.close();
  return handle;
};

Without the API, saving reduces to a short routine that synthesizes an anchor element with a download attribute set to the filename and a blob URL as the href. The element is clicked programmatically and the URL revoked afterward for memory management.

export default async (blob, options = {}) => {
  const a = document.createElement('a');
  a.download = options.fileName || 'Untitled';
  a.href = URL.createObjectURL(blob);
  a.addEventListener('click', () => {
    setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
  });
  a.click();
};

Drag and Drop and Share Targets

Dropping an .excalidraw file onto the canvas opens it immediately. When the File System Access API is present, the app calls getAsFileSystemHandle() on the data transfer item to get a file handle, passing it through to the loader — so immediate saving without a dialog is possible.

const file = event.dataTransfer?.files[0];
if (file?.type === 'application/json' || file?.name.endsWith('.excalidraw')) {
  this.setState({ isLoading: true });
  // Provided by browser-fs-access.
  if (supported) {
    try {
      const item = event.dataTransfer.items[0];
      file as any.handle = await item as any
        .getAsFileSystemHandle();
    } catch (error) {
      console.warn(error.name, error.message);
    }
  }
  loadFromBlob(file, this.state).then(({ elements, appState }) =>
    // Load from blob
  ).catch((error) => {
    this.setState({ isLoading: false, errorMessage: error.message });
  });
}

On Android, ChromeOS, and Windows, the Web Share Target API lets users share the file directly from the OS's sharing sheet into Excalidraw for immediate inspection and editing.

Why Electron Was Deprecated

Excalidraw once had an Electron version whose main appeal was handling system-level file associations, such as responding to double-clicks on file icons and potential store distribution. Lipis created that version and later deprecated it. His rationale: Project Fugu APIs — file system access, clipboard access, and file handling — made the PWA a full replacement. Installing the web app needs one click, without Electron's overhead, and PWAs can now be published to the Play Store and the Microsoft Store. Excalidraw Electron, he concluded, "was not deprecated because Electron is bad — not at all — but because the web has become good enough."

Opening files from the operating system

One of the most visible improvements in Excalidraw comes from the File Handling API, which lets an installed PWA register itself as an opener for specific file types. On macOS Big Sur, right-clicking an .excalidraw file shows Excalidraw in the Open With menu, and double-clicking works the same way.

The registration happens in the web app manifest through a new file_handlers field. Its value is an array of objects, each with an action URL and an accept mapping of MIME types to file extensions:

{
  "name": "Excalidraw",
  "description": "Excalidraw is a whiteboard tool...",
  "start_url": "/",
  "display": "standalone",
  "theme_color": "#000000",
  "background_color": "#ffffff",
  "file_handlers": [
    {
      "action": "/",
      "accept": {
        "application/vnd.excalidraw+json": [".excalidraw"]
      }
    }
  ]
}

When the OS launches the app with a file, the launchQueue interface takes over. Calling setConsumer() registers an asynchronous function that receives launchParams. From there, the files array provides handles; Excalidraw takes the first handle, gets a blob from it, and passes it to loadFromBlob():

if ('launchQueue' in window && 'LaunchParams' in window) {
  window as any.launchQueue
    .setConsumer(async (launchParams: { files: any[] }) => {
      if (!launchParams.files.length) return;
      const fileHandle = launchParams.files[0];
      const blob: Blob = await fileHandle.getFile();
      blob.handle = fileHandle;
      loadFromBlob(blob, this.state).then(({ elements, appState }) =>
        // Initialize app state.
      ).catch((error) => {
        this.setState({ isLoading: false, errorMessage: error.message });
      });
    });
}

The File Handling API is currently behind the experimental web platform features flag and is scheduled to ship in Chrome later this year.

Richer clipboard output

Excalidraw also demonstrates the clipboard API's ability to carry image data. You can copy an entire drawing, or parts of one, and paste it directly into another application.

The implementation is minimal: the canvas is converted to a blob, wrapped in a ClipboardItem inside a one-element array, and passed to navigator.clipboard.write():

export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => {
  const blob = await canvasToBlob(canvas);
  await navigator.clipboard.write([
    new window.ClipboardItem({
      'image/png': blob,
    }),
  ]);
};

export const canvasToBlob = async (canvas: HTMLCanvasElement): Promise<Blob> => {
  return new Promise((resolve, reject) => {
    try {
      canvas.toBlob((blob) => {
        if (!blob) {
          return reject(new CanvasError(t('canvasError.canvasTooBig'), 'CANVAS_POSSIBLY_TOO_BIG'));
        }
        resolve(blob);
      });
    } catch (error) {
      reject(error);
    }
  });
};

Real-time collaboration

Starting a session

Excalidraw's collaboration mode allows multiple people to edit the same document. Starting a session creates a shareable URL, which can be sent to collaborators using the Web Share API.

Syncing across devices

In testing, changes made on one device appear on all other connected devices. The setup included a Pixelbook, a Pixel 3a, and an iPad Pro, all running the same session. While trackpad input on the Pixelbook creates smooth cursor movement, finger input on the phone and tablet causes more abrupt jumps, but the synchronization remains consistent.

Idle status

Collaborators also see each other's presence status. An active participant's cursor shows a green dot. Switching to another app or tab changes the dot to black. Staying in the Excalidraw window without interaction changes it to zZZ.

It would be reasonable to assume this feature relies on the Idle Detection API, an early-stage proposal tied to Project Fugu. In fact, Excalidraw started with that approach but switched to measuring pointer movement and page visibility instead. Feedback on the decision was filed in the WICG idle-detection repository.

Missing from the platform

When asked what holds back Excalidraw, collaborator lipis pointed to a gap in the File System Access API:

The File System Access API is great, but you know what? Most files that I care about these days live in my Dropbox or Google Drive, not on my hard disk. I wish the File System Access API would include an abstraction layer for remote file systems providers like Dropbox or Google to integrate with and that developers could code against. Users could then relax and know their files are safe with the cloud provider they trust.

Tabbed application mode

Beyond these APIs, Excalidraw shows an early version of tabbed application mode. In a PWA running in standalone mode, a new tab icon opens an additional PWA tab within the same app window, letting you keep two documents open side by side and edit them independently. This feature is still in active development and its final shape is not yet settled.