Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 289 additions & 0 deletions src/routes/blog/post/screenshots-best-practices/+page.markdoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
---
layout: post
title: Best practices for handling screenshots in your app
description: Learn how to efficiently capture, store, and serve webpage screenshots without wasting resources or slowing down your app.
date: 2026-01-27
cover: /images/blog/screenshots-best-practices/cover.png
timeToRead: 5
author: atharva
category: tutorial
featured: false
callToAction: true
---

Screenshots are everywhere in modern applications. Link previews in chat apps, visual documentation in developer tools, thumbnail galleries in dashboards, and compliance archives in enterprise software. What seems like a simple feature, "show me what this page looks like", can quickly become a performance bottleneck and maintenance headache if not handled properly.

The naive approach of generating a screenshot every time it is needed works fine during development. But in production, with real traffic, that approach falls apart. API rate limits get hit, response times spike, users stare at loading spinners, and costs climb.

This guide covers practical patterns for handling screenshots efficiently. Whether you are building link previews, automated reports, or visual testing pipelines, these best practices will help you avoid common pitfalls and build a robust screenshot workflow.

# Stop regenerating the same screenshot

The most common mistake is treating screenshot generation as a real-time operation. Every time a user requests a preview, the app fires off a new screenshot request, waits for it to render, and serves the result. This works for a handful of requests but scales poorly.

The fix is simple: **store screenshots in persistent storage and serve them from there**.

Here is how this looks with Appwrite. First, generate the screenshot:

```js
import { Client, Avatars, Storage } from "appwrite";

const client = new Client()
.setEndpoint('https://<REGION>.cloud.appwrite.io/v1')
.setProject('<PROJECT_ID>');

const avatars = new Avatars(client);
const storage = new Storage(client);

const screenshotUrl = avatars.getScreenshot({
url: 'https://example.com',
viewportWidth: 1200,
viewportHeight: 630,
output: 'webp',
quality: 85
});
```

Then, fetch it and store it in a bucket:

```js
// Fetch the screenshot as a blob
const response = await fetch(screenshotUrl);
const blob = await response.blob();

// Store in your screenshots bucket
const file = await storage.createFile({
bucketId: 'screenshots',
fileId: ID.unique(),
file: new File([blob], 'example-com.webp', { type: 'image/webp' })
});

console.log(file.$id); // Save this ID for later retrieval
```

Now you have a permanent reference to the screenshot. Subsequent requests fetch from storage instead of regenerating.

# Track screenshots in a table

When storing screenshots, you need a way to look them up later. Use a table to map URLs to their stored file IDs:

```js
import { Client, Storage, TablesDB, Avatars, ID, Query } from "appwrite";

const client = new Client()
.setEndpoint('https://<REGION>.cloud.appwrite.io/v1')
.setProject('<PROJECT_ID>');

const storage = new Storage(client);
const tablesDB = new TablesDB(client);
const avatars = new Avatars(client);

async function getOrCreateScreenshot(url) {
// Check if we already have this screenshot
const existing = await tablesDB.listRows({
databaseId: 'main',
tableId: 'screenshots',
queries: [Query.equal('url', [url])]
});

if (existing.total > 0) {
return storage.getFileView({ bucketId: 'screenshots', fileId: existing.rows[0].fileId });
}

// Generate and store new screenshot
const screenshotUrl = avatars.getScreenshot({ url });
const response = await fetch(screenshotUrl);
const blob = await response.blob();

const file = await storage.createFile({
bucketId: 'screenshots',
fileId: ID.unique(),
file: new File([blob], 'screenshot.webp', { type: 'image/webp' })
});

// Save the mapping
await tablesDB.createRow({
databaseId: 'main',
tableId: 'screenshots',
rowId: ID.unique(),
data: { url: url, fileId: file.$id }
});

return storage.getFileView({ bucketId: 'screenshots', fileId: file.$id });
}
```

This lets you query by URL directly. Each row automatically gets `$createdAt` and `$updatedAt` timestamps you can use for invalidation.

# Implement smart refresh strategies

Stored screenshots go stale. A page you captured last week might look completely different today. You need a strategy for keeping screenshots fresh without regenerating them unnecessarily.

**Time-based expiration** is the simplest approach. Check the `$updatedAt` timestamp and regenerate when the screenshot is too old:

```js
async function getScreenshot(url, maxAgeHours = 24) {
const existing = await tablesDB.listRows({
databaseId: 'main',
tableId: 'screenshots',
queries: [Query.equal('url', [url])]
});

if (existing.total > 0) {
const row = existing.rows[0];
const age = Date.now() - new Date(row.$updatedAt).getTime();
const maxAge = maxAgeHours * 60 * 60 * 1000;

if (age < maxAge) {
return storage.getFileView({ bucketId: 'screenshots', fileId: row.fileId });
}

// Screenshot expired, regenerate and update the row
const newFileId = await captureAndStore(url);
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This TTL refresh example calls captureAndStore(url), but that helper isn’t defined anywhere in the post. Define it (e.g., as the screenshot capture + Storage upload logic) or replace this call with an existing helper shown earlier.

Suggested change
const newFileId = await captureAndStore(url);
const newFileId = await createScreenshot(url);

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@atharvadeosthale where is this function called from?

await tablesDB.updateRow({
databaseId: 'main',
tableId: 'screenshots',
rowId: row.$id,
data: { fileId: newFileId }
});
return storage.getFileView({ bucketId: 'screenshots', fileId: newFileId });
}

// No existing screenshot, create new one
return await createScreenshot(url);
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function returns createScreenshot(url), but createScreenshot isn’t defined anywhere in the post. Either define it or reuse the earlier screenshot creation logic so the snippet is complete.

Suggested change
return await createScreenshot(url);
const fileId = await captureAndStore(url);
return storage.getFileView('screenshots', fileId);

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}
```

