Someone Asked Me “RomM or EmulatorJS” and I Had to Stop Them
That question is like asking whether you should buy a car or an engine. One of those things goes in the other one. RomM is a self-hosted library manager: it scans your ROM folders, scrapes box art and metadata, and gives you a Jellyfin-style browsing grid. EmulatorJS is the thing that actually runs the game in your browser tab. RomM ships EmulatorJS inside it. You don’t pick between them. You pick RomM, and EmulatorJS comes along for the ride.
Here’s the setup I’d run on a home NAS: RomM as the front door, its bundled EmulatorJS handling playback, and zero attempts to stand up EmulatorJS as its own standalone site. That last option used to exist as a Docker image. It’s dead now, and its own documentation tells you to go install RomM instead. We’ll get to that.
What Each Project Actually Is
RomM (rommapp/romm on GitHub, AGPL-3.0, latest release 5.2.0 as of August 2026) is a self-hosted app that scans, organizes, and serves a ROM library across 400-plus platforms. It has a database, a web UI, user accounts, and a scraper pipeline that pulls box art and descriptions from a stack of external metadata sources. Click a game and RomM opens an in-browser player. That player is EmulatorJS.
EmulatorJS (EmulatorJS/EmulatorJS on GitHub, GPL-3.0, latest release v4.2.3) is not an app you install. Its own README says it plainly: it’s “built as a library/plugin, not a standalone website (therefore, no Docker container).” It’s a JavaScript wrapper around compiled RetroArch and libretro cores, running through WebAssembly, that any website can drop in with a script tag and a folder of ROM files. It has no library manager, no metadata, no user accounts, and no server-side save sync unless whatever embeds it builds that part.
For a while, the shortcut around that gap was linuxserver/emulatorjs, a community Docker image that wrapped EmulatorJS in a bare-bones file-browser frontend. It’s deprecated now. LinuxServer’s own docs say flatly they won’t update it and point you at three replacements: gaseous-server, webrcade, and RomM. When the image that used to let you run “just EmulatorJS” as a standalone service tells you to go use RomM, that settles the framing for this whole article.
Metadata Scraping: Where the Box Art Comes From
This is the part of RomM setup that trips people up, because half the providers want an account and half don’t, and the docs don’t put that in one place. Here’s the actual list, gotchas included:
- IGDB needs a Twitch developer application. RomM’s own docs warn that the app name “has to be unique! Picking an existing name will fail silently, with no error messages.” That’s a real hour of your life if you don’t know it going in.
- ScreenScraper needs a free account, set via
SCREENSCRAPER_USERandSCREENSCRAPER_PASSWORD. The official Docker image bakes in a shared developer credential at build time, so ScreenScraper works out of the box even before you add your own login. - MobyGames is not free anymore. RomM’s docs say it outright: “Access to the MobyGames API is a paid feature. While we will continue to support it, we recommend using ScreenScraper instead, as it is free to use.”
- LaunchBox needs no key at all. It matches games against a locally downloaded database by exact filename.
- Hasheous and Playmatch are free, open-source, hash-based matching services, flipped on with
HASHEOUS_API_ENABLED=trueandPLAYMATCH_API_ENABLED=true. - SteamGridDB needs a Steam login and only feeds manual box art picks, not automated scans.
- RetroAchievements needs an API key plus a matching username set per user profile, since achievements are personal, not library-wide.
- Flashpoint and HowLongToBeat round out the list: flash game metadata and completion-time data, both key-free.
None of these are mandatory. Skip every one and RomM still scans your files and shows a plain list. You just get worse box art and fuzzier matches. ScreenScraper and Hasheous are the two I’d turn on first: free, no paid tier drama, and they cover most of the retro catalog without a Twitch developer account slowing you down.
Full example: A working
compose.yamlfor a RomM stack, with placeholder values for every environment variable mentioned below, is worth keeping in a git repo next to your other home lab configs so a NAS rebuild doesn’t mean re-deriving every setting from memory.
Docker Deployment: One Compose File, Not Two
RomM ships an official image and needs a database next to it:
services: romm: image: rommapp/romm:latest container_name: romm environment: - DB_HOST=romm-db - DB_NAME=romm - DB_USER=romm-user - DB_PASSWD=change-me - ROMM_AUTH_SECRET_KEY=generate-with-openssl-rand-hex-32 - SCREENSCRAPER_USER=your-screenscraper-user - SCREENSCRAPER_PASSWORD=your-screenscraper-password - HASHEOUS_API_ENABLED=true ports: - "80:8080" volumes: - /srv/roms/library:/romm/library - /srv/roms/assets:/romm/assets - /srv/roms/config:/romm/config - romm_resources:/romm/resources # covers and screenshots fetched from IGDB depends_on: romm-db: condition: service_healthy restart: unless-stopped
romm-db: image: mariadb:latest container_name: romm-db environment: - MARIADB_ROOT_PASSWORD=change-me-too - MARIADB_DATABASE=romm - MARIADB_USER=romm-user - MARIADB_PASSWORD=change-me volumes: - /srv/roms/db:/var/lib/mysql healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] start_period: 30s start_interval: 10s interval: 10s timeout: 5s retries: 5 restart: unless-stopped
volumes: romm_resources:Two details in there are easy to drop and painful to debug. The romm_resources volume is where every cover and screenshot RomM scrapes actually lands, so without it your whole art library evaporates the next time you pull a new image. And the depends_on has to use condition: service_healthy against a real healthcheck, not the plain list form: MariaDB accepts the container as “started” long before it finishes initializing, and RomM will fail its first boot trying to connect to a database that isn’t ready.
Generate ROMM_AUTH_SECRET_KEY with openssl rand -hex 32 before you first boot the stack, not after. Rotating it later logs everyone out.
There’s no equivalent compose file for EmulatorJS, because there’s nothing to run. If you want it without RomM, you’re writing your own frontend that fetches a ROM, initializes the EJS_ config object, and drops the EmulatorJS script tag on the page yourself. That’s a legitimate weekend project if you’re building a custom kiosk. It’s a bad use of a Tuesday night if all you wanted was to browse a library and hit Play.
Folder Structure RomM Actually Expects
RomM auto-detects two layouts. Structure A is the one to use:
library/├─ roms/│ ├─ snes/│ │ └─ Super Metroid.zip│ └─ n64/│ └─ Ocarina of Time.z64└─ bios/ └─ n64/ └─ ...Mount the parent of roms/ into the container. The platform folder name has to match a known slug from RomM’s supported-platforms list (snes, n64, psx, and so on) or one of the aliases RomM already recognizes from Batocera, RetroBat, and ES-DE, so megadrive/, gamecube/ and n3ds/ scan as-is. Matching is case-insensitive. Anything outside those needs a manual binding in config.yml. Get the slug wrong and RomM just won’t see the platform, no error dialog, no warning, it’s simply absent from the UI. Check the slug list before you rename twenty folders at 11 PM.
BIOS files go in a matching bios/{platform}/ folder and are optional, RomM only asks for them on platforms that legally need firmware to boot at all (PlayStation, Sega CD, and similar). Multi-disc games can live as a folder containing every disc image, and RomM hands all of them to EmulatorJS at once so you swap discs from the emulator’s own menu with no playlist file. RomM’s scanner treats a handful of subfolder names (dlc, hack, manual, mod, patch, update, demo, translation, prototype, screenshots) as tags instead of separate games, so a ROM hack sitting in a hack/ folder next to the original shows up labeled instead of duplicated in your grid.
In-Browser Play and LAN Latency
Since RomM’s Play button is EmulatorJS, asking “which one plays better” is asking whether the car or the engine handles the highway on-ramp better. There’s one player here, not two.
What actually varies is the core, not the frontend. SNES, Genesis, and NES-era cores are light enough that any machine from the last decade runs them without a hitch over LAN, input lag dominated by your browser’s Gamepad API polling rate rather than anything RomM or EmulatorJS does server-side. N64 and PSP cores ask a lot more of the client CPU, because that emulation happens in your browser tab, not on the NAS. A weak thin client trying to run an N64 core over EmulatorJS will chug no matter how fast your NAS is, because the NAS’s only job here is serving the ROM file and the WebAssembly bundle. The heavy lifting happens locally, in the tab.
Controller Support Lives in the Browser, Not on the NAS
Gamepad support is a browser feature, not a RomM feature or an EmulatorJS feature specifically. Both rely on the browser’s Gamepad API, which means a wired USB controller plugged into the client machine works the moment the browser sees it, no drivers, no config file. Bluetooth pads add a small amount of connection overhead before the browser picks them up, but once paired, the actual input latency comes from the same Gamepad API polling loop as USB. Chrome and Firefox both support it; Safari’s support has historically lagged, so if you’re testing on an iPad, try Chrome or a Chromium-based browser first before assuming your pad is broken.
None of this changes based on whether RomM or a raw EmulatorJS embed is serving the page. The player is the same code either way. The only thing RomM adds on top is remembering your button mapping per user profile instead of making you remap on every session, which sounds small until you’ve done it for the fifth time in one evening.
Save States: Server-Synced vs Whatever You Wire Up Yourself
Raw EmulatorJS, embedded on its own, saves states to the browser’s local storage. Close that browser on a different device and your save state stays behind on the first one, unless whatever site embedded EmulatorJS built its own sync layer.
RomM builds that layer. Per its docs, “any in-emulator save or state is written straight back to the server with no manual download or re-upload step.” Save on your desktop at lunch, resume on a tablet on the couch that night, same file, no manual export. That’s the single biggest practical reason to run RomM instead of a bare EmulatorJS embed: the save-sync problem is already solved for you.
Storage and Hardware, Realistically
RomM’s own footprint is small: a Python backend, a MariaDB instance, and static files. Scanning a library of a few thousand ROMs and running metadata scrapes is not a CPU-intensive job, a modest NAS handles it without breaking a sweat. The part that needs horsepower, the actual emulation, runs client-side in EmulatorJS inside your browser tab. Your NAS never emulates anything. It just hands out files.
That means your NAS sizing decision is really a storage decision (how many terabytes of your own dumps you’re keeping) and a “how many concurrent scrapes am I running” decision, not an emulation-performance decision. RomM’s own FAQ puts a comfortable footprint, meaning thousands of ROMs, a few users and occasional scans, at 2 GB of RAM and 2 cores, with the heaviest CPU spikes coming from scans rather than steady-state emulation. Any NAS or mini PC that can already run a couple of other Docker containers alongside it, x86_64 or ARM64, has plenty of headroom left over. If you’re weighing specific NAS hardware, check the vendor’s current listing at purchase time. Don’t plan around a number from a review you half-remember from two years ago; prices and specs both drift.
The Legal Part, Stated Once
Dumping your own physical carts or discs onto your own NAS is a different question from downloading someone else’s dumps off the internet. RomM and EmulatorJS are both just software: they don’t check where your ROM files came from, and neither do I. Where your files came from is between you and whatever laws apply where you live. Moving on.
What I’d Actually Run
Run RomM. It already includes EmulatorJS, so you get the library manager, the metadata scraping, and the in-browser player in one Docker Compose stack, with save states that follow you between devices without a thumb drive. Skip trying to stand up bare EmulatorJS as a NAS app: the one project that offered that path is deprecated, and even its own maintainers point you at RomM as the replacement. This isn’t a two-horse race. It’s one horse, with an engine already installed.
Common Questions
Does RomM use EmulatorJS for in-browser play?
Yes. RomM’s own documentation states that “EmulatorJS is the in-browser retro emulator that powers RomM’s Play button.” RomM doesn’t ship a competing player; it bundles EmulatorJS directly, handles the save-state sync EmulatorJS doesn’t provide on its own, and wraps the whole thing in a library manager with metadata scraping.
Do I need an IGDB or ScreenScraper API key to use RomM?
No, neither is required. RomM scans and lists your files without any metadata provider configured. ScreenScraper works best with your own free account (SCREENSCRAPER_USER/SCREENSCRAPER_PASSWORD), while IGDB needs a Twitch developer app with a unique name, since a duplicate name fails silently with no error.
Can I self-host EmulatorJS as a standalone app without RomM?
Not as a maintained, ready-made app. EmulatorJS’s own README says it’s “built as a library/plugin, not a standalone website (therefore, no Docker container).” The community Docker image that used to fill that gap, linuxserver/emulatorjs, is deprecated, and its docs now recommend RomM as a replacement.
Do RomM save states sync between devices?
Yes. RomM writes in-emulator saves and save states straight back to the server automatically, with no manual download or re-upload step. Start a game on one device, pick it up on another, and the save state is already there, because it lives on the RomM server, not in that first browser’s local storage.
What emulator cores does EmulatorJS support?
EmulatorJS wraps compiled RetroArch and libretro cores running through WebAssembly, covering roughly 35 systems: NES, SNES, Nintendo 64, Game Boy through Game Boy Advance, PlayStation, PSP, Sega Genesis/Saturn/CD, Atari’s various consoles, arcade boards via MAME, and more. RomM inherits this full core list since it embeds EmulatorJS directly.