If you have a Java Swing application and
| migrating one view at a time |
, you have had two ways to do that. Either take on a rewrite project with your favorite web framework or stream it to the web using solutions like SwingBridge Streamer.
Now we are giving you a third option, SwingBridge Emulators. They run the Swing API on top of Vaadin, so migrating a legacy Swing desktop app to a web application is mostly a mechanical swap of import statements. AI skills and guardrails, included in the kit, handle the rest of the migration.
The Emulators are released under open source license, allowing you to use them freely in your migration project. Read on for more information on how they work and an example run on a third-party application!
How SwingBridge Emulators run Swing code on Vaadin
vaadinx.awt.* mirrors java.awt.* for the Component and Container hierarchy, and vaadinx.swing.* mirrors javax.swing.* for the JComponent subclasses. Every vaadinx.awt.Component holds a real Vaadin component as its peer and forwards behavior to it.
Here is a view from the bundled CRUD example, shortened. The imports change; the body does not.
// before
import javax.swing.BorderFactory;
import javax.swing.BoxLayout;
import javax.swing.JLabel;
import javax.swing.JPanel;
import java.awt.BorderLayout;
// after
import vaadinx.swing.BorderFactory;
import vaadinx.swing.BoxLayout;
import vaadinx.swing.JLabel;
import vaadinx.swing.JPanel;
import vaadinx.awt.BorderLayout;
public final class EmployeePreviewPanel extends JPanel {
private final JLabel nameValue = new JLabel(EMPTY);
// …
public EmployeePreviewPanel() {
super(new BorderLayout());
setBorder(BorderFactory.createTitledBorder("Preview"));
JPanel rows = new JPanel();
rows.setLayout(new BoxLayout(rows, BoxLayout.Y_AXIS));
rows.add(row("Name", nameValue));
// …
add(rows, BorderLayout.NORTH);
}
} Over forty Java Swing components are emulated, along with six layout managers, Action routing, keyboard bindings, focus traversal, Timer and SwingWorker, clipboard and drag-and-drop.
When a method has no Vaadin counterpart it logs a WARN and returns a default instead of throwing. About 450 methods do this. The app keeps running, but a default can be the wrong answer, and each warning is something to check. Genuine programming errors still throw what Java Swing would throw.
Rewrite, streaming or emulation: choosing a Swing migration path
Stream it. SwingBridge Streamer runs your unmodified app on the server and streams the rendered UI to the browser. It needs no source access and no per-component coverage, so third-party toolkits and custom painting simply work. What you still own afterwards is a Java Swing application.
Rewrite it. Build a fresh Vaadin app and you get idiomatic code immediately. You also have to reproduce twenty years of edge cases from a specification, and behavior the specification misses can change without anyone deciding to change it.
Port it. The emulators sit between those. The transform is mechanical, and the time goes into checking the result against the running desktop app. The emulators aim to reproduce Java Swing's contract, bugs included, with no deliberate improvements. Where the emulation is incomplete, behavior can differ without an error.
Because the ported sources stay line-for-line close to the Java Swing sources, you can keep developing the Swing mainline and re-run the swap on each merged delta. Business logic crosses once.
Landing is a legitimate place to stop: off the desktop, on Vaadin, with source you own, refined toward idiomatic Vaadin one view at a time as far as the business case justifies. The catch is that the code stays Swing-shaped, on an emulation layer maintained on a best-effort basis. If you later want idiomatic Vaadin code, that rewrite is deferred rather than avoided.
Case study: migrating a 10,000-line Swing app to the web
testapps/inventory is Ganesh Tiwari's java-inventory-management-system-swing-hibernate, written by somebody who had never heard of this project: Hibernate with an embedded H2 database, sixty-five source files, a login window, 42 JOptionPane calls and two third-party Swing libraries. Before the port we made the few changes its PROVENANCE.md lists, among them a database seed and dropping one SwingX call.
An agent migrated it by following the kit's guide. At commit b51bd3c we classified every line of the 64 files both versions share. A line is mechanical if the swap tool rewrote it (imports, blanks between them, fully qualified type references); anything else the agent changed is a hand edit.
| inventory, 64 shared files (10,082 lines) | |
|---|---|
| Lines byte-identical after migration | 9,811 (97.3%) |
| Lines rewritten mechanically | 192 (1.9%) |
| Lines changed or removed by hand | 79 (0.8%) |
| Lines added by hand | 117 |
That is 196 hand-edited lines, 156 of them in three files. Main got the one structural refactor every migration does, splitting the process entry point from the per-UI entry point. AppFrame kept its login state in a static field, and Seed needed its constants declared safe to share between sessions. The port also deleted the 67-line AppStarter and added five small files.
Line counts don't prove behavior, though. In the first port, three Saves that write to components from a SwingWorker silently did nothing. The fix went into the emulators; the app code stayed as it was. In our run on 30 September Item Entry saved directly, but Transfer and Return rejected a quantity typed into the table until we opened another cell: where Java Swing commits the app's custom cell editor on Enter, Tab or a button click, the emulated table did not. That gap was still open at b51bd3c, and our browser-only click-through didn't cover every screen.
How JOptionPane modal dialogs still block in the browser
In Java Swing, JOptionPane.showConfirmDialog does not return until the user answers, and code written against that reads straight down the page:
int result = JOptionPane.showConfirmDialog(this, "Delete this record?");
if (result == JOptionPane.YES_OPTION) {
deleteRecord();
} A web application cannot normally block like that. Vaadin handles each request on a server thread that holds the user's session lock, and the browser sees nothing until that thread lets go. A call that simply waited would never send the response, and the session would hang. This is why most web toolkits make you rewrite dialog code into callbacks.
The emulators handle it: a virtual thread parks the waiting code and hands the request thread back, the dialog renders, and the call returns the user's choice. You need Vaadin Push and JDK 24 or later. Before JEP 491, a virtual thread parked inside a synchronized block pinned the request thread holding the session lock, and the dialog never rendered.
One case can still freeze a session on any JDK: a dialog opened inside a synchronized block while another listener waits for that monitor. hazard-scan lists every synchronized region in your sources.
All 42 of the inventory app's dialog calls came through unchanged, and the confirmations we clicked blocked and returned the answer.
Running a Swing migration with hazard-scan, import-swap and AI skills
The kit is one archive: a guide of seven phases with tickable steps, reference documents, three example apps and three tools.
hazard-scanreports which of 22 known migration hazards it finds, fromSystem.exitcalls and staticJFramesingletons to timezone drift.static-sweepbuilds the static-field worklist from your compiled classes. A field that was safe in one desktop JVM is shared by every session on a server.import-swapdoes the rewrite, driven by a table of ported types read off the classpath.
None of them needs the network: the scan and the rewrite run on an air-gapped machine. Building the migrated app still needs Maven Central or a mirror, and an agent sends the code it reads to its model.
The guardrails module is an ArchUnit rule set for your migrated app's tests. It fails the build on unvetted static state, on a component held in a static field, and on System.exit. It resolves the type hierarchy, so it catches static MainFrame INSTANCE where a grep for static JFrame would not.
SwingBridge Skills
The guides were written to be followed by an agent. Three Claude Code skills ship in the kit: /migrate-testapp for a bundled example, /migrate-your-app for an app you copy into the kit, and /migrate-swing-app <folder> for one in its own git checkout, migrated in place as a single diff to review. A skill is done when the app compiles, starts and survives a click-through. The first inventory port met that bar with three broken Saves, so checking behavior stays with you.
For other agents, agent-prompt.md holds the same instructions as plain Markdown, and the guides work by hand too.
SwingBridge MCP
SwingBridge MCP is a -javaagent you attach to your Java Swing app before you migrate it. It exposes the desktop app's accessibility tree and interactions over MCP, so an agent can drive it and record how it behaves, then check the browser app against that. Version 1.0 is on the swing-mcp releases page. It serves one session at a time and has no authentication. Keep it on your own machine.
Limitations: what won't migrate from Swing
Three capabilities are permanently out of scope. JApplet is gone, as is the JDK class itself as of Java 26. Look-and-feel dispatch is out: UIManager.setLookAndFeel and pluggable *UI classes go away, and styling becomes a Vaadin and CSS concern. User-authored Graphics painting is out: paintComponent overrides, custom-painted widgets and JTable.print(). The one exception is a hand-written Printable.print(Graphics), which the optional printing module renders to a PDF download.
There is no binary drop-in. Every Swing-touching dependency has to be recompiled against vaadinx.*, from source or through an add-on. Two add-ons ship today, for JGoodies Forms 1.2.1 and JCalendar 1.4, as free starter prototypes.
A Java Swing app whose own code is built on Spring isn't supported yet, because a singleton bean holding one user's state would leak it to the next. Hosting a Spring-free app on Spring Boot is fine.
If most of your app's value sits in custom painting, look-and-feel work or libraries you have no source for, Streamer covers them.
Try it out
The repository is github.com/vaadin/swingbridge-emulators. The libraries are on Maven Central as 1.0.0-rc1; the kit is built from the main branch as 0.1-SNAPSHOT, newer than rc1 and what our b51bd3c port ran on. Maintenance is best-effort, but issues get read.
The emulator library is GPLv2 with the Classpath Exception, like OpenJDK; add-ons and example apps have their own licenses. Linking your app against the library creates no obligation to publish your source.
A migrated app also needs the emulators' servlet or Spring Boot module, requests on platform threads, and --add-opens java.base/java.lang=ALL-UNNAMED. It builds and runs on JDK 24+, though its bytecode can target Java 21.
Build once (-DskipTests skips the repository's own tests):
git clone https://github.com/vaadin/swingbridge-emulators
cd swingbridge-emulators && ./mvnw -C clean install -DskipTests That produces the kit, zip-distro/target/swingbridge-emulators-0.1-SNAPSHOT-dist.zip. The cheapest test of fit is a read-only scan of your own app from the unzipped kit:
tools/bin/hazard-scan <your app>/src/main/java --report hazards.md To see the emulated surface in a browser, run ./mvnw -C -pl sampler exec:exec in the repository and open http://localhost:8080. To migrate a bundled example with an agent, start Claude Code in the unzipped kit and type:
/migrate-testapp crud The skill works on a copy in work/; testapps/crud/1-emulators/ is the finished migration to diff against.
We would like to hear from anyone who runs this against a real application, particularly about which third-party Java Swing libraries block you and which hazards the scan missed. Both go in the repository's issue tracker.
FAQ
Can I run a Java Swing application in a browser without rewriting it?
Yes. SwingBridge Emulators provide the Swing API as vaadinx.swing.* and vaadinx.awt.* packages backed by real Vaadin components. You change the imports, recompile, and the app runs as a Vaadin web application. In the inventory app migration, 97.3% of lines stayed the same.
How is this different from SwingBridge Streamer?
Streamer runs your existing desktop app and streams it to the browser without changing it. Emulators convert the code so it runs on Vaadin components, which you can then keep developing as a web app.
Which Java version do I need?
JDK 24 or later. Migrated code runs on Java 21+ bytecode. The JDK 24 requirement comes from virtual threads that no longer pin carrier threads in synchronized blocks (JEP 491), which modal dialogs rely on.
Do modal dialogs like JOptionPane still work?
Yes. The calling code waits on a virtual thread while Vaadin Push updates the browser, so code that reads a dialog's return value works unchanged.
What can't be migrated?
JApplet, pluggable look-and-feel, custom Graphics painting (apart from an optional PDF printing module), Spring singleton beans, and third-party Swing libraries you don't have the source code for.
How is it licensed?
GPLv2 with the Classpath Exception. Check the JEP 491 explanation with the author; the post links to JEP 491 but the exact reason should be in his words.