π― Interview Guide
TunnelFlow β Interview Preparation Guide
Table of Contents
- How to Explain This Project in an Interview
- Key Talking Points to Highlight
- Interview Questions & Answers β System Design
- Interview Questions & Answers β Java & Spring Internals
- Interview Questions & Answers β Concurrency & Async
- Interview Questions & Answers β Networking & Protocols
- Interview Questions & Answers β Error Handling & Edge Cases
- Interview Questions & Answers β Scalability & Production Readiness
- Interview Questions & Answers β Behavioral / Project-Based
1. How to Explain This Project in an Interview
π― The 30-Second Pitch
"I built TunnelFlow β a reverse tunneling system similar to ngrok. It lets developers expose their local development servers to the internet through a public URL. For example, if I'm running a React app on localhost:3000, TunnelFlow gives me a URL like https://abc123.tunnel.rajeshbandi.site that anyone on the internet can use to access my local app. It's built with Java, Spring Boot, and WebSockets, following a clean client-server architecture with a shared protocol module."
π― The 2-Minute Structured Explanation
Use the Problem β Solution β Architecture β Technical Highlights framework:
Problem
"During development, you often need to share your local work with teammates, test webhooks from third-party services like Stripe or GitHub, or demo your app to clients β but localhost isn't accessible from the internet."
Solution
"TunnelFlow solves this by establishing a persistent WebSocket connection between a CLI client on your machine and a public cloud server. The server assigns a unique subdomain. When external HTTP traffic hits that subdomain, the server wraps the HTTP request into a JSON message, sends it to the client over WebSocket, the client forwards it to localhost, gets the response, and sends it back the same way."
Architecture
"The system is split into three Maven modules:
- A protocol module β shared DTOs and message types used by both sides.
- A server β a Spring Boot app deployed on the cloud. It handles WebSocket connections, manages client registrations and tunnels, and intercepts inbound HTTP traffic using a servlet filter.
- A client β a Spring Boot CLI app using Picocli. It connects via WebSocket, registers itself, requests tunnels, and forwards incoming requests to the local application using Java's
HttpClient."
Technical Highlights (pick 2β3 based on the role)
- "I used
CompletableFuture to bridge the async WebSocket protocol with synchronous HTTP request-response β the server's filter thread blocks on a future that gets resolved when the client sends back the response."
- "The server uses Java virtual threads to drain an outbound message queue per client, which keeps WebSocket writes serialized and avoids concurrency issues with
WebSocketSession."
- "I designed a custom application-layer protocol with typed message envelopes (
TunnelMessage) that wrap different payloads like HttpRequestMessage, ClientRegisterRequest, etc."
- "The client supports a multi-app mode where you define multiple services in a YAML config, and it provisions a separate tunnel for each, resolving cross-service URL placeholders before launching the processes."
2. Key Talking Points to Highlight
| Topic |
What to Say |
| Why you built it |
"I wanted to deeply understand how tools like ngrok work under the hood β reverse proxying, WebSocket multiplexing, protocol design β so I built my own from scratch." |
| Design decisions |
"I chose WebSockets over raw TCP because they work through corporate proxies/firewalls and Spring Boot has first-class support. I used a shared protocol module to enforce type safety across client and server." |
| Async coordination |
"The trickiest part was bridging sync HTTP with async WebSocket. I used CompletableFuture with a ConcurrentHashMap keyed by request ID. The filter registers a future, the WebSocket handler resolves it when the response arrives." |
| Thread safety |
"Spring's WebSocketSession is not thread-safe for concurrent writes. I solved this by giving each client a LinkedBlockingQueue drained by a single virtual thread, ensuring serialized writes." |
| Configuration system |
"The client reads a tunnelflow.yaml file, validates it, provisions tunnels for each declared app, resolves ${app.url} placeholders across services, then launches each app as an OS process with the resolved environment." |
| Clean architecture |
"I separated concerns into layers β CLI commands, configuration, handlers, services, runtime, and WebSocket β making it easy to test and extend." |
3. Interview Questions & Answers β System Design
Q1. Can you draw the high-level architecture of TunnelFlow?
A: The system has three components:
ββββββββββββββββ WebSocket (persistent) ββββββββββββββββ
β TunnelFlow βββββββββββββββββββββββββββββββββββββΊβ TunnelFlow β
β Client β TunnelMessage JSON protocol β Server β
β (your PC) β β (cloud VM) β
ββββββββ¬ββββββββ ββββββββ¬ββββββββ
β HttpClient β Servlet Filter
βΌ βΌ
ββββββββββββββββ ββββββββββββββββ
β localhost: β β External Userβ
β 3000 β β (browser) β
ββββββββββββββββ ββββββββββββββββ
The protocol module is a shared Maven dependency that defines TunnelMessage, MessageType, and all request/response DTOs. Both client and server depend on it.
Q2. How does a request travel from an external user to the local app and back?
A: Nine steps:
- External user sends HTTP request to
https://abc123.tunnel.rajeshbandi.site/api/data
TunnelIngressFilter intercepts it, extracts tunnelId from Host header
- Filter looks up
TunnelInfo β clientId β ClientConnection
- Filter creates a
CompletableFuture<HttpResponseMessage> in PendingRequestManager
- Filter wraps the HTTP request as a
TunnelMessage(HTTP_REQUEST) and enqueues it in the client's BlockingQueue
OutboundMessageSender virtual thread takes the message and sends it over WebSocket
- Client's
TunnelWebSocketClient receives it β TunnelMessageReceiver β TunnelMessageDispatcher β HttpRequestMessageHandler
LocalHttpForwarder sends the request to http://localhost:3000/api/data via Java HttpClient, gets the response, and sends it back as TunnelMessage(HTTP_RESPONSE) via WebSocket
- Server's
TunnelWebSocketHandler receives it, calls PendingRequestManager.complete(), the future resolves, and the filter writes the response to the external user
Q3. Why did you choose WebSockets instead of raw TCP sockets or HTTP long polling?
A:
- Firewall-friendly: WebSockets upgrade from HTTP, so they pass through corporate proxies and firewalls. Raw TCP on a custom port would often be blocked.
- Full-duplex: Both sides can send messages at any time without polling.
- Spring Boot support: Spring provides
TextWebSocketHandler on the server side, making it trivial to integrate with the existing Spring ecosystem.
- HTTP long polling would have been too slow and resource-intensive β each pending request would hold an HTTP connection, and there's no way to push serverβclient messages efficiently.
Q4. Why did you create a separate protocol module?
A: To enforce type safety and consistency between client and server. Both sides serialize/deserialize the same DTOs (TunnelMessage, HttpRequestMessage, etc.). If I change a field, both modules get the update at compile time. Without it, I'd risk silent deserialization mismatches at runtime.
Q5. How does the server route incoming HTTP requests to the correct client?
A: Through subdomain-based routing. Each tunnel gets a unique 8-character ID. The public URL is https://{tunnelId}.tunnel.rajeshbandi.site. When a request comes in, TunnelIngressFilter extracts the Host header, parses the subdomain to get the tunnelId, looks it up in TunnelManager's ConcurrentHashMap to get the TunnelInfo, and from there gets the clientId to find the ClientConnection with its WebSocket session.
A: It's a JSON envelope with five fields:
public class TunnelMessage {
String requestId;
MessageType type;
String clientId;
String tunnelId;
String payload;
}
The payload is a double-encoded JSON string β the outer TunnelMessage is JSON, and payload is another JSON string that gets deserialized into the appropriate DTO based on the type field. This avoids polymorphic deserialization and keeps things simple.
Q7. Why is the payload stored as a JSON string rather than a nested object?
A: To avoid Jackson polymorphic type handling. If payload were Object, Jackson wouldn't know which class to deserialize it into without @JsonTypeInfo annotations, which adds complexity and security risks (deserialization attacks). By keeping it as a String, the receiver can explicitly choose the target class based on the MessageType enum:
case HTTP_REQUEST -> objectMapper.readValue(message.getPayload(), HttpRequestMessage.class);
case CLIENT_REGISTER -> objectMapper.readValue(message.getPayload(), ClientRegisterRequest.class);
4. Interview Questions & Answers β Java & Spring Internals
Q8. What Spring Boot features did you use and why?
A:
| Feature |
Where |
Why |
@SpringBootApplication |
Server entry point |
Auto-configuration, component scanning |
WebSocketConfigurer |
WebSocketConfig |
Register WebSocket handler at /ws |
TextWebSocketHandler |
TunnelWebSocketHandler |
Handle JSON text messages |
OncePerRequestFilter |
TunnelIngressFilter |
Intercept all HTTP for tunnel routing |
@Configuration + @Bean |
BeansConfig |
Declare ObjectMapper, HttpClient |
| Picocli + Spring |
Client CLI |
Dependency injection in CLI commands |
@Value |
TunnelConnectionManager |
Inject tunnelflow.server.websocket-url |
@Service, @Component |
Everywhere |
Spring-managed beans with DI |
Q9. How does the CLI framework (Picocli) integrate with Spring?
A: Through PicocliFactory, which implements CommandLine.IFactory. When Picocli needs to instantiate a command class, it calls PicocliFactory.create(Class), which delegates to applicationContext.getBean(cls). This means all @Component command classes get their dependencies injected by Spring:
public <K> K create(Class<K> cls) throws Exception {
return applicationContext.getBean(cls);
}
Q10. Why do you have two ObjectMapper beans in the client?
A: One for JSON (default) and one for YAML (via YAMLFactory):
@Bean public ObjectMapper objectMapper() { return new ObjectMapper(); }
@Bean public ObjectMapper yamlObjectMapper() { return new ObjectMapper(new YAMLFactory()); }
The JSON mapper is used for WebSocket message serialization. The YAML mapper is used by ConfigLoader to parse tunnelflow.yaml.
Q11. Why did you set destroyMethod = "" on the HttpClient bean?
A: Java's HttpClient doesn't have a standard close() or destroy() method. Spring tries to call a destroy method when the application shuts down. Without destroyMethod = "", Spring would throw an error trying to find a default destroy method. This suppresses that behavior.
Q12. How does TunnelMessageDispatcher work?
A: It uses the Strategy Pattern. At construction, it receives all MessageHandler beans via Spring's list injection. It builds a Map<MessageType, MessageHandler> by calling getSupportedType() on each handler:
public TunnelMessageDispatcher(List<MessageHandler> messageHandlers) {
this.handlers = messageHandlers.stream()
.collect(Collectors.toMap(MessageHandler::getSupportedType, Function.identity()));
}
When a message arrives, it simply does handlers.get(message.getType()).handle(message). This makes it trivially extensible β adding a new message type only requires a new MessageHandler implementation.
5. Interview Questions & Answers β Concurrency & Async
Q13. How do you bridge synchronous HTTP with asynchronous WebSocket?
A: Using CompletableFuture + ConcurrentHashMap as a correlation mechanism:
TunnelIngressFilter generates a unique requestId
- It creates a
CompletableFuture<HttpResponseMessage> and stores it in PendingRequestManager (a ConcurrentHashMap<String, CompletableFuture>)
- The filter thread calls
future.get(30, TimeUnit.SECONDS) β blocking
- The request travels over WebSocket to the client and back
- When the
HTTP_RESPONSE message arrives on the WebSocket thread, PendingRequestManager.complete(requestId, response) resolves the future
- The filter thread unblocks and writes the HTTP response
This is conceptually the same as the Correlation ID pattern used in message-driven architectures.
Q14. Why is WebSocketSession.sendMessage() not called from multiple threads?
A: Spring's WebSocketSession is not thread-safe for concurrent writes. If two threads call sendMessage() simultaneously, the messages can get interleaved or corrupt each other. I solved this by using a single-writer pattern:
- Each
ClientConnection has a LinkedBlockingQueue<TunnelMessage>
OutboundMessageSender.start() spawns a single virtual thread that drains this queue in a loop
- Any code that wants to send a message to a client calls
connection.getOutboundQueue().offer(message) β a thread-safe enqueue
- Only the virtual thread calls
session.sendMessage(), ensuring serialized writes
A: Because each connected client gets its own sender thread. With traditional platform threads, 1000 clients = 1000 OS threads, consuming ~1 MB stack memory each. Virtual threads (Project Loom) are extremely lightweight β they're scheduled on a small pool of carrier threads and use minimal memory when blocked on queue.take(). This is a textbook use case: I/O-bound work (waiting for queue items, then doing a WebSocket write) with high concurrency.
Q16. What happens if the client takes longer than 30 seconds to respond?
A: The future.get(30, TimeUnit.SECONDS) in TunnelIngressFilter throws a TimeoutException. The filter catches it and returns HTTP 504 Gateway Timeout to the external user. The orphaned future is left in the PendingRequestManager map β in production, I'd add a cleanup mechanism, but for the current scope, the 30-second timeout is sufficient.
Q17. Why did you use ExecutorService with a fixed thread pool of 10 in HttpRequestMessageHandler?
A: The WebSocket onMessage callback must return quickly β it runs on the WebSocket I/O thread, and blocking it would prevent other messages from being received. By submitting the local HTTP forwarding work to a separate thread pool, the WebSocket thread is freed immediately. The pool size of 10 limits the concurrent local HTTP requests per client, providing backpressure.
Q18. Is there a race condition between PendingRegistrationManager.register() and complete()?
A: No. The register() call always happens before the CLIENT_REGISTER message is sent, so the CompletableFuture is in place before the server can possibly respond with CLIENT_REGISTERED. The sequence is:
registrationFuture = new CompletableFuture<>() (stored)
tunnelSender.send(CLIENT_REGISTER) (sent)
- Server processes and sends
CLIENT_REGISTERED
ClientRegisteredHandler.handle() calls complete(clientId) (resolved)
future.get() returns
Since step 1 happens-before step 2, the future is always registered before a response can arrive.
6. Interview Questions & Answers β Networking & Protocols
Q19. What are hop-by-hop headers and why do you filter them?
A: Hop-by-hop headers are HTTP headers meant for a single transport-level connection, not for the end-to-end communication. They must not be forwarded by proxies. Examples:
Connection β controls per-connection options
Transfer-Encoding β how the message body is encoded between two nodes
Keep-Alive β per-connection keep-alive parameters
If I forwarded Transfer-Encoding: chunked from the local app to the external user, the server (Tomcat) would try to re-chunk an already-chunked response, corrupting it. So both TunnelIngressFilter (server) and LocalHttpForwarder (client) strip these headers.
Q20. How does subdomain-based routing work at the DNS level?
A: I have a wildcard DNS record: *.tunnel.rajeshbandi.site β server IP. This means any subdomain like abc123.tunnel.rajeshbandi.site resolves to the same server. The server then uses the Host header to differentiate which tunnel the request is for.
Q21. Why do you send the HTTP body as byte[] instead of String?
A: Because HTTP bodies can be binary β images, PDFs, protobuf, gzip-compressed data. Converting to String would corrupt non-text data. byte[] preserves the raw bytes. Jackson serializes byte[] fields as Base64-encoded strings in JSON, so they travel safely over the text-based WebSocket.
Q22. What's the maximum message size your WebSocket supports?
A: I configured it to 10 MB in WebSocketBufferConfig:
container.setMaxTextMessageBufferSize(10 * 1024 * 1024);
container.setMaxBinaryMessageBufferSize(10 * 1024 * 1024);
This is necessary because the entire HTTP request/response body is serialized into the WebSocket text message. For production, I'd implement chunked transfer β splitting large payloads into multiple WebSocket frames.
7. Interview Questions & Answers β Error Handling & Edge Cases
Q23. What happens if the client disconnects mid-request?
A: Two things happen:
- Server side:
TunnelWebSocketHandler.afterConnectionClosed() fires. It calls TunnelManager.removeTunnelByClientId(clientId) to clean up all tunnels, and ClientManager.unregister(session) to clean up the client. Any future HTTP requests to the tunnel will get 404 Tunnel not found.
- In-flight requests: If there are pending futures in
PendingRequestManager, they'll time out after 30 seconds and return 504 to the external user. The OutboundMessageSender virtual thread will also exit because session.isOpen() returns false.
Q24. What if two requests arrive at the same tunnel simultaneously?
A: They're handled correctly because:
- Each request gets a unique
requestId (UUID)
- Each request creates its own
CompletableFuture in PendingRequestManager's ConcurrentHashMap
- The client processes them in parallel via the
ExecutorService in HttpRequestMessageHandler (10 threads)
- Responses are correlated by
requestId, so each future gets the correct response
- The only serialization point is the
OutboundMessageSender queue, but that's by design for thread safety on WebSocket writes
Q25. What if the outbound queue is full?
A: It can't be, because LinkedBlockingQueue has unbounded capacity by default. The offer() call always succeeds. However, in production, I'd use a bounded queue with a capacity limit and handle the offer() returning false by sending 503 Service Unavailable.
Q26. What if the tunnelflow.yaml has a port that no local app is listening on?
A: The tunnel will be created successfully, but when an external request arrives, LocalHttpForwarder will make an HttpClient.send() call to http://localhost:{port}, which will throw a ConnectException. The catch block in HttpRequestMessageHandler logs the error, and the server-side future will eventually timeout with 504.
Q27. How do you handle an unregistered client sending a CREATE_TUNNEL message?
A: In TunnelWebSocketHandler, the CREATE_TUNNEL case first calls clientManager.getClientId(session). If it returns null, the code logs a warning and breaks out of the switch β the message is silently ignored.
8. Interview Questions & Answers β Scalability & Production Readiness
Q28. How would you scale this to handle thousands of concurrent tunnels?
A:
- Horizontal server scaling: Deploy multiple server instances behind a load balancer. Use sticky sessions (by subdomain) so each tunnel's HTTP traffic goes to the server that holds the client's WebSocket connection.
- External state store: Move
TunnelManager and ClientManager data to Redis so any server instance can look up tunnelβclient mappings.
- Message broker: Use Redis Pub/Sub or Kafka to route HTTP requests to the correct server holding the WebSocket connection.
Q29. What's missing for production readiness?
A:
| Gap |
Solution |
| No authentication |
Add API keys or JWT tokens during client registration |
| No rate limiting |
Apply rate limits per tunnel or per client |
| No HTTPS termination |
Use Nginx/Caddy as a reverse proxy for TLS |
| No reconnection logic |
Implement exponential backoff reconnect in the client |
| No chunked transfer for large bodies |
Split large payloads into multiple WebSocket frames |
| No persistence |
Tunnels are in-memory; server restart loses all state |
| No monitoring |
Add metrics (Prometheus), health checks, request logging dashboards |
| No request size limit |
Add max body size validation in the filter |
| Single point of failure |
Horizontal scaling with shared state (Redis) |
| No graceful shutdown |
Drain in-flight requests before server restart |
Q30. How would you add authentication?
A: Add an API key system:
- Client includes an
apiKey field in ClientRegisterRequest
- Server validates it against a database/Redis during the
CLIENT_REGISTER handler
- If invalid, close the WebSocket with a
4001 close code
- If valid, proceed with registration
- For HTTP ingress, the tunnel is already authenticated by being created by a validated client
Q31. How would you add reconnection to the client?
A: In TunnelWebSocketClient.onClose():
@Override
public void onClose(int code, String reason, boolean remote) {
if (remote) {
scheduleReconnect();
}
}
On reconnect, the client would need to re-register and re-create its tunnels. The server would assign new IDs, so the client would need to update its local TunnelRuntimeRegistry.
Q32. How would you implement request/response streaming for large files?
A: Instead of sending the entire body in one TunnelMessage, I'd implement chunked transfer:
- Add a
STREAM_START, STREAM_CHUNK, and STREAM_END message type
TunnelIngressFilter reads the body in chunks (e.g., 64 KB) and sends each as a separate STREAM_CHUNK
- Client reassembles chunks before forwarding to the local app
- Same in reverse for the response
- This keeps WebSocket frame sizes small and avoids the 10 MB buffer limit
9. Interview Questions & Answers β Behavioral / Project-Based
Q33. What was the hardest part of building this project?
A: "The hardest part was bridging synchronous HTTP with asynchronous WebSocket. The server's servlet thread must block and wait for a response that arrives on a completely different thread β the WebSocket handler thread. I solved it using CompletableFuture with a ConcurrentHashMap keyed by request ID. Getting the correlation right, handling timeouts, and ensuring no futures leaked was challenging."
Q34. What design patterns did you use?
A:
| Pattern |
Where |
| Strategy |
MessageHandler interface + TunnelMessageDispatcher map |
| Builder |
Lombok @Builder on all protocol DTOs and runtime objects |
| Factory |
TunnelProtocolService creates TunnelMessage objects; PicocliFactory creates CLI commands |
| Observer / Callback |
WebSocket event handlers (onOpen, onMessage, onClose) |
| Producer-Consumer |
LinkedBlockingQueue in ClientConnection + OutboundMessageSender drain thread |
| Correlation ID |
requestId in TunnelMessage to match async request-response pairs |
| Registry |
TunnelRuntimeRegistry, ApplicationRuntimeRegistry, TunnelManager, ClientManager |
| Template |
ConfigTemplateService generates starter YAML |
Q35. If you had to start over, what would you change?
A:
- "I'd add binary WebSocket frames instead of JSON text for HTTP bodies β Base64 encoding inflates the payload by ~33%."
- "I'd use Netty instead of the Java-WebSocket library on the client side for better performance and control."
- "I'd design for multi-tenancy from day one with proper auth, rate limiting, and usage tracking."
- "I'd add health check pings from the server to proactively detect dead connections instead of waiting for timeout."
Q36. How did you test this project?
A: "I tested it end-to-end by:
- Starting the server on a cloud VM
- Running the client on my local machine with a React app on port 3000
- Accessing the public tunnel URL from my phone's browser
- Testing with various HTTP methods (GET, POST with JSON body, file uploads)
- Testing edge cases: killing the client mid-request, slow local servers, large response bodies"
Q37. Why Java and Spring Boot instead of Go or Node.js?
A: "I chose Java because:
- It's the language I'm most proficient in and wanted to demonstrate deep knowledge
- Spring Boot gave me WebSocket support, dependency injection, and a servlet container out of the box
- Java's
CompletableFuture and ConcurrentHashMap gave me the concurrency primitives I needed
- Virtual threads (Project Loom) let me handle thousands of concurrent connections efficiently
- The strong type system with a shared protocol module prevented serialization bugs"
Q38. Walk me through what happens if I run tunnelflow expose 3000 right now.
A:
- Picocli parses the command,
PicocliFactory creates ExposeCommand from Spring context
ExposeCommand.run() calls ClientTunnelManager.expose(3000)
TunnelConnectionManager.connect():
- Creates
TunnelWebSocketClient with the server URL
- Calls
TunnelSender.attach(client) β links the sender to the live WS client
client.connectBlocking() β TCP + WebSocket handshake
ClientRegistrationService.register():
- Creates
ClientRegisterRequest (machine name, OS, version)
PendingRegistrationManager creates a CompletableFuture<String>
- Sends
CLIENT_REGISTER message via TunnelSender β TunnelWebSocketClient.send()
- Blocks on
future.get() until server responds with CLIENT_REGISTERED
ClientRegisteredHandler resolves the future with the clientId
TunnelCreationService.createTunnel(3000):
PendingTunnelRequestManager creates a CompletableFuture<TunnelCreatedResponse>
- Sends
CREATE_TUNNEL message with localPort: 3000
- Blocks on
future.get() until server responds with TUNNEL_CREATED
TunnelCreatedHandler resolves the future with { tunnelId, publicUrl }
TunnelRuntime is built with { tunnelId, localPort: 3000, publicUrl }
- Registered in
TunnelRuntimeRegistry (so LocalHttpForwarder can look it up later)
- Prints:
Tunnel URL: https://abc123.tunnel.rajeshbandi.site
- The WebSocket stays open,
TunnelMessageReceiver listens for incoming HTTP_REQUEST messages
Q39. What's the difference between tunnelflow app up and tunnelflow expose?
A:
| Feature |
app up |
expose |
| Config file |
Reads tunnelflow.yaml |
No config file |
| Multiple apps |
Provisions tunnels for ALL apps in the config |
Single port only |
| Validation |
Validates version, project, apps |
No validation |
| Process launch |
Starts each app as an OS process via ProcessLauncher |
Does NOT start any process (app must already be running) |
| Env resolution |
Resolves ${app.url} placeholders across services |
No placeholder support |
| Orchestrator |
ApplicationManager |
ClientTunnelManager |
Q40. How does PlaceholderResolver work?
A: It enables cross-service URL injection. If your tunnelflow.yaml looks like:
apps:
backend:
port: 8080
command: "java -jar app.jar"
env:
FRONTEND_URL: "${frontend.url}"
frontend:
port: 3000
command: "npm start"
env:
API_URL: "${backend.url}"
After provisioning tunnels, PlaceholderResolver scans all env values. When it finds ${frontend.url}, it looks up the frontend deployment's TunnelRuntime.publicUrl and substitutes it. So the backend process starts with FRONTEND_URL=https://xyz.tunnel.rajeshbandi.site in its environment.
Q41. Explain the ClientConnection class and its role.
A: ClientConnection is a data holder that bundles two things per connected client:
- The
WebSocketSession β the live connection to the client
- A
LinkedBlockingQueue<TunnelMessage> β an outbound message buffer
This design decouples message production (any thread can enqueue messages) from message consumption (a single virtual thread drains the queue and writes to the session). Without this, multiple threads writing to the session simultaneously would corrupt the WebSocket stream.
Q42. Why ConcurrentHashMap everywhere instead of synchronized?
A: ConcurrentHashMap provides lock-free reads and fine-grained segment locking for writes. In a tunneling server, reads vastly outnumber writes:
- Every HTTP request reads from
TunnelManager and ClientManager
- Writes only happen during registration/disconnection
Using synchronized on a regular HashMap would serialize ALL operations behind a single lock, creating a bottleneck. ConcurrentHashMap lets concurrent reads proceed without blocking.
Q43. What's the role of HttpRequestMapper?
A: It converts a jakarta.servlet.http.HttpServletRequest into the portable HttpRequestMessage DTO:
- Iterates all headers via
request.getHeaderNames() enumeration
- Reads the body as
byte[]
- Captures method, path, and query string
This abstraction lets the protocol message be a simple POJO, completely decoupled from the servlet API.
Q44. What would happen if the server restarts?
A: All state is lost:
TunnelManager (ConcurrentHashMap) β all tunnel mappings gone
ClientManager β all client sessions gone
PendingRequestManager β all in-flight requests get no response
The client's WebSocket connection would close, triggering onClose(). Currently there's no reconnect logic, so the user would have to restart the client. For production, I'd persist tunnel state in Redis and add auto-reconnect on the client.
Q45. How many concurrent requests can one tunnel handle?
A: Bounded by several factors:
- Client-side:
HttpRequestMessageHandler has a thread pool of 10, so 10 local HTTP calls in parallel
- Server-side:
OutboundMessageSender is single-threaded per client (serialized WebSocket writes), but enqueuing is concurrent
- WebSocket bandwidth: 10 MB max message size, network throughput
- Filter threads: Tomcat's thread pool (default 200), each blocking on
future.get()
The practical bottleneck is the client's 10-thread pool for local forwarding.
Q46. Describe the complete lifecycle of a TunnelMessage.
A: Taking HTTP_REQUEST as an example:
- Created by
TunnelProtocolService.createHttpRequestTunnelMessage() on the server
- Enqueued into
ClientConnection.outboundQueue by TunnelIngressFilter
- Dequeued by
OutboundMessageSender virtual thread
- Serialized to JSON via
ObjectMapper.writeValueAsString()
- Sent over WebSocket as a
TextMessage
- Received by
TunnelWebSocketClient.onMessage(json)
- Deserialized by
TunnelMessageReceiver β ObjectMapper.readValue(json, TunnelMessage.class)
- Dispatched by
TunnelMessageDispatcher β HttpRequestMessageHandler
- Payload extracted β
ObjectMapper.readValue(payload, HttpRequestMessage.class)
- Processed β forwarded to localhost by
LocalHttpForwarder
- Discarded β no further reference
Q47. What's the purpose of ApplicationDeployment?
A: It's a mutable deployment descriptor that enriches the immutable ApplicationConfig (parsed from YAML) with runtime information:
config β the original YAML config (name, port, command, path, env)
tunnelRuntime β set after tunnel provisioning (tunnelId, publicUrl, localPort)
resolvedEnvironment β a mutable LinkedHashMap where PlaceholderResolver writes the final environment variables
It acts as a staging area between configuration parsing and process launch.
Q48. How does the server differentiate between tunnel traffic and regular traffic?
A: In TunnelIngressFilter.doFilterInternal():
/ws paths β pass through (WebSocket upgrade)
/error paths β pass through (Spring error handling)
- Host doesn't end with
.tunnel.rajeshbandi.site β pass through (regular API endpoints)
- Host does end with
.tunnel.rajeshbandi.site β tunnel traffic, extract tunnelId and process
Q49. If you were asked to add WebSocket binary frame support, how would you do it?
A:
- Change
TunnelWebSocketHandler to extend BinaryWebSocketHandler instead of TextWebSocketHandler
- Use a binary serialization format like Protocol Buffers or MessagePack instead of JSON
- Send HTTP bodies directly as binary instead of Base64-encoded JSON strings
- This would reduce payload size by ~33% and improve throughput
- On the client, switch
TunnelWebSocketClient to use binary frames via the java-websocket library's send(byte[]) method
Q50. Summarize your project in one sentence for a resume.
A: "Built a reverse tunneling system (like ngrok) in Java/Spring Boot that exposes local development servers to the internet via WebSocket-based full-duplex communication, using CompletableFuture correlation for bridging synchronous HTTP with asynchronous message passing."