diff --git a/assets/common.css/main.css b/assets/common.css/main.css
index d9bccaf6..c37e59fc 100644
--- a/assets/common.css/main.css
+++ b/assets/common.css/main.css
@@ -22,3 +22,4 @@ html, body {
 }
 
 @include loading.css;
+@include perf.css;
diff --git a/assets/common.css/perf.css b/assets/common.css/perf.css
new file mode 100644
index 00000000..9756a1c6
--- /dev/null
+++ b/assets/common.css/perf.css
@@ -0,0 +1,100 @@
+.msz-perfs {
+    position: fixed;
+    bottom: 4px;
+    left: 4px;
+    display: flex;
+    gap: 2px;
+    flex-direction: column-reverse;
+    align-items: flex-start;
+    opacity: .5;
+}
+.msz-perfs-right {
+    left: initial;
+    right: 4px;
+}
+.msz-perfs:hover {
+    opacity: 1;
+}
+
+.msz-perf {
+    background-color: #111d;
+    color: #fff;
+    font-family: monospace;
+    font-size: 12px;
+    line-height: 20px;
+    padding: 4px 8px;
+    border-radius: 6px;
+}
+.msz-perfs:hover .msz-perf {
+    backdrop-filter: blur(10px);
+}
+
+.msz-perf-number {
+    color: #fff;
+}
+.msz-perf-unit {
+    color: #888;
+}
+
+.msz-perf-header {
+    display: flex;
+    flex: 0 0 auto;
+    gap: 4px;
+}
+.msz-perf:hover .msz-perf-header {
+    border-bottom: 1px solid #888;
+    margin-bottom: 2px;
+}
+
+.msz-perf-type {
+    flex: 0 0 auto;
+    font-weight: 700;
+    min-width: 60px;
+}
+.msz-perf-type-navigation {
+    color: #f0f;
+}
+.msz-perf-type-other {
+    color: #0ff;
+}
+
+.msz-perf-target {
+    flex: 1 0 auto;
+    min-width: 200px;
+}
+.msz-perf-target-host,
+.msz-perf-target-path,
+.msz-perf-target-query {
+    display: inline-block;
+}
+.msz-perf-target-host,
+.msz-perf-target-query {
+    color: #888;
+}
+
+.msz-perf-total {
+    flex: 0 0 auto;
+    min-width: 80px;
+    text-align: right;
+}
+
+.msz-perf-timings {
+    display: none;
+    border-collapse: collapse;
+    width: 100%;
+}
+.msz-perf:hover .msz-perf-timings {
+    display: table;
+}
+
+.msz-perf-timing-name {
+    font-weight: 700;
+    min-width: 60px;
+}
+.msz-perf-timing-comment {
+    color: #888;
+}
+.msz-perf-timing-duration {
+    min-width: 80px;
+    text-align: right;
+}
diff --git a/assets/common.js/main.js b/assets/common.js/main.js
index e1c805b6..ef40e85e 100644
--- a/assets/common.js/main.js
+++ b/assets/common.js/main.js
@@ -2,6 +2,7 @@
 #include csrf.js
 #include html.js
 #include meta.js
+#include perf.jsx
 #include uniqstr.js
 #include xhr.js
 
