.
diff --git a/vello_gpu/src/lib.rs b/vello_gpu/src/lib.rs
index 2e6b70a..65b6b02 100644
--- a/vello_gpu/src/lib.rs
+++ b/vello_gpu/src/lib.rs
@@ -107,6 +107,29 @@
 
 extern crate alloc;
 
+#[cfg(feature = "std")]
+#[doc(hidden)]
+pub mod timings;
+
+/// The start of a rendering phase, for [`phase_end`].
+pub(crate) fn phase_start() -> f64 {
+    #[cfg(feature = "std")]
+    return timings::now_ms();
+    #[cfg(not(feature = "std"))]
+    0.0
+}
+
+/// Adds the time since `$start` to the given field of the render phase timings.
+macro_rules! phase_end {
+    ($field:ident, $start:expr) => {
+        #[cfg(feature = "std")]
+        crate::timings::record(|t| t.$field += crate::timings::now_ms() - $start);
+        #[cfg(not(feature = "std"))]
+        let _ = $start;
+    };
+}
+pub(crate) use phase_end;
+
 pub(crate) mod blend;
 pub(crate) mod copy;
 pub(crate) mod filter;
diff --git a/vello_gpu/src/render/webgl/mod.rs b/vello_gpu/src/render/webgl/mod.rs
index 6e3cfb9..1e7ed71 100644
--- a/vello_gpu/src/render/webgl/mod.rs
+++ b/vello_gpu/src/render/webgl/mod.rs
@@ -597,6 +597,7 @@
             }
         };
 
+        let before_render_start = crate::phase_start();
         #[cfg(feature = "text")]
         resources.before_render(
             self,
@@ -619,6 +620,7 @@
             },
         )?;
 
+        crate::phase_end!(before_render_ms, before_render_start);
         let target_init = TargetInit::Clear(ClearSettings::Viewport {
             color: options.clear_color,
         });
@@ -696,10 +698,12 @@
             }
         }
 
-        #[cfg(feature = "text")]
         // TODO: We should sort the rectangles once by atlas
         // page and then clear per atlas page instead of per rect.
+        let after_render_start = crate::phase_start();
+        #[cfg(feature = "text")]
         resources.after_render(self, clear_atlas_region)?;
+        crate::phase_end!(after_render_ms, after_render_start);
 
         Ok(())
     }
@@ -822,12 +826,15 @@
         let encoded_paints = &scene.encoded_paints;
         let use_depth_buffer = self.programs.resources.view_framebuffer.use_depth_buffer();
 
+        let phase = crate::phase_start();
         self.prepare_gpu_encoded_paints(
             encoded_paints,
             image_cache,
             texture_bindings,
             render_target_texture,
         )?;
+        crate::phase_end!(encode_paints_ms, phase);
+        let phase = crate::phase_start();
 
         let mut required_texture_size = self
             .layers_config
@@ -857,8 +864,14 @@
             current_allocations,
             self.layers_config.max_textures,
         )?;
+        crate::phase_end!(schedule_ms, phase);
+        let phase = crate::phase_start();
         self.programs
             .prepare_intermediate_textures(&self.gl, &schedule, required_texture_size)?;
+        crate::phase_end!(intermediate_textures_ms, phase);
+        let phase = crate::phase_start();
+        #[cfg(feature = "std")]
+        let alpha_bytes = scene.strip_storage.borrow().alphas.len() as u64;
 
         // TODO: For the time being, we upload the entire alpha buffer as one big chunk. As a future
         // refinement, we could have a bounded alpha buffer, and break draws when the alpha
@@ -874,6 +887,18 @@
             &self.schedule_storage.filter_context,
             root_output_target,
         )?;
+        crate::phase_end!(upload_ms, phase);
+        #[cfg(feature = "std")]
+        let strip_count = (self
+            .schedule_storage
+            .buffers
+            .draw_buffers
+            .opaque
+            .strips()
+            .len()
+            + self.schedule_storage.buffers.draw_buffers.strips.len())
+            as u64;
+        let phase = crate::phase_start();
         self.programs.resources.depth_cleared_this_frame = false;
         let mut ctx = WebGlRendererContext {
             programs: &mut self.programs,
@@ -891,6 +916,14 @@
             schedule,
             root_output_target,
         )?;
+        crate::phase_end!(execute_ms, phase);
+        #[cfg(feature = "std")]
+        crate::timings::record(|t| {
+            t.renders += 1;
+            t.strips += strip_count;
+            t.alpha_bytes += alpha_bytes;
+            t.encoded_paints += encoded_paints.len() as u64;
+        });
 
         self.gl.bind_framebuffer(
             WebGl2RenderingContext::FRAMEBUFFER,
diff --git a/vello_gpu/src/render/wgpu/mod.rs b/vello_gpu/src/render/wgpu/mod.rs
index 80851a3..fac6abb 100644
--- a/vello_gpu/src/render/wgpu/mod.rs
+++ b/vello_gpu/src/render/wgpu/mod.rs
@@ -412,6 +412,7 @@
             }
         };
 
+        let before_render_start = crate::phase_start();
         #[cfg(feature = "text")]
         {
             resources.before_render(
@@ -439,6 +440,7 @@
             )?;
         }
 
+        crate::phase_end!(before_render_ms, before_render_start);
         let result = self.render_scene(
             scene,
             encoder,
@@ -453,12 +455,14 @@
             options.offset,
         );
 
+        let after_render_start = crate::phase_start();
         #[cfg(feature = "text")]
         resources.after_render(self, |renderer, rect| {
             clear_atlas_region(renderer, rect);
 
             Ok::<(), RenderError>(())
         })?;
