Skip to content

[Proposal] - Support high refresh rate (> 60) on Linux #183703

Open

Description

@finnvyrn

Use case

Linux (I tested both on Hyprland (Wayland, with VRR turned on) and LXDE (X11)) is the last platform that doesn't support high refresh rate (> 60 FPS) for Flutter, making scrolling and animation less pleasant.

Since the main window managers and desktop environments on Linux like GNOME, KDE, Sway, Hyprland, niri already support VRR (variable refresh rate or adaptive sync) (Variable refresh rate - ArchWiki), Flutter supporting high refresh rate can make itself more competitive, especially considering that Electron on Linux doesn't support high refresh rate either at this moment (tested Obsidian app on Linux and Windows) and one of selling points of Flutter, compared to React Native and Electron, is performance.

I used the same hardware, monitor, and app to test. On Windows, the max FPS is 6000+, and the avg. is 250+. On Linux, the max is locked to 60. (Testing tool: show_fps | Flutter package).

Proposal

Support high refresh rate (> 60) for Linux (at least on Wayland).

Activity

  1. changed the title [-]Support high refresh rate (> 60) on Linux[/-] [+][Proposal] - Support high refresh rate (> 60) on Linux[/+] on Mar 18, 2026
  2. saurabh-mirajkar commented on Mar 18, 2026

    @saurabh-mirajkar
    Contributor

    @finnvyrn Thanks for a detailed proposal report. Labelling this to investigate further.

  3. added
    platform-linuxBuilding on or for Linux specifically
    c: proposalA detailed proposal for a change to Flutter
    team-linuxOwned by the Linux platform team
    and removed
    in triagePresently being triaged by the triage team
    on Mar 18, 2026
  4. added
    P2Important issues not at the top of the work list
    triaged-linuxTriaged by the Linux platform team
    on Mar 25, 2026
  5. finnvyrn commented on Mar 28, 2026

    @finnvyrn
    Author

    Updated: I noticed Electron apps like VS Code and Obsidian have already been supporting VRR on Linux (at least on Wayland).

  6. misobadev commented on May 10, 2026

    @misobadev

    is this the same with #49757 (comment) ?

  7. ales-drnz commented on Aug 6, 2026

    @ales-drnz

    @misobadev asked above whether this is the same as #49757. Yes, same root cause. Everything in this comment applies to both.

    The groundwork is all on that thread already. nortio implemented the waiter and reported that the app claims 144fps while the animation stays at 60 (#49757 (comment)), and knopp's reply is the conclusion that turns out to be right: a vsync waiter is pointless while GTK controls buffer flipping, and the way out is taking over rendering and pacing off Wayland frame callbacks (#49757 (comment)). nortio also noticed that GtkGLArea is capped at 60 on GTK3 while GTK4 is not - what follows is the mechanism-level version of that observation.

    @finnvyrn's report is accurate as filed. What I couldn't find written down anywhere is the exact chain, a reproducer with no Flutter in it, and a way to measure this that doesn't produce false positives.

    Environment: 3840x2160 @ 143.99Hz, RTX 4070 SUPER (proprietary), KDE Plasma 6.7.4 Wayland, GTK 3.24.52, Flutter master @ e26b384.

    Why GDK's frame clock is stuck at 60 for a Flutter window

    Flutter's frame is a GTK paint: present_layers → g_idle_add → gtk_widget_queue_draw → draw handler → gdk_cairo_draw_from_gl. queue_draw schedules on GDK's frame clock, and for this window that clock never learns the display's rate:

    1. gdkwindow.c:2965 sets current_paint.use_gl = impl_window->gl_paint_context != NULL. Note this is not a property of the paint: it is the presence of a GL context cached on the window, created by the first gdk_cairo_draw_from_gl (gdkwindow.c:2866) and released only at unrealize (gdkwindow.c:2106-2111). One GL frame in the window's lifetime is enough to take this branch forever after, including for frames that are pure cairo.
    2. gdk_window_impl_wayland_end_paint guards its whole body on !current_paint.use_gl (gdkwindow-wayland.c:1084), so gdk_wayland_window_attach_image and impl->pending_commit = TRUE (:1116) are skipped for those paints
    3. on_frame_clock_after_paint therefore early-returns on if (!impl->pending_commit) (:723)
    4. which also means _gdk_frame_clock_freeze() (:729) never runs. The clock is never throttled by the compositor at all - it free-runs on the idle timer, which is why the measurement below is an exact 60.00 rather than something that jitters.
    5. awaiting_frame (:771) stays FALSE, so GDK never requests a wl_surface_frame
    6. frame_callback() never fires, and it is the only place in GTK3 that sets GdkFrameTimings.complete (:666)
    7. gdkframeclockidle.c:428-432 then reads refresh_interval == 0 and falls back to #define FRAME_INTERVAL 16667 (:37)
    8. the clock ticks at 60Hz, and every paint with it

    Instrumented in the Flutter window, on every tick, indefinitely:

    refresh_interval=16667us presentation_time=0
    frame 483: complete=0   frame 482: complete=0   frame 481: complete=0
    

    The frame counter advances normally (120 per 2s). Nothing ever completes.

    Reproducer without Flutter

    Same window, same frame clock, only the draw path differs. No argument for the GL path, cairo for the control.

    // gcc $(pkg-config --cflags gtk+-3.0 epoxy) -o t t.c $(pkg-config --libs gtk+-3.0 epoxy) -lm
    #include <epoxy/gl.h>
    #include <gtk/gtk.h>
    #include <math.h>
    
    static GdkGLContext *ctx; static GLuint fbo, tex; static int tw, th;
    static int ticks; static gint64 start; static double phase;
    static GtkWidget *area; static int use_gl = 1;
    
    static void ensure_target(int w, int h) {
      if (tex && tw == w && th == h) return;
      if (tex) { glDeleteTextures(1, &tex); glDeleteFramebuffers(1, &fbo); }
      glGenTextures(1, &tex); glBindTexture(GL_TEXTURE_2D, tex);
      glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL);
      glGenFramebuffers(1, &fbo); glBindFramebuffer(GL_FRAMEBUFFER, fbo);
      glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 0);
      tw = w; th = h;
    }
    
    static gboolean on_draw(GtkWidget *w, cairo_t *cr, gpointer d) {
      phase += 0.02;
      if (!use_gl || !ctx) {
        cairo_set_source_rgb(cr, 0.2, 0.4, 0.5 + 0.5 * sin(phase));
        cairo_paint(cr); return TRUE;
      }
      GdkWindow *win = gtk_widget_get_window(w);
      int sf = gdk_window_get_scale_factor(win);
      int width = gdk_window_get_width(win) * sf, height = gdk_window_get_height(win) * sf;
      gdk_gl_context_make_current(ctx);
      ensure_target(width, height);
      glBindFramebuffer(GL_FRAMEBUFFER, fbo); glViewport(0, 0, width, height);
      glClearColor(0.2f, 0.4f, (float)(0.5 + 0.5 * sin(phase)), 1.0f);
      glClear(GL_COLOR_BUFFER_BIT);
      glBindFramebuffer(GL_FRAMEBUFFER, 0);
      gdk_cairo_draw_from_gl(cr, win, tex, GL_TEXTURE, sf, 0, 0, width, height);
      gdk_gl_context_clear_current();
      return TRUE;
    }
    
    static void on_update(GdkFrameClock *clock, gpointer d) {
      gint64 now = g_get_monotonic_time();
      if (!start) { start = now; return; }
      ticks++; gtk_widget_queue_draw(area);
      if (now - start < 2000000) return;
      gint64 interval = -1, presentation = -1;
      gdk_frame_clock_get_refresh_info(clock, gdk_frame_clock_get_frame_time(clock),
                                       &interval, &presentation);
      gint64 counter = gdk_frame_clock_get_frame_counter(clock);
      gint64 hs = gdk_frame_clock_get_history_start(clock);
      int complete = 0, examined = 0;
      for (gint64 i = counter; i >= hs && i > counter - 8; i--) {
        GdkFrameTimings *t = gdk_frame_clock_get_timings(clock, i);
        if (!t) continue;
        examined++; if (gdk_frame_timings_get_complete(t)) complete++;
      }
      g_print("mode=%s FPS=%.2f refresh_interval=%" G_GINT64_FORMAT "us complete=%d/%d\n",
              use_gl ? "GL" : "cairo", ticks * 1e6 / (double)(now - start),
              interval, complete, examined);
      ticks = 0; start = now;
    }
    
    static void realize_cb(GtkWidget *w, gpointer d) {
      if (use_gl) {
        GError *e = NULL;
        ctx = gdk_window_create_gl_context(gtk_widget_get_window(w), &e);
        if (ctx && !gdk_gl_context_realize(ctx, &e)) ctx = NULL;
      }
      GdkFrameClock *clock = gtk_widget_get_frame_clock(w);
      g_signal_connect(clock, "update", G_CALLBACK(on_update), NULL);
      gdk_frame_clock_begin_updating(clock);
    }
    
    int main(int argc, char **argv) {
      gtk_init(&argc, &argv);
      if (argc > 1 && !g_strcmp0(argv[1], "cairo")) use_gl = 0;
      GtkWidget *win = gtk_window_new(GTK_WINDOW_TOPLEVEL);
      gtk_window_set_default_size(GTK_WINDOW(win), 160, 120);
      area = gtk_drawing_area_new();
      gtk_container_add(GTK_CONTAINER(win), area);
      g_signal_connect(area, "draw", G_CALLBACK(on_draw), NULL);
      g_signal_connect(area, "realize", G_CALLBACK(realize_cb), NULL);
      g_signal_connect(win, "destroy", G_CALLBACK(gtk_main_quit), NULL);
      gtk_widget_show_all(win); gtk_main(); return 0;
    }
    mode=cairo FPS=143.97 refresh_interval=6944us  complete=7/8
    mode=GL    FPS=60.00  refresh_interval=16667us complete=0/8
    

    GTK3 on Wayland isn't inherently capped at 60. The GL presentation path is.

    Measuring this correctly

    I implemented a vsync waiter to confirm what knopp said, in two variants. The GdkFrameClock-backed one can't exceed 60 by construction, since it paces off the clock that step 8 above pins at 60. The one driven by wl_surface_frame does lift frame production, and that's the interesting case. Counting frames in present_layers and paints in the renderer's draw handler, in the same binary:

                           baseline    with waiter
    engine frames produced   60.00/s     90-95/s
    GTK paints (on screen)   60.00/s     60.0/s
    

    Production is load-dependent and moves a lot between runs, anywhere from 77 to 98/s in my measurements. The number that matters is the one that doesn't move: presentation stays at 60.0 in every run. Those extra 30-35 frames per second are rendered and discarded. Strictly worse than the baseline: same 60 on screen, more CPU and GPU burned.

    This is the trap in this area. An fps counter wired to Ticker, addTimingsCallback or the engine timeline measures production. On this path that number moves freely without anything reaching the display, which is how a patch here can look like a 40% win while being a regression. Any change claiming to fix this should be measured at the last link, not the first. A counter in the renderer's draw handler is enough.

    This matters for how the issue gets closed, so to be concrete: the show_fps package used in the report measures production too. Today that gives the right answer, because production and presentation are both 60. After a waiter-only patch it would read 144 while the screen still showed 60. The tool that correctly demonstrates the problem now would incorrectly demonstrate it fixed.

    Notes on things that are easy to get wrong

    • refresh_interval is only populated once the compositor acknowledges a frame. A window that calls gdk_frame_clock_begin_updating() without ever drawing keeps GDK's 16667us default and looks exactly like a hard 60Hz cap. Any measurement here has to actually draw.
    • An exact 60.00 does not by itself identify VsyncWaiterFallback: the Flutter-free reproducer above produces the same exact 60.00 straight from GDK's clock.
    • eglSwapBuffers isn't the bottleneck. GDK calls eglSwapInterval(egl_display, 0) on every make-current (gdkglcontext-wayland.c:574), and __GL_SYNC_TO_VBLANK=0 / vblank_mode=0 change nothing.
    • None of this applies to X11. Under Xwayland the reproducer gives 60.00fps with complete=7/8 in both modes, the GL path and the cairo control alike. So _NET_WM_FRAME_TIMINGS does arrive and timings do complete; what's missing is a non-zero refresh_interval in it (gdkdisplay-x11.c:1423). Whatever caps X11 is a different mechanism, and since cairo is capped too, the GL path isn't it. I only tested Xwayland, not a native X11 session.
    • MinikPLayer reported on Desktop app frame rate isn't synced to display #49757 reaching 165Hz with a vsync waiter plus a patched FRAME_INTERVAL in gdk/gdkframeclockidle.c. I haven't reproduced that, but it is consistent with the mechanism here: patching the constant changes the fallback the clock uses precisely because timings never complete. It also shows the cap can be lifted without leaving gdk_cairo_draw_from_gl, just not in a way that can ship, since it requires patching the system GTK.

    One more datapoint, because it rules out the obvious workaround: #189584 renders Impeller into a wl_subsurface and paints a cairo background on the parent, which is enough to keep GDK's frame accounting alive. On that branch the toplevel clock runs at refresh_interval=6944us with complete=7/8. The frame-clock half of the problem is solved there. The parent still only commits 48 times a second, below what the OpenGL path manages. So getting GDK to learn the refresh rate is necessary and not sufficient.

    Implication

    Presentation has to stop depending on GTK's paint cycle for its cadence, which is the direction of #189584 and of the GTK4 migration in #94804. A vsync waiter is necessary but must land with such a path, not before it: on its own it's a regression.

    For the pacing source, wp_presentation gives real presentation timestamps for frame_target_time_nanos, which GDK's frame clock can't provide here. KWin, Mutter and wlroots all expose it.

    The waiter builds against master and has tests, if any of it is useful once presentation moves. I'd rather shape it to whatever the team wants than push it as-is.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important issues not at the top of the work listc: proposalA detailed proposal for a change to Flutterplatform-linuxBuilding on or for Linux specificallyteam-linuxOwned by the Linux platform teamtriaged-linuxTriaged by the Linux platform team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions