Running scout against a clean Medusa 2.14.0 cold-boot, 8 of 15 admin
scenarios started failing seconds after login. The trigger turned out to be a
3/5 (60%) intermittent 404 on
GET /admin/users/me — with the same cookie, the same
actor_id, and the same SQL hitting postgres and returning the row.
Filed upstream; Medusa maintainer confirmed in 25 minutes and
identified a buffer-aliasing race in the mikro-orm entity serializer.
On Medusa 2.14.0, after a successful login, the admin SPA's first call to GET /admin/users/me during navigation to any sub-route returns 404 in about 60% of attempts. The SPA reacts by redirecting the user back to /login. The same flow is rock-solid on 2.13.6 (0/5 across many trials).
Same request, same cookie session, same actor_id — but the response flips between 200 (with user) and 404 ("User with id: X was not found") within a 2-second window. Postgres receives the exact ORM-generated query and returns the row; the row is dropped somewhere between the DB driver and the route handler.
This is a regression in the entity serialization layer, not in the auth/session or DB path. The row is dropped in mikroOrmSerializer, which every module read passes through. getKeys() returns the shared keyCollectionBuffer by reference (not a copy) when the buffer is exactly full; a nested serialize() call during relation traversal then mutates that buffer while the parent loop is still iterating it, intermittently overwriting the parent's remaining keys (potentially including id). The previous implementation (retained as mikro-orm-serializer-old.ts) allocated a fresh Array.from(keys) per serialize() call, so nested calls could never corrupt a parent frame — which is why 2.13.6 does not reproduce.
Root-cause file: packages/core/utils/src/dal/mikro-orm/mikro-orm-serializer.ts
Scope: This is a core, hot serialization path that can intermittently drop rows from any query result. Admin login is just one visible symptom.
The whole point of the report we filed was that we couldn't pinpoint the in-Medusa cause from outside — we just stacked enough evidence that the maintainer could. Four observable layers, in order:
Login → navigate to /collections → 2s later the page bounces back to /login. Sometimes. Roughly 3 in 5 attempts on 2.14.0.
The 200 (right after login) and the 404 (2s later) carry the exact same connect.sid cookie. The 404 body interpolates the real, current admin user_id — so req.auth_context.actor_id resolved correctly. Handler reached the if (!user) NOT_FOUND branch.
Postgres log_statement = 'all' shows the exact ORM-emitted SELECT against the user table reaches the DB during the 404. Running that same SQL by hand 30 times in a row returns 1 row, 30/30. So the row leaves postgres but is gone by the time const [user] = await remoteQuery(query) runs.
md5-comparing every .js file in the request chain — @medusajs/user, modules-sdk, orchestration, framework/http, authenticate-middleware, /admin/users/me/route, /auth/session/route, medusa-config — between 2.13.6 and 2.14.0 produces zero diffs.
Each hypothesis we tested ourselves before filing — so the maintainer didn't have to ask “did you try X?”
Twenty lines. Runs against an unmodified dtc-starter at the v2.14.0 tag.
# pip install playwright && playwright install chromium
import asyncio
from playwright.async_api import async_playwright
async def attempt(p, base_url):
browser = await p.chromium.launch(headless=True)
ctx = await browser.new_context()
page = await ctx.new_page()
await page.goto(f"{base_url}/login", wait_until="networkidle")
await page.locator("input").nth(0).fill("admin@medusa-test.com")
await page.locator("input").nth(1).fill("supersecret")
await page.locator("button").filter(has_text="Continue with Email").click()
await asyncio.sleep(2)
await page.goto(f"{base_url}/collections", wait_until="networkidle")
await asyncio.sleep(2)
kicked = "login" in page.url
await browser.close()
return kicked
async def main():
async with async_playwright() as p:
kicks = sum(await attempt(p, "http://localhost:9000/app") for _ in range(5))
print(f"kicked: {kicks}/5")
asyncio.run(main())