+        crate::phase_end!(after_render_ms, after_render_start);
         result
     }
 
@@ -566,12 +570,15 @@
         scene_offset: (i32, i32),
     ) -> Result<(), RenderError> {
         self.programs.depth_cleared_this_frame = false;
+        let phase = crate::phase_start();
         self.prepare_gpu_encoded_paints(
             encoded_paints,
             image_cache,
             texture_bindings,
             view.texture(),
         )?;
+        crate::phase_end!(encode_paints_ms, phase);
+        let phase = crate::phase_start();
         let required_texture_size = self
             .layers_config
             .required_intermediate_texture_size(&scene.recorder)?;
@@ -597,8 +604,14 @@
             current_allocations,
             self.layers_config.max_textures,
         )?;
+        crate::phase_end!(schedule_ms, phase);
+        let phase = crate::phase_start();
         self.programs
             .prepare_intermediate_textures(&self.device, &schedule);
+        crate::phase_end!(intermediate_textures_ms, phase);
+        let phase = crate::phase_start();
+        #[cfg(feature = "std")]
+        let alpha_bytes = scene.strip_storage.borrow().alphas.len() as u64;
         // TODO: For the time being, we upload the entire alpha buffer as one big chunk. As a future
         // refinement, we could have a bounded alpha buffer, and break draws when the alpha
         // buffer fills.
@@ -625,6 +638,8 @@
         self.programs
             .strips_arena
             .begin_frame(&self.device, (strip_count * size_of::<GpuStrip>()) as u64);
+        crate::phase_end!(upload_ms, phase);
+        let phase = crate::phase_start();
 
         let mut ctx = RendererContext {
             programs: &mut self.programs,
@@ -650,6 +665,14 @@
         .unwrap_or_else(|error| match error {});
 
         ctx.finish_root_clear();
+        crate::phase_end!(execute_ms, phase);
+        #[cfg(feature = "std")]
+        crate::timings::record(|t| {
+            t.renders += 1;
+            t.strips += strip_count as u64;
+            t.alpha_bytes += alpha_bytes;
+            t.encoded_paints += encoded_paints.len() as u64;
+        });
 
         self.gradient_cache.maintain();
 
diff --git a/vello_gpu/src/timings.rs b/vello_gpu/src/timings.rs
new file mode 100644
index 0000000..746e013
--- /dev/null
+++ b/vello_gpu/src/timings.rs
@@ -0,0 +1,81 @@
+//! Temporary instrumentation: accumulates the time spent in the phases of rendering.
+
+extern crate std;
+
+use core::cell::RefCell;
+use std::thread_local;
+
+/// The accumulated time per rendering phase, in milliseconds, since the last [`take`].
+#[derive(Clone, Debug, Default)]
+pub struct RenderPhaseTimings {
+    pub renders: u32,
+    pub before_render_ms: f64,
+    pub encode_paints_ms: f64,
+    pub schedule_ms: f64,
+    pub intermediate_textures_ms: f64,
+    pub upload_ms: f64,
+    pub execute_ms: f64,
+    pub after_render_ms: f64,
+    pub strips: u64,
+    pub alpha_bytes: u64,
+    pub encoded_paints: u64,
+}
+
+impl core::fmt::Display for RenderPhaseTimings {
+    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+        write!(
+            f,
+            "{} renders: before_render {:.2}ms, encode_paints {:.2}ms, schedule {:.2}ms, \
+             intermediate_textures {:.2}ms, upload {:.2}ms, execute {:.2}ms, after_render {:.2}ms \
+             | per render: {} strips, {} KiB alphas, {} paints",
+            self.renders,
+            self.before_render_ms,
+            self.encode_paints_ms,
+            self.schedule_ms,
+            self.intermediate_textures_ms,
+            self.upload_ms,
+            self.execute_ms,
+            self.after_render_ms,
+            self.strips / u64::from(self.renders.max(1)),
+            self.alpha_bytes / u64::from(self.renders.max(1)) / 1024,
+            self.encoded_paints / u64::from(self.renders.max(1)),
+        )
+    }
+}
+
+thread_local! {
+    static TIMINGS: RefCell<RenderPhaseTimings> = RefCell::new(RenderPhaseTimings::default());
+}
+
+/// Returns the timings accumulated on this thread and resets them.
+pub fn take() -> RenderPhaseTimings {
+    TIMINGS.with(|timings| core::mem::take(&mut *timings.borrow_mut()))
+}
+
+pub(crate) fn record(f: impl FnOnce(&mut RenderPhaseTimings)) {
+    TIMINGS.with(|timings| f(&mut timings.borrow_mut()));
+}
+
+#[cfg(all(target_arch = "wasm32", feature = "webgl"))]
+pub(crate) fn now_ms() -> f64 {
+    let performance = js_sys::Reflect::get(&js_sys::global(), &"performance".into()).unwrap();
+    let now = js_sys::Reflect::get(&performance, &"now".into()).unwrap();
+    js_sys::Function::from(now)
+        .call0(&performance)
+        .unwrap()
+        .as_f64()
+        .unwrap()
+}
+
+#[cfg(all(target_arch = "wasm32", not(feature = "webgl")))]
+pub(crate) fn now_ms() -> f64 {
+    0.0
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+pub(crate) fn now_ms() -> f64 {
+    use std::sync::OnceLock;
+    use std::time::Instant;
+    static START: OnceLock<Instant> = OnceLock::new();
+    START.get_or_init(Instant::now).elapsed().as_secs_f64() * 1000.0
+}