diff --git a/assets/common.js/perf.jsx b/assets/common.js/perf.jsx
new file mode 100644
index 00000000..50edc3a5
--- /dev/null
+++ b/assets/common.js/perf.jsx
@@ -0,0 +1,72 @@
+#include html.js
+
+(() => {
+    const perfs = <div class="msz-perfs"/>;
+    perfs.ondblclick = () => {
+        perfs.classList.toggle('msz-perfs-right');
+    };
+
+    const appendReal = elem => {
+        $appendChild(perfs, elem);
+    };
+
+    let append = elem => {
+        append = appendReal;
+        $appendChild(document.body, perfs);
+        appendReal(elem);
+    };
+
+    (new PerformanceObserver(list => {
+        for(const entry of list.getEntries()) {
+            if(entry.serverTiming.length < 1)
+                break;
+
+            const url = new URL(entry.name);
+
+            let total = 0;
+            let queries = -1;
+            const timings = <table class="msz-perf-timings"/>;
+            for(const timing of entry.serverTiming) {
+                if(timing.name === 'msz-queries') {
+                    queries = Math.ceil(timing.duration);
+                    continue;
+                }
+
+                total += timing.duration;
+                $appendChild(timings, <tr class="msz-perf-timing">
+                    <td class="msz-perf-timing-name">{timing.name}</td>
+                    <td class="msz-perf-timing-comment">{decodeURIComponent(timing.description)}</td>
+                    <td class="msz-perf-timing-duration">
+                        <span class="msz-perf-number">{timing.duration}</span>
+                        <span class="msz-perf-unit">ms</span>
+                    </td>
+                </tr>);
+            }
+
+            append(<div class="msz-perf">
+                <div class="msz-perf-header">
+                    <div class={`msz-perf-type msz-perf-type-${entry instanceof PerformanceNavigationTiming ? 'navigation' : 'other'}`}>
+                        {entry instanceof PerformanceNavigationTiming ? entry.type : (
+                            entry.initiatorType === 'xmlhttprequest' ? 'xhr' : entry.initiatorType
+                        )}
+                    </div>
+                    <div class="msz-perf-target">
+                        {url.host !== location.host ? <div class="msz-perf-target-host">{url.host}</div> : null}
+                        <div class="msz-perf-target-path">{url.pathname}</div>
+                        {url.search !== '' ? <div class="msz-perf-target-query">{url.search}</div> : null}
+                    </div>
+                    {queries > 0 ? <div class="msz-perf-total">
+                        <span class="msz-perf-number">{queries}</span>
+                        {' '}
+                        <span class="msz-perf-unit">{queries === 1 ? 'query' : 'queries'}</span>
+                    </div> : null}
+                    <div class="msz-perf-total">
+                        <span class="msz-perf-number">{total.toFixed(5)}</span>
+                        <span class="msz-perf-unit">ms</span>
+                    </div>
+                </div>
+                {timings}
+            </div>);
+        }
+    })).observe({ entryTypes: ['navigation', 'resource'] });
+})();
diff --git a/composer.lock b/composer.lock
index b6a42d3b..2385af28 100644
--- a/composer.lock
+++ b/composer.lock
@@ -501,11 +501,11 @@
         },
         {
             "name": "flashwave/index",
-            "version": "v0.2504.21040",
+            "version": "v0.2504.31541",
             "source": {
                 "type": "git",
                 "url": "https://patchii.net/flash/index.git",
-                "reference": "42adbe5da8325d66a996e5d69dbb01def80743fc"
+                "reference": "c2bcb0611bee08a9a21930b92c96cc85304562b6"
             },
             "require": {
                 "ext-mbstring": "*",
@@ -554,7 +554,7 @@
             ],
             "description": "Composer package for the common library for my projects.",
             "homepage": "https://railgun.sh/index",
-            "time": "2025-04-02T10:41:10+00:00"
+            "time": "2025-04-03T15:42:01+00:00"
         },
         {
             "name": "graham-campbell/result-type",
diff --git a/misuzu.php b/misuzu.php
index 0211a960..afece484 100644
--- a/misuzu.php
+++ b/misuzu.php
@@ -1,31 +1,41 @@
 <?php
 namespace Misuzu;
 
-define('MSZ_STARTUP', microtime(true));
+define('MSZ_STARTUP', hrtime(true));
 define('MSZ_ROOT', __DIR__);
 
 require_once __DIR__ . '/vendor/autoload.php';
 
-\Dotenv\Dotenv::createImmutable(Misuzu::PATH_ROOT)->load();
+$msz = (function() {
+    $timings = new \Index\Performance\Timings(
+        new \Index\Performance\Stopwatch(MSZ_STARTUP),
+    );
 
-if(!empty($_ENV['SENTRY_DSN']))
-    \Sentry\init(['dsn' => $_ENV['SENTRY_DSN']]);
+    try {
+        \Dotenv\Dotenv::createImmutable(Misuzu::PATH_ROOT)->load();
 
-(function(\Whoops\RunInterface $whoops) {
-    if(Misuzu::cli())
-        $whoops->pushHandler(new Whoops\SentryPlainTextHandler);
-    elseif(Misuzu::debug())
-        $whoops->pushHandler(new \Whoops\Handler\PrettyPageHandler);
-    else
-        $whoops->pushHandler(new Whoops\SentryPageHandler);
+        if(!empty($_ENV['SENTRY_DSN']))
+            \Sentry\init(['dsn' => $_ENV['SENTRY_DSN']]);
 
-    $whoops->register();
-})(new \Whoops\Run);
+        $whoops = new \Whoops\Run;
+        if(Misuzu::cli())
+            $whoops->pushHandler(new Whoops\SentryPlainTextHandler);
+        elseif(Misuzu::debug())
+            $whoops->pushHandler(new \Whoops\Handler\PrettyPageHandler);
+        else
+            $whoops->pushHandler(new Whoops\SentryPageHandler);
 
-$msz = new MisuzuContext(
-    $_ENV['DATABASE_DSN'] ?? 'null:',
-    $_ENV['DOMAIN_ROLES'] ?? 'localhost=main,redirect,storage',
-    $_ENV['STORAGE_PATH_LOCAL'] ?? Misuzu::PATH_STORAGE,
-    $_ENV['STORAGE_PATH_REMOTE'] ?? '/_storage',
-    $_ENV['TEMPLATE_CACHE'] ?? '',
-);
+        $whoops->register();
+
+        return new MisuzuContext(
+            $timings,
+            $_ENV['DATABASE_DSN'] ?? 'null:',
+            $_ENV['DOMAIN_ROLES'] ?? 'localhost=main,redirect,storage',
+            $_ENV['STORAGE_PATH_LOCAL'] ?? Misuzu::PATH_STORAGE,
+            $_ENV['STORAGE_PATH_REMOTE'] ?? '/_storage',
+            $_ENV['TEMPLATE_CACHE'] ?? '',
+        );
+    } finally {
+        $timings->lap('startup');
+    }
+})();
diff --git a/public/index.php b/public/index.php
index 7145f851..7e6f0041 100644
--- a/public/index.php
+++ b/public/index.php
@@ -27,6 +27,7 @@ if(is_file($msz->dbCtx->getMigrateLockPath())) {
 }
 
 $request = \Index\Http\HttpRequest::fromRequest();
+$msz->timings->lap('request');
 $msz->registerRequestRoutes($request);
 
 if($msz->routingCtx->domainRoles->hasRole($request->getHeaderLine('Host'), 'main')) {
diff --git a/src/Auth/AuthInfo.php b/src/Auth/AuthInfo.php
index e6b51e38..63cf5bcd 100644
--- a/src/Auth/AuthInfo.php
+++ b/src/Auth/AuthInfo.php
@@ -169,10 +169,6 @@ class AuthInfo implements LastModifiedExtensionInterface {
         return $this->usersCtx->tryGetActiveBan($this->userInfo);
     }
 
-    public function getDisplayTimings(): bool {
-        return Misuzu::debug() || $this->getPerms('global')->check(Perm::G_TIMINGS_VIEW);
-    }
-
     #[\Override]
     public function getFunctions() {
         return [
@@ -181,7 +177,6 @@ class AuthInfo implements LastModifiedExtensionInterface {
             new TwigFunction('auth_impersonating', $this->getImpersonating(...)),
             new TwigFunction('auth_get_real_user_info', $this->getRealUserInfo(...)),
             new TwigFunction('auth_get_ban_info', $this->getBanInfo(...)),
-            new TwigFunction('auth_display_timings', $this->getDisplayTimings(...)),
         ];
     }
 }
diff --git a/src/DatabaseContext.php b/src/DatabaseContext.php
index 4cfe8050..75420785 100644
--- a/src/DatabaseContext.php
+++ b/src/DatabaseContext.php
@@ -6,12 +6,9 @@ use Index\Db\{DbBackends,DbConnection};
 use Index\Db\Migration\{DbMigrationManager,DbMigrationRepo,FsDbMigrationRepo};
 use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\Filters\PrefixFilter;
-use Index\Templating\Extension\TplExtensionCommon;
-use Twig\TwigFunction;
-use Twig\Extension\LastModifiedExtensionInterface;
 
-class DatabaseContext implements RouteHandler, LastModifiedExtensionInterface {
-    use RouteHandlerCommon, TplExtensionCommon;
+class DatabaseContext implements RouteHandler {
+    use RouteHandlerCommon;
 
     public private(set) DbConnection $conn;
 
@@ -49,11 +46,4 @@ class DatabaseContext implements RouteHandler, LastModifiedExtensionInterface {
         if(is_file($this->getMigrateLockPath()))
             return 503;
     }
-
-    #[\Override]
-    public function getFunctions() {
-        return [
-            new TwigFunction('sql_query_count', $this->getQueryCount(...)),
-        ];
-    }
 }
diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php
index 8d24e4ed..c01538cf 100644
--- a/src/MisuzuContext.php
+++ b/src/MisuzuContext.php
@@ -7,6 +7,7 @@ use Index\Config\Db\DbConfig;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
 use Index\Http\Routing\{Router,RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\Filters\PrefixFilter;
+use Index\Performance\Timings;
 use Index\Snowflake\{BinarySnowflake,RandomSnowflake,SnowflakeGenerator};
 use Index\Urls\{ArrayUrlRegistry,UrlRegistry,UrlSource};
 
@@ -52,6 +53,7 @@ class MisuzuContext implements RouteHandler {
     public private(set) Users\UsersContext $usersCtx;
 
     public function __construct(
+        public private(set) Timings $timings,
         #[\SensitiveParameter] string $dsn,
         string $domainRoles,
         string $storageLocalPath,
@@ -61,6 +63,7 @@ class MisuzuContext implements RouteHandler {
         $this->deps = new Dependencies;
         $this->deps->register($this);
         $this->deps->register($this->deps);
+        $this->deps->register($timings);
 
         $this->deps->register($this->dbCtx = new DatabaseContext($dsn));
         $this->deps->register($this->dbCtx->conn);
diff --git a/src/Routing/RoutingContext.php b/src/Routing/RoutingContext.php
index b7849969..51993abe 100644
--- a/src/Routing/RoutingContext.php
+++ b/src/Routing/RoutingContext.php
@@ -6,8 +6,11 @@ use Index\Config\Config;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
 use Index\Http\Routing\{Router,RouteHandler as IndexRouteHandler};
 use Index\Http\Routing\Filters\FilterInfo;
+use Index\Performance\Timings;
 use Index\Templating\Extension\TplExtensionCommon;
 use Index\Urls\{ArrayUrlRegistry,UrlRegistry,UrlSource};
+use Misuzu\{DatabaseContext,Misuzu,Perm};
+use Misuzu\Auth\AuthInfo;
 use Twig\TwigFunction;
 use Twig\Extension\LastModifiedExtensionInterface;
 
@@ -20,6 +23,9 @@ class RoutingContext implements LastModifiedExtensionInterface {
     public private(set) DomainRoles $domainRoles;
 
     public function __construct(
+        private Timings $timings,
+        private AuthInfo $authInfo,
+        private DatabaseContext $dbCtx,
         Dependencies $deps,
         string $domainRoles,
     ) {
@@ -47,7 +53,21 @@ class RoutingContext implements LastModifiedExtensionInterface {
     }
 
     public function dispatch(?HttpRequest $request = null): void {
-        $this->router->dispatch($request);
+        if($request === null) {
+            $request = HttpRequest::fromRequest();
+            $this->timings->lap('request');
+        }
+
+        $response = $this->router->handle($request);
+        $this->timings->lap('response');
+
+        if(Misuzu::debug() || $this->authInfo->getPerms('global')->check(Perm::G_TIMINGS_VIEW))
+            $response = $response->withHeader(
+                'Server-Timing',
+                sprintf('%s, msz-queries;dur=%d', $this->timings, $this->dbCtx->getQueryCount())
+            );
+
+        Router::output($response);
     }
 
     #[\Override]
diff --git a/src/Template.php b/src/Template.php
index 655ad104..dbf772b5 100644
--- a/src/Template.php
+++ b/src/Template.php
@@ -2,17 +2,20 @@
 namespace Misuzu;
 
 use InvalidArgumentException;
+use Index\Performance\Timings;
 use Index\Templating\TplEnvironment;
 
 final class Template {
     private const FILE_EXT = '.twig';
 
+    private static Timings $timings;
     private static TplEnvironment $env;
 
     /** @var array<string, mixed> */
     private static array $vars = [];
 
     public static function init(MisuzuContext $env): void {
+        self::$timings = $env->timings;
         self::$env = $env->tplCtx->env;
     }
 
@@ -22,13 +25,15 @@ final class Template {
 
     /** @param array<string, mixed> $vars */
     public static function renderRaw(string $file, array $vars = []): string {
-        if(!defined('MSZ_TPL_RENDER'))
-            define('MSZ_TPL_RENDER', microtime(true));
+        self::$timings->lap('load');
+        try {
+            if(!str_ends_with($file, self::FILE_EXT))
+                $file = str_replace('.', DIRECTORY_SEPARATOR, $file) . self::FILE_EXT;
 
-        if(!str_ends_with($file, self::FILE_EXT))
-            $file = str_replace('.', DIRECTORY_SEPARATOR, $file) . self::FILE_EXT;
-
-        return self::$env->render($file, array_merge(self::$vars, $vars));
+            return self::$env->render($file, array_merge(self::$vars, $vars));
+        } finally {
+            self::$timings->lap('render');
+        }
     }
 
     /** @param array<string, mixed> $vars */
diff --git a/src/Templating/TemplatingExtension.php b/src/Templating/TemplatingExtension.php
index 45abaa2b..0b005b66 100644
--- a/src/Templating/TemplatingExtension.php
+++ b/src/Templating/TemplatingExtension.php
@@ -44,7 +44,6 @@ final class TemplatingExtension implements LastModifiedExtensionInterface {
             new TwigFunction('git_commit_hash', GitInfo::hash(...)),
             new TwigFunction('git_tag', GitInfo::tag(...)),
             new TwigFunction('git_branch', GitInfo::branch(...)),
-            new TwigFunction('startup_time', fn(float $time = MSZ_STARTUP) => microtime(true) - $time),
             new TwigFunction('msz_header_menu', $this->getHeaderMenu(...)),
             new TwigFunction('msz_user_menu', $this->getUserMenu(...)),
             new TwigFunction('msz_manage_menu', $this->getManageMenu(...)),
diff --git a/templates/_layout/footer.twig b/templates/_layout/footer.twig
index 74281deb..be32a26a 100644
--- a/templates/_layout/footer.twig
+++ b/templates/_layout/footer.twig
@@ -12,13 +12,6 @@
                 <a href="https://patchii.net/flashii/misuzu/src/tag/{{ git_tag }}" target="_blank" rel="noopener" class="footer__link">{{ git_tag }}</a>
             {% endif %}
             # <a href="https://patchii.net/flashii/misuzu/commit/{{ git_commit_hash(true) }}" target="_blank" rel="noopener" class="footer__link">{{ git_commit_hash() }}</a>
-            {% if auth_display_timings() %}
-                / Index {{ ndx_version() }}
-                / {{ sql_query_count()|number_format }} queries
-                / {{ (startup_time() - startup_time(constant('MSZ_TPL_RENDER')))|number_format(5) }} load
-                / {{ startup_time(constant('MSZ_TPL_RENDER'))|number_format(5) }} template
-                / {{ startup_time()|number_format(5) }} total
-            {% endif %}
         </div>
     </div>
 </footer>
diff --git a/templates/home/landing.twig b/templates/home/landing.twig
index 6142cfb6..8f520dea 100644
--- a/templates/home/landing.twig
+++ b/templates/home/landing.twig
@@ -89,11 +89,6 @@
 {% endif %}
                     # <a href="https://github.com/flashwave/misuzu/commit/{{ git_commit_hash(true) }}" target="_blank" rel="noreferrer noopener">{{ git_commit_hash() }}</a>
                 </div>
-{% if auth_display_timings() %}
-                <div class="landingv2-footer-copyright-line">
-                    {{ sql_query_count()|number_format }} queries / {{ (startup_time() - startup_time(constant('MSZ_TPL_RENDER')))|number_format(5) }} load / {{ startup_time(constant('MSZ_TPL_RENDER'))|number_format(5) }} template / {{ startup_time()|number_format(5) }} total
-                </div>
-{% endif %}
             </div>
         </div>
     </footer>
diff --git a/tools/cron b/tools/cron
index 8f616059..cc47383b 100755
--- a/tools/cron
+++ b/tools/cron
@@ -407,5 +407,5 @@ try {
     }
 } finally {
     unlink($cronPath);
-    echo 'Took ' . number_format(microtime(true) - MSZ_STARTUP, 5) . 'ms.' . PHP_EOL;
+    printf('Took %.5fms.%s', ((float)hrtime(true) - MSZ_STARTUP) / 1000000, PHP_EOL);
 }
diff --git a/tools/render-tpl b/tools/render-tpl
index 20e04aa4..ccfcc935 100755
--- a/tools/render-tpl
+++ b/tools/render-tpl
@@ -86,8 +86,4 @@ $ctx = $msz->tplCtx->loadTemplate(implode(' ', array_slice($argv, $pathIndex)));
 foreach($tplArgs as $name => $value)
     $ctx->setVar($name, $value);
 
-// things expect this to be defined which is also a bit yucky
-if(!defined('MSZ_TPL_RENDER'))
-    define('MSZ_TPL_RENDER', microtime(true));
-
 echo $ctx->render();