Day 10: Device emulation - mobile, tablet, desktop

Day 10 of 30. Today we make screenshots look right on every device.

#devices #responsive

Day 10 of 30. Today we make screenshots look right on every device.

A screenshot API that only does desktop is a good start, but only half a product. Mobile traffic is close to 60% of the web in 2025, and developers need to see how their sites render on phones and tablets.

Device emulation is more than just changing the viewport size. Let’s dig in!

What makes a device a device?

When you visit a website on your phone, the site knows it’s a phone because of:

1. Viewport size. The iPhone 14 has a viewport of 390×844 points. The iPad has 1024×1366, and a desktop is typically 1920×1080 or larger.

2. User agent string. The browser sends a string identifying itself and sites use this to serve different experiences.

3. Device pixel ratio. Retina screens have 2x or 3x pixel density. A “390px wide” phone actually renders at 1170 physical pixels.

4. Touch capability. Some sites check if touch events are available and adjust accordingly.

5. Media queries. CSS media queries respond to viewport size, pixel ratio, and orientation.

To emulate a device properly, we need to fake all of these.

See it in action

Switch between device types and see how the screenshot dimensions change:

device-preview - Device emulation preview
Viewport 1920 × 1080
DPR 1x
Image Size 1920 × 1080
Est. File ~850 KB
https://example.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0

Playwright’s device registry

We’re using Playwright, and luckily, Playwright maintains a registry of device profiles. Here’s what iPhone 14 looks like:

{
  "name": "iPhone 14",
  "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0...",
  "viewport": { "width": 390, "height": 664 },
  "deviceScaleFactor": 3,
  "isMobile": true,
  "hasTouch": true
}

Using this profile, Playwright configures the browser to behave like an iPhone. Websites see the mobile user agent, receive touch events, and render at the correct size.

Our device configuration

DeviceViewportDPRNotes
Desktop1920×10801xStandard output size
Mobile390×8443xLarger files due to DPR
Tablet1024×13662xiPad-style rendering

We will support the 3 most used presets, plus custom dimensions when the presets aren’t sufficient.

enum class Device(
    val viewport: Viewport,
    val userAgent: String,
    val deviceScaleFactor: Double,
    val isMobile: Boolean
) {
    DESKTOP(
        viewport = Viewport(1920, 1080),
        userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
        deviceScaleFactor = 1.0,
        isMobile = false
    ),
    MOBILE(
        viewport = Viewport(390, 844),
        userAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
        deviceScaleFactor = 3.0,
        isMobile = true
    ),
    TABLET(
        viewport = Viewport(1024, 1366),
        userAgent = "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
        deviceScaleFactor = 2.0,
        isMobile = true
    )
}

If the user specifies a custom width and height, we use those instead of the preset.

Applying device settings in Playwright

Here’s how we configure the browser context:

fun createContext(request: ScreenshotRequest): BrowserContext {
    val device = Device.valueOf(request.device.uppercase())

    val options = Browser.NewContextOptions()
        .setViewportSize(
            request.width ?: device.viewport.width,
            request.height ?: device.viewport.height
        )
        .setUserAgent(device.userAgent)
        .setDeviceScaleFactor(device.deviceScaleFactor)
        .setIsMobile(device.isMobile)
        .setHasTouch(device.isMobile)

    return browser.newContext(options)
}

The pixel ratio problem

While building this, we had an unexpected experience; device pixel ratio affects screenshot size.

Let’s explain. An iPhone 14 has a 390×844 viewport at 3x DPR. That means the actual screenshot is 1170×2532 pixels. That’s a 3MB PNG.

For our API, we have two options:

Option A: Return the full-resolution screenshot. What you see is what you get.

Option B: Return a screenshot at 1x resolution. Smaller files, but not pixel-accurate.

We went with Option A - full resolution. Our aim is to deliver the best screenshots, and for that, we need to be pixel-accurate. Developers who use our service can resize when needed, but it’s tricky to add pixels back. So, we document this clearly:

{
  "metadata": {
    "viewport_width": 390,
    "viewport_height": 844,
    "device_pixel_ratio": 3,
    "image_width": 1170,
    "image_height": 2532
  }
}

Testing across devices

DeviceViewportDPRImage SizeFile Size
Desktop1920×10801x1920×1080847 KB
Tablet1024×13662x2048×27321.2 MB
Mobile390×8443x1170×25321.8 MB

Mobile screenshots are larger than desktop because of the higher pixel ratio. So while they have a lower viewport resolution, the total image size is much bigger. This may sound counterintuitive, but is correct.

A great resource for reading up on DPR can be found here.

Full page screenshots on mobile

Given the above, a full page + mobile + high DPR results potentially in very large images.

A long mobile page (say, 5000px tall at 3x) generates a 1170×15000 pixel image. That’s 15-20MB as PNG.

To address this, we’re considering to add limits:

  • Maximum page height: 10000 CSS pixels
  • Maximum image dimension: 16384 pixels (browser limit)
  • Warning in response if truncated

Let us know if this is an acceptable solution!

val maxHeight = 10000
if (request.fullPage && page.evaluate("document.body.scrollHeight") > maxHeight) {
    // Truncate and flag
    job.metadata["truncated"] = true
    job.metadata["original_height"] = page.evaluate("document.body.scrollHeight")
}

Landscape mode

Some users will want landscape mobile screenshots (844×390 instead of 390×844). We support this via an explicit width/height:

{
  "url": "https://example.com",
  "device": "mobile",
  "width": 844,
  "height": 390
}

The user agent still says “iPhone” but the viewport is rotated. This matches how landscape actually works - same device, different orientation. We might add a more user-friendly option later if we notice that many people make landscape mobile screenshots.

What we discovered today

Responsive design is inconsistent. Some sites look great on mobile, but others have overlapping elements, unreadable text, or broken layouts. This isn’t our bug, but users might think it is. We use a wide range of test websites to test that our screenshot solution works well.

Cookie banners are worse on mobile. They often cover 30-40% of the screen, especially for European sites. We’re definitely adding auto-dismiss in a future version.

Viewport vs. screen size. Some sites use screen.width instead of viewport.width. Playwright emulates both, but edge cases still exist.

Font rendering varies. The same font looks different on different devices. Our screenshots use Chrome’s font rendering, which is close to but not identical to Safari on iOS. Something to document in our guides!

What we built today

  • Implemented device presets (desktop, mobile, tablet)
  • Added custom viewport support
  • Handled device pixel ratio correctly
  • Added metadata to responses
  • Implemented page height limits for full-page captures

Tomorrow: storage strategy

On day 11 we tackle where screenshots live. S3-compatible storage, signed URLs, cleanup policies, and keeping costs low.

Book of the day

Responsive Web Design by Ethan Marcotte

Ethan Marcotte coined the term “responsive web design” in 2010. This book is the foundational text on building sites that work across devices.

Understanding responsive design helps us understand what we’re screenshotting. Media queries, fluid grids, flexible images - these are the tools developers use to make sites adapt. When a screenshot looks wrong on mobile, knowing these concepts helps us debug whether it’s our emulation or the site’s CSS.

Short, well-written, and still relevant. If you’re building anything for the web, it’s essential background knowledge.


Day 10 stats

Hours
████░░░░░░░░░░░
24h
</> Code
███░░░░░░░░░░░░
1,100
$ Revenue
░░░░░░░░░░░░░░░
$0
Customers
░░░░░░░░░░░░░░░
0
Hosting
████░░░░░░░░░░░
$5.5/mo
Achievements:
[✓] Device presets working [✓] Custom viewports added [✓] DPR handling correct
╔════════════════════════════════════════════════════════════╗
E

Erik

Building Allscreenshots. Writes code, takes screenshots, goes diving.

Try allscreenshots

Screenshot API for the modern web. Capture any URL with a simple API call.

Get started