**On-demand refresh** works well for user-facing features. Add a "refresh preview" button that regenerates the screenshot when clicked, rather than on a timer.

# Choose the right format for your use case

Screenshot APIs typically support multiple output formats. Each has trade-offs.

| Format | Best for | Considerations |
| ------ | -------- | -------------- |
| WebP | Web delivery, general use | Best size-to-quality ratio, wide browser support |
| PNG | Screenshots with text, transparency | Larger files, lossless quality |
| JPEG | Photographs, gradients | Smaller files, no transparency, some quality loss |

For most web applications, **WebP at 80-85% quality** offers the best balance:

```js
const screenshot = avatars.getScreenshot({
url: 'https://example.com',
output: 'webp',
quality: 85
});
```

If you need to support older browsers, generate both WebP and JPEG versions and serve the appropriate one based on the Accept header.

# Size screenshots for their purpose

A full-page screenshot at 1920x1080 might be 2MB. If you are displaying it as a 300px thumbnail, you are wasting bandwidth and slowing down your page.

Generate screenshots at the size you actually need:

```js
// For link preview cards (Open Graph size)
const ogPreview = avatars.getScreenshot({
url: 'https://example.com',
viewportWidth: 1200,
viewportHeight: 630,
width: 1200,
height: 630
});

// For thumbnail galleries
const thumbnail = avatars.getScreenshot({
url: 'https://example.com',
viewportWidth: 1280,
viewportHeight: 720,
width: 400,
height: 225
});

// For full documentation
const fullPage = avatars.getScreenshot({
url: 'https://example.com',
fullpage: true,
viewportWidth: 1280
});
```

If you need multiple sizes, consider generating at the largest size and using Appwrite Storage's image transformation to serve smaller versions:

```js
// Get a resized version from storage
const thumbnail = storage.getFilePreview({
bucketId: 'screenshots',
fileId: screenshotFileId,
width: 400,
height: 225,
gravity: 'center',
quality: 80,
output: 'webp'
});
```

# Handle dynamic content correctly

Modern web pages are full of dynamic content: animations, lazy-loaded images, JavaScript-rendered components. A screenshot taken too early captures a half-loaded page.

Use the `sleep` parameter to wait for content to settle:

```js
// Wait 3 seconds for animations and lazy loading
const screenshot = avatars.getScreenshot({
url: 'https://example.com',
sleep: 3
});
```

For pages with heavy JavaScript, 2-3 seconds is usually sufficient. For simple static pages, you can skip the delay entirely.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should improve that first part and include a mention of loading

Also, pages with heavy JavaScript is a little too informal


Some pages require specific browser permissions to render correctly. A mapping application needs geolocation permission, or it shows a permission prompt instead of the map:

```js
const mapScreenshot = avatars.getScreenshot({
url: 'https://maps.example.com',
permissions: ['geolocation'],
latitude: 40.7128,
longitude: -74.0060
});
```

For authenticated pages, use the `headers` parameter to pass authorization tokens or session cookies:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add a server-side security note for auth headers.

The sentence can be read as client-side guidance. Please add a short caution to keep tokens server-side to avoid exposing secrets in browser code.

🛡️ Suggested wording
-For authenticated pages, use the `headers` parameter to pass authorization tokens or session cookies:
+For authenticated pages, use the `headers` parameter to pass authorization tokens or session cookies. Do this from a trusted backend/service to avoid exposing secrets in client-side code:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
For authenticated pages, use the `headers` parameter to pass authorization tokens or session cookies:
For authenticated pages, use the `headers` parameter to pass authorization tokens or session cookies. Do this from a trusted backend/service to avoid exposing secrets in client-side code:
🤖 Prompt for AI Agents
In `@src/routes/blog/post/screenshots-best-practices/`+page.markdoc at line 246,
Add a short server-side security caution after the line "For authenticated
pages, use the `headers` parameter to pass authorization tokens or session
cookies:" clarifying that tokens should never be embedded in client-side or
browser-exposed code; recommend sending auth tokens from server-side code (e.g.,
in a load function or API route) and passing only necessary session data to the
client to avoid exposing secrets. Reference the `headers` parameter and the
phrase "authenticated pages" so the note is placed immediately afterward.


```js
const authenticatedScreenshot = avatars.getScreenshot({
url: 'https://dashboard.example.com',
headers: {
'Authorization': 'Bearer your-access-token',
'Cookie': 'session=your-session-id'
}
});
```

This lets you capture internal dashboards, admin panels, or any page that requires authentication.

# Final thoughts

Handling screenshots well comes down to a few key principles:

1. **Store, do not regenerate** - Store screenshots in persistent storage and serve from there
2. **Size appropriately** - Generate at the dimensions you need, not the largest possible
3. **Choose the right format** - WebP for most cases, PNG when you need lossless quality
4. **Handle staleness** - Implement TTL-based or on-demand refresh strategies
5. **Wait for dynamic content** - Use sleep and permissions to capture fully-loaded pages

With these patterns in place, screenshots become a reliable, performant feature rather than a source of production headaches.

To get started, check out the [Screenshots API documentation](/docs/products/avatars/screenshots) and try capturing your first screenshot. As always, we would love to see what you build with it.

# Further reading

- [Screenshots API documentation](/docs/products/avatars/screenshots)
- [Announcing Screenshots API](/blog/post/announcing-screenshots-api)
- [Image transformation with Appwrite Storage](/blog/post/image-transformation-with-appwrite-storage)
- [Appwrite Storage quick start](/docs/products/storage/quick-start)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.